feat: Minecraft theme overhaul, fix points bug, add history
这个提交包含在:
@@ -10,40 +10,59 @@ class MeController : public drogon::HttpController<MeController> {
|
|||||||
public:
|
public:
|
||||||
METHOD_LIST_BEGIN
|
METHOD_LIST_BEGIN
|
||||||
ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get);
|
ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get);
|
||||||
ADD_METHOD_TO(MeController::listRedeemItems, "/api/v1/me/redeem/items", drogon::Get);
|
ADD_METHOD_TO(MeController::listRedeemItems, "/api/v1/me/redeem/items",
|
||||||
ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records", drogon::Get);
|
drogon::Get);
|
||||||
ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records", drogon::Post);
|
ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records",
|
||||||
ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks", drogon::Get);
|
drogon::Get);
|
||||||
ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book", drogon::Get);
|
ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records",
|
||||||
ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}", drogon::Patch);
|
drogon::Post);
|
||||||
ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}", drogon::Delete);
|
ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks",
|
||||||
|
drogon::Get);
|
||||||
|
ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book",
|
||||||
|
drogon::Get);
|
||||||
|
ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}",
|
||||||
|
drogon::Patch);
|
||||||
|
ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}",
|
||||||
|
drogon::Delete);
|
||||||
|
ADD_METHOD_TO(MeController::listRatingHistory, "/api/v1/me/rating-history",
|
||||||
|
drogon::Get);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
void profile(const drogon::HttpRequestPtr &req,
|
void profile(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||||
|
|
||||||
void listRedeemItems(const drogon::HttpRequestPtr& req,
|
void
|
||||||
|
listRedeemItems(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||||
|
|
||||||
void listRedeemRecords(const drogon::HttpRequestPtr& req,
|
void
|
||||||
|
listRedeemRecords(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||||
|
|
||||||
void createRedeemRecord(const drogon::HttpRequestPtr& req,
|
void
|
||||||
|
createRedeemRecord(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||||
|
|
||||||
void listDailyTasks(const drogon::HttpRequestPtr& req,
|
void
|
||||||
|
listDailyTasks(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||||
|
|
||||||
void listWrongBook(const drogon::HttpRequestPtr &req,
|
void listWrongBook(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||||
|
|
||||||
void upsertWrongBookNote(const drogon::HttpRequestPtr& req,
|
void
|
||||||
|
upsertWrongBookNote(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||||
int64_t problem_id);
|
int64_t problem_id);
|
||||||
|
|
||||||
void deleteWrongBookItem(const drogon::HttpRequestPtr& req,
|
void
|
||||||
|
deleteWrongBookItem(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||||
int64_t problem_id);
|
int64_t problem_id);
|
||||||
|
|
||||||
|
void
|
||||||
|
listRatingHistory(const drogon::HttpRequestPtr &req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace csp::controllers
|
} // namespace csp::controllers
|
||||||
|
|||||||
@@ -5,9 +5,17 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace csp::services {
|
namespace csp::services {
|
||||||
|
|
||||||
|
struct SolutionViewStats {
|
||||||
|
int total_views = 0;
|
||||||
|
int total_cost = 0;
|
||||||
|
std::optional<int64_t> last_viewed_at;
|
||||||
|
bool has_viewed = false;
|
||||||
|
};
|
||||||
|
|
||||||
struct SolutionViewChargeResult {
|
struct SolutionViewChargeResult {
|
||||||
bool granted = true;
|
bool granted = true;
|
||||||
bool charged = false;
|
bool charged = false;
|
||||||
@@ -21,21 +29,26 @@ struct SolutionViewChargeResult {
|
|||||||
std::string deny_reason;
|
std::string deny_reason;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct SolutionViewStats {
|
struct RatingHistoryItem {
|
||||||
bool has_viewed = false;
|
std::string type;
|
||||||
int total_views = 0;
|
int64_t created_at;
|
||||||
int total_cost = 0;
|
int change;
|
||||||
std::optional<int64_t> last_viewed_at;
|
std::string note;
|
||||||
};
|
};
|
||||||
|
|
||||||
class SolutionAccessService {
|
class SolutionAccessService {
|
||||||
public:
|
public:
|
||||||
explicit SolutionAccessService(db::SqliteDb &db) : db_(db) {}
|
explicit SolutionAccessService(db::SqliteDb &db) : db_(db) {}
|
||||||
|
|
||||||
// Daily policy: first answer view is free, then each full view costs 2 rating.
|
// Daily policy: first answer view is free, then each full view costs 2
|
||||||
SolutionViewChargeResult ConsumeSolutionView(int64_t user_id, int64_t problem_id);
|
// rating.
|
||||||
|
SolutionViewChargeResult ConsumeSolutionView(int64_t user_id,
|
||||||
|
int64_t problem_id);
|
||||||
|
|
||||||
SolutionViewStats QueryUserProblemViewStats(int64_t user_id, int64_t problem_id);
|
SolutionViewStats QueryUserProblemViewStats(int64_t user_id,
|
||||||
|
int64_t problem_id);
|
||||||
|
|
||||||
|
std::vector<RatingHistoryItem> ListRatingHistory(int64_t user_id, int limit);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
db::SqliteDb &db_;
|
db::SqliteDb &db_;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "csp/domain/json.h"
|
#include "csp/domain/json.h"
|
||||||
#include "csp/services/daily_task_service.h"
|
#include "csp/services/daily_task_service.h"
|
||||||
#include "csp/services/redeem_service.h"
|
#include "csp/services/redeem_service.h"
|
||||||
|
#include "csp/services/solution_access_service.h"
|
||||||
#include "csp/services/user_service.h"
|
#include "csp/services/user_service.h"
|
||||||
#include "csp/services/wrong_book_service.h"
|
#include "csp/services/wrong_book_service.h"
|
||||||
#include "http_auth.h"
|
#include "http_auth.h"
|
||||||
@@ -37,7 +38,8 @@ drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req,
|
std::optional<int64_t>
|
||||||
|
RequireAuth(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &cb) {
|
std::function<void(const drogon::HttpResponsePtr &)> &cb) {
|
||||||
std::string auth_error;
|
std::string auth_error;
|
||||||
const auto user_id = GetAuthedUserId(req, auth_error);
|
const auto user_id = GetAuthedUserId(req, auth_error);
|
||||||
@@ -48,11 +50,10 @@ std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req,
|
|||||||
return user_id;
|
return user_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
int ParseClampedInt(const std::string& s,
|
int ParseClampedInt(const std::string &s, int default_value, int min_value,
|
||||||
int default_value,
|
|
||||||
int min_value,
|
|
||||||
int max_value) {
|
int max_value) {
|
||||||
if (s.empty()) return default_value;
|
if (s.empty())
|
||||||
|
return default_value;
|
||||||
const int value = std::stoi(s);
|
const int value = std::stoi(s);
|
||||||
return std::max(min_value, std::min(max_value, value));
|
return std::max(min_value, std::min(max_value, value));
|
||||||
}
|
}
|
||||||
@@ -64,7 +65,8 @@ void MeController::profile(
|
|||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
||||||
try {
|
try {
|
||||||
const auto user_id = RequireAuth(req, cb);
|
const auto user_id = RequireAuth(req, cb);
|
||||||
if (!user_id.has_value()) return;
|
if (!user_id.has_value())
|
||||||
|
return;
|
||||||
|
|
||||||
services::UserService users(csp::AppState::Instance().db());
|
services::UserService users(csp::AppState::Instance().db());
|
||||||
const auto user = users.GetById(*user_id);
|
const auto user = users.GetById(*user_id);
|
||||||
@@ -83,7 +85,8 @@ void MeController::listRedeemItems(
|
|||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
||||||
try {
|
try {
|
||||||
if (!RequireAuth(req, cb).has_value()) return;
|
if (!RequireAuth(req, cb).has_value())
|
||||||
|
return;
|
||||||
|
|
||||||
services::RedeemService redeem(csp::AppState::Instance().db());
|
services::RedeemService redeem(csp::AppState::Instance().db());
|
||||||
const auto items = redeem.ListItems(false);
|
const auto items = redeem.ListItems(false);
|
||||||
@@ -114,7 +117,8 @@ void MeController::listRedeemRecords(
|
|||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
||||||
try {
|
try {
|
||||||
const auto user_id = RequireAuth(req, cb);
|
const auto user_id = RequireAuth(req, cb);
|
||||||
if (!user_id.has_value()) return;
|
if (!user_id.has_value())
|
||||||
|
return;
|
||||||
|
|
||||||
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
|
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
|
||||||
services::RedeemService redeem(csp::AppState::Instance().db());
|
services::RedeemService redeem(csp::AppState::Instance().db());
|
||||||
@@ -150,7 +154,8 @@ void MeController::createRedeemRecord(
|
|||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
||||||
try {
|
try {
|
||||||
const auto user_id = RequireAuth(req, cb);
|
const auto user_id = RequireAuth(req, cb);
|
||||||
if (!user_id.has_value()) return;
|
if (!user_id.has_value())
|
||||||
|
return;
|
||||||
|
|
||||||
const auto json = req->getJsonObject();
|
const auto json = req->getJsonObject();
|
||||||
if (!json) {
|
if (!json) {
|
||||||
@@ -198,7 +203,8 @@ void MeController::listDailyTasks(
|
|||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
||||||
try {
|
try {
|
||||||
const auto user_id = RequireAuth(req, cb);
|
const auto user_id = RequireAuth(req, cb);
|
||||||
if (!user_id.has_value()) return;
|
if (!user_id.has_value())
|
||||||
|
return;
|
||||||
|
|
||||||
services::DailyTaskService tasks(csp::AppState::Instance().db());
|
services::DailyTaskService tasks(csp::AppState::Instance().db());
|
||||||
const auto rows = tasks.ListTodayTasks(*user_id);
|
const auto rows = tasks.ListTodayTasks(*user_id);
|
||||||
@@ -239,7 +245,8 @@ void MeController::listWrongBook(
|
|||||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
||||||
try {
|
try {
|
||||||
const auto user_id = RequireAuth(req, cb);
|
const auto user_id = RequireAuth(req, cb);
|
||||||
if (!user_id.has_value()) return;
|
if (!user_id.has_value())
|
||||||
|
return;
|
||||||
|
|
||||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||||
const auto rows = wrong_book.ListByUser(*user_id);
|
const auto rows = wrong_book.ListByUser(*user_id);
|
||||||
@@ -263,7 +270,8 @@ void MeController::upsertWrongBookNote(
|
|||||||
int64_t problem_id) {
|
int64_t problem_id) {
|
||||||
try {
|
try {
|
||||||
const auto user_id = RequireAuth(req, cb);
|
const auto user_id = RequireAuth(req, cb);
|
||||||
if (!user_id.has_value()) return;
|
if (!user_id.has_value())
|
||||||
|
return;
|
||||||
|
|
||||||
const auto json = req->getJsonObject();
|
const auto json = req->getJsonObject();
|
||||||
if (!json) {
|
if (!json) {
|
||||||
@@ -296,7 +304,8 @@ void MeController::deleteWrongBookItem(
|
|||||||
int64_t problem_id) {
|
int64_t problem_id) {
|
||||||
try {
|
try {
|
||||||
const auto user_id = RequireAuth(req, cb);
|
const auto user_id = RequireAuth(req, cb);
|
||||||
if (!user_id.has_value()) return;
|
if (!user_id.has_value())
|
||||||
|
return;
|
||||||
|
|
||||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||||
wrong_book.Remove(*user_id, problem_id);
|
wrong_book.Remove(*user_id, problem_id);
|
||||||
@@ -311,4 +320,30 @@ void MeController::deleteWrongBookItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MeController::listRatingHistory(
|
||||||
|
const drogon::HttpRequestPtr &req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
||||||
|
try {
|
||||||
|
const auto user_id = RequireAuth(req, cb);
|
||||||
|
if (!user_id.has_value())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
|
||||||
|
services::SolutionAccessService access_svc(csp::AppState::Instance().db());
|
||||||
|
const auto rows = access_svc.ListRatingHistory(*user_id, limit);
|
||||||
|
|
||||||
|
Json::Value arr(Json::arrayValue);
|
||||||
|
for (const auto &row : rows) {
|
||||||
|
Json::Value j;
|
||||||
|
j["type"] = row.type;
|
||||||
|
j["created_at"] = Json::Int64(row.created_at);
|
||||||
|
j["change"] = row.change;
|
||||||
|
j["note"] = row.note;
|
||||||
|
arr.append(j);
|
||||||
|
}
|
||||||
|
cb(JsonOk(arr));
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||||
|
}
|
||||||
|
}
|
||||||
} // namespace csp::controllers
|
} // namespace csp::controllers
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ int64_t NowSec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void CheckSqlite(int rc, sqlite3 *db, const char *what) {
|
void CheckSqlite(int rc, sqlite3 *db, const char *what) {
|
||||||
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
|
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE)
|
||||||
|
return;
|
||||||
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
|
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,36 +55,33 @@ int QueryRating(sqlite3* db, int64_t user_id) {
|
|||||||
|
|
||||||
int QueryDailyUsage(sqlite3 *db, int64_t user_id, const std::string &day_key) {
|
int QueryDailyUsage(sqlite3 *db, int64_t user_id, const std::string &day_key) {
|
||||||
sqlite3_stmt *stmt = nullptr;
|
sqlite3_stmt *stmt = nullptr;
|
||||||
const char* sql =
|
const char *sql = "SELECT COUNT(1) FROM problem_solution_view_logs WHERE "
|
||||||
"SELECT COUNT(1) FROM problem_solution_view_logs WHERE user_id=? AND day_key=?";
|
"user_id=? AND day_key=?";
|
||||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
"prepare query daily usage");
|
"prepare query daily usage");
|
||||||
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||||
CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
|
CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
"bind day_key");
|
db, "bind day_key");
|
||||||
CheckSqlite(sqlite3_step(stmt), db, "step query daily usage");
|
CheckSqlite(sqlite3_step(stmt), db, "step query daily usage");
|
||||||
const int used = sqlite3_column_int(stmt, 0);
|
const int used = sqlite3_column_int(stmt, 0);
|
||||||
sqlite3_finalize(stmt);
|
sqlite3_finalize(stmt);
|
||||||
return used;
|
return used;
|
||||||
}
|
}
|
||||||
|
|
||||||
void InsertViewLog(sqlite3* db,
|
void InsertViewLog(sqlite3 *db, int64_t user_id, int64_t problem_id,
|
||||||
int64_t user_id,
|
const std::string &day_key, int64_t viewed_at, bool charged,
|
||||||
int64_t problem_id,
|
|
||||||
const std::string& day_key,
|
|
||||||
int64_t viewed_at,
|
|
||||||
bool charged,
|
|
||||||
int cost) {
|
int cost) {
|
||||||
sqlite3_stmt *stmt = nullptr;
|
sqlite3_stmt *stmt = nullptr;
|
||||||
const char* sql =
|
const char *sql = "INSERT INTO "
|
||||||
"INSERT INTO problem_solution_view_logs(user_id,problem_id,day_key,viewed_at,charged,cost,created_at) "
|
"problem_solution_view_logs(user_id,problem_id,day_key,"
|
||||||
|
"viewed_at,charged,cost,created_at) "
|
||||||
"VALUES(?,?,?,?,?,?,?)";
|
"VALUES(?,?,?,?,?,?,?)";
|
||||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
"prepare insert solution view log");
|
"prepare insert solution view log");
|
||||||
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||||
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
|
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
|
||||||
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
|
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
"bind day_key");
|
db, "bind day_key");
|
||||||
CheckSqlite(sqlite3_bind_int64(stmt, 4, viewed_at), db, "bind viewed_at");
|
CheckSqlite(sqlite3_bind_int64(stmt, 4, viewed_at), db, "bind viewed_at");
|
||||||
CheckSqlite(sqlite3_bind_int(stmt, 5, charged ? 1 : 0), db, "bind charged");
|
CheckSqlite(sqlite3_bind_int(stmt, 5, charged ? 1 : 0), db, "bind charged");
|
||||||
CheckSqlite(sqlite3_bind_int(stmt, 6, cost), db, "bind cost");
|
CheckSqlite(sqlite3_bind_int(stmt, 6, cost), db, "bind cost");
|
||||||
@@ -105,8 +103,24 @@ void DeductRating(sqlite3* db, int64_t user_id, int cost) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
|
// ... (Helper functions)
|
||||||
int64_t user_id,
|
|
||||||
|
bool QueryHasPurchased(sqlite3 *db, int64_t user_id, int64_t problem_id) {
|
||||||
|
sqlite3_stmt *stmt = nullptr;
|
||||||
|
const char *sql = "SELECT COUNT(1) FROM problem_solution_view_logs "
|
||||||
|
"WHERE user_id=? AND problem_id=? AND charged=1";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare query has purchased");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
|
||||||
|
CheckSqlite(sqlite3_step(stmt), db, "step query has purchased");
|
||||||
|
const int count = sqlite3_column_int(stmt, 0);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
SolutionViewChargeResult
|
||||||
|
SolutionAccessService::ConsumeSolutionView(int64_t user_id,
|
||||||
int64_t problem_id) {
|
int64_t problem_id) {
|
||||||
if (user_id <= 0 || problem_id <= 0) {
|
if (user_id <= 0 || problem_id <= 0) {
|
||||||
throw std::runtime_error("invalid user_id/problem_id");
|
throw std::runtime_error("invalid user_id/problem_id");
|
||||||
@@ -123,6 +137,26 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
|
|||||||
result.day_key = day_key;
|
result.day_key = day_key;
|
||||||
result.viewed_at = now;
|
result.viewed_at = now;
|
||||||
|
|
||||||
|
// Check if already purchased (charged=1 before)
|
||||||
|
if (QueryHasPurchased(db, user_id, problem_id)) {
|
||||||
|
result.daily_used_count =
|
||||||
|
QueryDailyUsage(db, user_id, day_key); // Just for info
|
||||||
|
result.rating_before = QueryRating(db, user_id);
|
||||||
|
result.charged = false;
|
||||||
|
result.daily_free = false; // Not "free" quota, but "owned"
|
||||||
|
result.cost = 0;
|
||||||
|
result.rating_after = result.rating_before;
|
||||||
|
result.granted = true;
|
||||||
|
|
||||||
|
// We still log this view, but charged=0, cost=0.
|
||||||
|
// This keeps track of "views" even if free.
|
||||||
|
InsertViewLog(db, user_id, problem_id, day_key, now, false, 0);
|
||||||
|
|
||||||
|
db_.Exec("COMMIT");
|
||||||
|
committed = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const int used_before = QueryDailyUsage(db, user_id, day_key);
|
const int used_before = QueryDailyUsage(db, user_id, day_key);
|
||||||
result.daily_used_count = used_before;
|
result.daily_used_count = used_before;
|
||||||
const int rating_before = QueryRating(db, user_id);
|
const int rating_before = QueryRating(db, user_id);
|
||||||
@@ -150,12 +184,7 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
|
|||||||
DeductRating(db, user_id, kViewCost);
|
DeductRating(db, user_id, kViewCost);
|
||||||
}
|
}
|
||||||
|
|
||||||
InsertViewLog(db,
|
InsertViewLog(db, user_id, problem_id, day_key, now, result.charged,
|
||||||
user_id,
|
|
||||||
problem_id,
|
|
||||||
day_key,
|
|
||||||
now,
|
|
||||||
result.charged,
|
|
||||||
result.cost);
|
result.cost);
|
||||||
result.daily_used_count = used_before + 1;
|
result.daily_used_count = used_before + 1;
|
||||||
|
|
||||||
@@ -173,11 +202,12 @@ SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SolutionViewStats SolutionAccessService::QueryUserProblemViewStats(
|
SolutionViewStats
|
||||||
int64_t user_id,
|
SolutionAccessService::QueryUserProblemViewStats(int64_t user_id,
|
||||||
int64_t problem_id) {
|
int64_t problem_id) {
|
||||||
SolutionViewStats stats;
|
SolutionViewStats stats;
|
||||||
if (user_id <= 0 || problem_id <= 0) return stats;
|
if (user_id <= 0 || problem_id <= 0)
|
||||||
|
return stats;
|
||||||
|
|
||||||
sqlite3 *db = db_.raw();
|
sqlite3 *db = db_.raw();
|
||||||
sqlite3_stmt *stmt = nullptr;
|
sqlite3_stmt *stmt = nullptr;
|
||||||
@@ -200,4 +230,49 @@ SolutionViewStats SolutionAccessService::QueryUserProblemViewStats(
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<RatingHistoryItem>
|
||||||
|
SolutionAccessService::ListRatingHistory(int64_t user_id, int limit) {
|
||||||
|
if (limit <= 0)
|
||||||
|
limit = 100;
|
||||||
|
if (limit > 500)
|
||||||
|
limit = 500;
|
||||||
|
|
||||||
|
std::vector<RatingHistoryItem> items;
|
||||||
|
sqlite3 *db = db_.raw();
|
||||||
|
sqlite3_stmt *stmt = nullptr;
|
||||||
|
|
||||||
|
// Union query for history
|
||||||
|
const char *sql =
|
||||||
|
"SELECT 'solution_view' as type, created_at, -cost as change, ('Problem "
|
||||||
|
"' || problem_id) as note "
|
||||||
|
"FROM problem_solution_view_logs WHERE user_id=? AND cost > 0 "
|
||||||
|
"UNION ALL "
|
||||||
|
"SELECT 'daily_task' as type, created_at, reward as change, title as "
|
||||||
|
"note "
|
||||||
|
"FROM daily_task_logs WHERE user_id=? "
|
||||||
|
"UNION ALL "
|
||||||
|
"SELECT 'redeem' as type, created_at, -total_cost as change, item_name "
|
||||||
|
"as note "
|
||||||
|
"FROM redeem_logs WHERE user_id=? "
|
||||||
|
"ORDER BY created_at DESC LIMIT ?";
|
||||||
|
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare history query");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id 1");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id 2");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 3, user_id), db, "bind user_id 3");
|
||||||
|
CheckSqlite(sqlite3_bind_int(stmt, 4, limit), db, "bind limit");
|
||||||
|
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
RatingHistoryItem item;
|
||||||
|
item.type = ColText(stmt, 0);
|
||||||
|
item.created_at = sqlite3_column_int64(stmt, 1);
|
||||||
|
item.change = sqlite3_column_int(stmt, 2);
|
||||||
|
item.note = ColText(stmt, 3);
|
||||||
|
items.push_back(item);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace csp::services
|
} // namespace csp::services
|
||||||
|
|||||||
56
frontend/GAMEPLAY.md
普通文件
56
frontend/GAMEPLAY.md
普通文件
@@ -0,0 +1,56 @@
|
|||||||
|
# CSP Adventure Guide
|
||||||
|
|
||||||
|
Welcome to the CSP Adventure Server! This document explains the new gameplay mechanics and terminology used in the platform.
|
||||||
|
|
||||||
|
## 🗺️ The World (Interface)
|
||||||
|
|
||||||
|
The entire platform has been re-enchanted with a **Minecraft-themed** interface.
|
||||||
|
- **Font**: Pixelated fonts (`Press Start 2P`, `VT323`) for that authentic retro feel.
|
||||||
|
- **Blocks**: Buttons and panels are styled like in-game blocks (Wood, Stone, Obsidian).
|
||||||
|
- **Day/Night**: Theme follows your system preference, but "Dark Mode" is the recommended "Cave" experience.
|
||||||
|
|
||||||
|
## ⚔️ Quest Board (Problem List)
|
||||||
|
|
||||||
|
The **Quest Board** is where you find your challenges.
|
||||||
|
- **Wood Tier (Levels 1-2)**: 🪵 Beginner tasks. Chop wood, build a crafting table.
|
||||||
|
- **Stone Tier (Levels 3-4)**: 🪨 Intermediate logic. Stone tools required.
|
||||||
|
- **Iron Tier (Levels 5-6)**: ⚔️ Hard algorithms. Iron armor recommended.
|
||||||
|
- **Diamond Tier (Levels 7-8)**: 💎 Expert challenges. Bring potions.
|
||||||
|
- **Netherite Tier (Levels 9+)**: 🔥 Legendary problems. Only for the bravest adventurers.
|
||||||
|
|
||||||
|
## 📜 Mission Scroll (Problem Detail)
|
||||||
|
|
||||||
|
When you accept a quest, you receive a **Mission Scroll**.
|
||||||
|
- **Objective**: Read the parchment to understand the task.
|
||||||
|
- **Enchantment Table (Editor)**: Use the obsidian table to write your spells (code).
|
||||||
|
- **Runes (Input/Output)**: Test your spells with input blocks.
|
||||||
|
- **Casting (Run/Submit)**:
|
||||||
|
- **Test Run**: Cast a test spell to see if it fizzles.
|
||||||
|
- **Submit Quest**: Offer your solution to the server for judgment.
|
||||||
|
|
||||||
|
## 🛡️ Character Sheet (Profile)
|
||||||
|
|
||||||
|
Your **Profile** is your inventory and status screen.
|
||||||
|
- **Avatar**: A unique pixel-art avatar generated from your soul signature (username).
|
||||||
|
- **Level**: Your coding proficiency level, calculated from your total Rating (XP).
|
||||||
|
- **Rank**:
|
||||||
|
- **Wood Rank**: < 1000 Rating
|
||||||
|
- **Stone Rank**: 1000 - 1199
|
||||||
|
- **Iron Rank**: 1200 - 1499
|
||||||
|
- **Diamond Rank**: 1500 - 1999
|
||||||
|
- **Netherite Rank**: 2000+
|
||||||
|
- **Daily Quests**: Complete daily tasks (Sign-in, First Blood, Craftsman) to earn extra XP.
|
||||||
|
|
||||||
|
## 💰 Trading Post (Redeem)
|
||||||
|
|
||||||
|
Visit the **Villager Trading Post** to spend your hard-earned Rating (Emeralds).
|
||||||
|
- Exchange Rating for tangible rewards or special server privileges.
|
||||||
|
- Watch out for "Holiday Prices" vs "Workday Prices"!
|
||||||
|
|
||||||
|
## 🏹 Raids (Contests)
|
||||||
|
|
||||||
|
**Raids** are timed competitive events.
|
||||||
|
- Join a Raid to compete against other adventurers.
|
||||||
|
- Climb the **Leaderboard** to prove your strength.
|
||||||
|
|
||||||
|
Happy Coding, Adventurer!
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
# Frontend (Next.js)
|
# Frontend (Next.js) - Minecraft Edition 🧱
|
||||||
|
|
||||||
|
This project now features a full **Minecraft-themed UI overhaul**!
|
||||||
|
|
||||||
|
📖 **[Read the Gameplay Guide](./GAMEPLAY.md)** for details on the RPG mechanics, Quest Board, and more.
|
||||||
|
|
||||||
|
## Theme Features
|
||||||
|
- **8-bit Aesthetic**: Uses `Press Start 2P` and `VT323` fonts.
|
||||||
|
- **RPG Terminology**: Problems are "Quests", Submissions are "Spells".
|
||||||
|
- **Gamification**: XP Bar, Levels, and Trading Post.
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -28,16 +37,16 @@ npm run start
|
|||||||
## 页面
|
## 页面
|
||||||
|
|
||||||
- `/auth` 登录/注册
|
- `/auth` 登录/注册
|
||||||
- `/problems` 题库列表
|
- `/problems` Quest Board (Questions)
|
||||||
- `/problems/:id` 题目详情与提交
|
- `/problems/:id` 题目详情与提交
|
||||||
- `/submissions` 提交列表
|
- `/submissions` Spell History (Submissions)
|
||||||
- `/submissions/:id` 提交详情
|
- `/submissions/:id` 提交详情
|
||||||
- `/wrong-book` 错题本
|
- `/wrong-book` Cursed Tome (Wrong Book)
|
||||||
- `/contests` 模拟竞赛列表
|
- `/contests` Raid Board (Contests)
|
||||||
- `/contests/:id` 比赛详情/报名/排行榜
|
- `/contests/:id` 比赛详情/报名/排行榜
|
||||||
- `/kb` 知识库列表
|
- `/kb` 知识库列表
|
||||||
- `/kb/:slug` 文章详情
|
- `/kb/:slug` 文章详情
|
||||||
- `/imports` 题库导入任务状态与结果
|
- `/imports` 题库导入任务状态与结果
|
||||||
- `/run` 在线 C++ 运行
|
- `/run` 在线 C++ 运行
|
||||||
- `/me` 当前用户信息
|
- `/me` 当前用户信息
|
||||||
- `/leaderboard` 全站排行
|
- `/leaderboard` Hall of Fame (Rankings)
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ type AuthErr = { ok: false; error: string };
|
|||||||
type AuthResp = AuthOk | AuthErr;
|
type AuthResp = AuthOk | AuthErr;
|
||||||
|
|
||||||
function passwordScore(password: string, isZh: boolean): { label: string; color: string } {
|
function passwordScore(password: string, isZh: boolean): { label: string; color: string } {
|
||||||
if (password.length >= 12) return { label: isZh ? "强" : "Strong", color: "text-emerald-600" };
|
if (password.length >= 12) return { label: isZh ? "钻石级" : "Diamond Tier", color: "text-[color:var(--mc-diamond)]" };
|
||||||
if (password.length >= 8) return { label: isZh ? "中" : "Medium", color: "text-blue-600" };
|
if (password.length >= 8) return { label: isZh ? "铁级" : "Iron Tier", color: "text-zinc-400" };
|
||||||
return { label: isZh ? "弱" : "Weak", color: "text-orange-600" };
|
return { label: isZh ? "木级" : "Wood Tier", color: "text-[color:var(--mc-wood)]" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthPage() {
|
export default function AuthPage() {
|
||||||
@@ -83,27 +83,30 @@ export default function AuthPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-10">
|
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-10">
|
||||||
<div className="grid gap-6 md:grid-cols-[1.1fr,1fr]">
|
<div className="grid gap-6 md:grid-cols-[1.1fr,1fr]">
|
||||||
<section className="rounded-2xl border bg-zinc-900 p-6 text-zinc-100">
|
<section className="rounded-none border-[3px] border-black bg-[color:var(--mc-obsidian)] p-6 text-zinc-100 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
<h1 className="text-2xl font-semibold">{tx("欢迎回来,开始刷题", "Welcome back, let's practice")}</h1>
|
<h1 className="text-2xl font-bold text-[color:var(--mc-diamond)] mc-text-shadow leading-relaxed">
|
||||||
<p className="mt-3 text-sm text-zinc-300">
|
{tx("欢迎回来,冒险者!", "Welcome Back, Adventurer!")}
|
||||||
{tx("登录后可提交评测、保存草稿、查看错题本和个人进度。", "After sign-in you can submit, save drafts, review wrong-book, and track your progress.")}
|
</h1>
|
||||||
|
<p className="mt-3 text-sm text-[color:var(--mc-stone)] font-mono">
|
||||||
|
{tx("登录服务器以访问任务布告栏、保存冒险进度、查看错题卷轴和个人成就。", "Login to access Quest Board, save Game Progress, review Grimoire, and track Achievements.")}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 space-y-2 text-sm text-zinc-300">
|
<div className="mt-6 space-y-2 text-sm text-[color:var(--mc-stone)] font-mono">
|
||||||
<p>{tx("• 题库按 CSP-J / CSP-S / NOIP 入门组织", "• Problem sets are organized by CSP-J / CSP-S / NOIP junior")}</p>
|
<p>{tx("• 任务按 CSP-J / CSP-S / NOIP 难度分级", "• Quests organized by CSP-J / CSP-S / NOIP Tiers")}</p>
|
||||||
<p>{tx("• 题目页支持本地草稿与试运行", "• Problem page supports local draft and run")}</p>
|
<p>{tx("• 任务卷轴支持本地草稿与试炼运行", "• Quest Scrolls support local drafting and trial runs")}</p>
|
||||||
<p>{tx("• 生成式题解会异步入库,支持多解法", "• Generated solutions are queued asynchronously with multiple methods")}</p>
|
<p>{tx("• 先知题解异步生成,包含多种解法", "• Oracles provide asynchronous wisdom with multiple paths")}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-6 text-xs text-zinc-400">
|
<p className="mt-6 text-xs text-[color:var(--mc-stone-dark)]">
|
||||||
API Base: <span className="font-mono">{apiBase}</span>
|
Server API: <span className="font-mono text-[color:var(--mc-red)]">{apiBase}</span>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-2xl border bg-white p-6">
|
<section className="rounded-none border-[3px] border-black bg-[color:var(--surface)] p-6 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1 text-sm">
|
<div className="grid grid-cols-2 gap-2 rounded-none bg-black/20 p-1 text-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded-md px-3 py-2 ${
|
className={`rounded-none px-3 py-2 border-[2px] transition-all ${mode === "login"
|
||||||
mode === "login" ? "bg-white shadow-sm" : "text-zinc-600"
|
? "bg-[color:var(--mc-wood)] border-black text-white shadow-[2px_2px_0_rgba(0,0,0,0.4)]"
|
||||||
|
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode("login");
|
setMode("login");
|
||||||
@@ -111,12 +114,13 @@ export default function AuthPage() {
|
|||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{tx("登录", "Sign In")}
|
{tx("登录服务器", "Login")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded-md px-3 py-2 ${
|
className={`rounded-none px-3 py-2 border-[2px] transition-all ${mode === "register"
|
||||||
mode === "register" ? "bg-white shadow-sm" : "text-zinc-600"
|
? "bg-[color:var(--mc-wood)] border-black text-white shadow-[2px_2px_0_rgba(0,0,0,0.4)]"
|
||||||
|
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode("register");
|
setMode("register");
|
||||||
@@ -124,91 +128,91 @@ export default function AuthPage() {
|
|||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{tx("注册", "Register")}
|
{tx("新玩家注册", "New Player")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 space-y-4">
|
<div className="mt-5 space-y-4 font-mono">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">{tx("用户名", "Username")}</label>
|
<label className="text-sm font-bold text-[color:var(--mc-stone)]">{tx("玩家代号", "Username")}</label>
|
||||||
<input
|
<input
|
||||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
className="mt-1 w-full"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder={tx("例如:csp_student", "e.g. csp_student")}
|
placeholder={tx("例如:Steve", "e.g. Steve")}
|
||||||
/>
|
/>
|
||||||
{usernameErr && <p className="mt-1 text-xs text-red-600">{usernameErr}</p>}
|
{usernameErr && <p className="mt-1 text-xs text-[color:var(--mc-red)]">{usernameErr}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium">{tx("密码", "Password")}</label>
|
<label className="text-sm font-bold text-[color:var(--mc-stone)]">{tx("极其机密的口令", "Secret Password")}</label>
|
||||||
<span className={`text-xs ${strength.color}`}>{tx("强度", "Strength")}: {strength.label}</span>
|
<span className={`text-xs ${strength.color}`}>{strength.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
className="mt-1 w-full"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder={tx("至少 6 位", "At least 6 chars")}
|
placeholder={tx("至少 6 个字符", "Min 6 chars")}
|
||||||
/>
|
/>
|
||||||
{passwordErr && <p className="mt-1 text-xs text-red-600">{passwordErr}</p>}
|
{passwordErr && <p className="mt-1 text-xs text-[color:var(--mc-red)]">{passwordErr}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === "register" && (
|
{mode === "register" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">{tx("确认密码", "Confirm Password")}</label>
|
<label className="text-sm font-bold text-[color:var(--mc-stone)]">{tx("确认口令", "Confirm Secret")}</label>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
className="mt-1 w-full"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder={tx("再输入一次密码", "Enter password again")}
|
placeholder={tx("再次输入口令", "Re-enter secret")}
|
||||||
/>
|
/>
|
||||||
{confirmErr && <p className="mt-1 text-xs text-red-600">{confirmErr}</p>}
|
{confirmErr && <p className="mt-1 text-xs text-[color:var(--mc-red)]">{confirmErr}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-xs text-zinc-600">
|
<label className="flex items-center gap-2 text-xs text-[color:var(--mc-stone)] cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showPassword}
|
checked={showPassword}
|
||||||
onChange={(e) => setShowPassword(e.target.checked)}
|
onChange={(e) => setShowPassword(e.target.checked)}
|
||||||
|
className="accent-[color:var(--mc-wood)]"
|
||||||
/>
|
/>
|
||||||
{tx("显示密码", "Show password")}
|
{tx("显示口令", "Reveal Secret")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="w-full rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800 disabled:opacity-50"
|
className={`w-full mc-btn ${mode === "register" ? "mc-btn-success" : ""}`}
|
||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
>
|
>
|
||||||
{loading ? tx("提交中...", "Submitting...") : mode === "register" ? tx("注册并登录", "Register & Sign In") : tx("登录", "Sign In")}
|
{loading ? tx("连接中...", "Connecting...") : mode === "register" ? tx("创建档案并连接", "Create & Connect") : tx("连接服务器", "Connect")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{resp && (
|
{resp && (
|
||||||
<div
|
<div
|
||||||
className={`mt-4 rounded-lg border px-3 py-2 text-sm ${
|
className={`mt-4 border-[2px] border-black px-3 py-2 text-sm shadow-[2px_2px_0_rgba(0,0,0,0.4)] ${resp.ok ? "bg-[color:var(--mc-grass-dark)] text-white" : "bg-[color:var(--mc-red)] text-white"
|
||||||
resp.ok ? "border-emerald-300 bg-emerald-50 text-emerald-700" : "border-red-300 bg-red-50 text-red-700"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{resp.ok
|
{resp.ok
|
||||||
? tx("登录成功,正在跳转到题库...", "Signed in. Redirecting to problem set...")
|
? tx("连接成功!正在传送至出生点...", "Connection Established! Teleporting to Spawn Point...")
|
||||||
: `${tx("操作失败:", "Action failed: ")}${resp.error}`}
|
: `${tx("连接失败:", "Connection Failed: ")}${resp.error}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-zinc-500">
|
<p className="mt-4 text-xs text-[color:var(--mc-stone-dark)]">
|
||||||
{tx("登录后 Token 自动保存在浏览器 localStorage,可直接前往", "Token is stored in browser localStorage after sign-in. You can go to")}
|
{tx("令牌将保存在客户端存储中,可直接前往", "Token stored in client. Warp to")}
|
||||||
<Link className="mx-1 underline" href="/problems">
|
<Link className="mx-1 underline text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)]" href="/problems">
|
||||||
{tx("题库", "Problems")}
|
{tx("任务板", "Quest Board")}
|
||||||
</Link>
|
</Link>
|
||||||
{tx("与", "and")}
|
{tx("与", "or")}
|
||||||
<Link className="mx-1 underline" href="/me">
|
<Link className="mx-1 underline text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)]" href="/me">
|
||||||
{tx("我的", "My Account")}
|
{tx("角色面板", "Character Sheet")}
|
||||||
</Link>
|
</Link>
|
||||||
{tx("页面。", ".")}
|
{tx("。", ".")}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { useI18nText } from "@/lib/i18n";
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||||
|
|
||||||
type Contest = {
|
type Contest = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,6 +17,8 @@ type Contest = {
|
|||||||
|
|
||||||
export default function ContestsPage() {
|
export default function ContestsPage() {
|
||||||
const { tx } = useI18nText();
|
const { tx } = useI18nText();
|
||||||
|
const { theme } = useUiPreferences();
|
||||||
|
const isMc = theme === "minecraft";
|
||||||
const [items, setItems] = useState<Contest[]>([]);
|
const [items, setItems] = useState<Contest[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -37,11 +40,18 @@ export default function ContestsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
<h1 className={`text-xl font-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
|
||||||
{tx("模拟竞赛", "Contests")}
|
{isMc ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>⚔️</span>
|
||||||
|
{tx("突袭公告板", "Raid Board")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
tx("模拟竞赛", "Contests")
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
|
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("正在寻找突袭目标...", "Scouting for raids...")}</p>}
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
@@ -49,13 +59,35 @@ export default function ContestsPage() {
|
|||||||
<Link
|
<Link
|
||||||
key={c.id}
|
key={c.id}
|
||||||
href={`/contests/${c.id}`}
|
href={`/contests/${c.id}`}
|
||||||
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
|
className={`block rounded-xl border p-4 transition-transform active:scale-[0.99] ${isMc
|
||||||
|
? "bg-[color:var(--mc-stone-dark)] border-[3px] border-black shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white hover:border-white"
|
||||||
|
: "bg-white border-zinc-200 hover:border-zinc-400"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-medium">{c.title}</h2>
|
<div className="flex items-start justify-between">
|
||||||
<p className="mt-1 text-xs text-zinc-500">{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}</p>
|
<div>
|
||||||
<p className="text-xs text-zinc-500">{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}</p>
|
<h2 className={`text-lg font-medium ${isMc ? "text-[color:var(--mc-gold)]" : ""}`}>
|
||||||
|
{isMc && <span className="mr-2">🛡️</span>}
|
||||||
|
{c.title}
|
||||||
|
</h2>
|
||||||
|
<div className={`mt-2 text-xs ${isMc ? "text-zinc-400" : "text-zinc-500"}`}>
|
||||||
|
<p>{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}</p>
|
||||||
|
<p>{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isMc && (
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<span className="mc-btn px-3 py-1 text-xs">{tx("加入突袭", "Join Raid")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<div className={`p-8 text-center border-2 border-dashed ${isMc ? "border-zinc-700 text-zinc-500 bg-black/20" : "border-zinc-200 text-zinc-500"}`}>
|
||||||
|
<p>{tx("暂无比赛", "No raids active")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
二进制文件未显示。
|
之前 宽度: | 高度: | 大小: 25 KiB 之后 宽度: | 高度: | 大小: 19 KiB |
@@ -45,9 +45,47 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
/* ── Force clean 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 {
|
body {
|
||||||
background: #fff !important;
|
background: #fff !important;
|
||||||
color: #000 !important;
|
color: #000 !important;
|
||||||
|
font-family: Arial, Helvetica, sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset all Minecraft fonts back to default for print */
|
||||||
|
* {
|
||||||
|
font-family: inherit !important;
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: Arial, Helvetica, sans-serif !important;
|
||||||
|
color: #111 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-hidden {
|
.print-hidden {
|
||||||
@@ -58,6 +96,13 @@ body {
|
|||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide nav, mobile tab bar, XP bar, and other chrome */
|
||||||
|
header,
|
||||||
|
.mobile-tab-bar,
|
||||||
|
.xp-bar-container {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.problem-detail-grid {
|
.problem-detail-grid {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
@@ -73,4 +118,47 @@ body {
|
|||||||
background: #f4f4f5 !important;
|
background: #f4f4f5 !important;
|
||||||
color: #111827 !important;
|
color: #111827 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reset 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 = {
|
export const metadata: Metadata = {
|
||||||
title: "CSP Online Learning & Contest Platform",
|
title: "CSP Online Learning & Contest Platform",
|
||||||
description: "Problems, wrong-book review, contests, knowledge base, and C++ runner.",
|
description: "Quests, Cursed Tome review, Raids, Knowledge Base, and C++ runner.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { useI18nText } from "@/lib/i18n";
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -14,6 +15,8 @@ type Row = {
|
|||||||
|
|
||||||
export default function LeaderboardPage() {
|
export default function LeaderboardPage() {
|
||||||
const { tx } = useI18nText();
|
const { tx } = useI18nText();
|
||||||
|
const { theme } = useUiPreferences();
|
||||||
|
const isMc = theme === "minecraft";
|
||||||
const [items, setItems] = useState<Row[]>([]);
|
const [items, setItems] = useState<Row[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -34,22 +37,52 @@ export default function LeaderboardPage() {
|
|||||||
void load();
|
void load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const getRankColor = (index: number) => {
|
||||||
|
if (!isMc) return "";
|
||||||
|
switch (index) {
|
||||||
|
case 0: return "text-[color:var(--mc-gold)] drop-shadow-sm"; // Gold
|
||||||
|
case 1: return "text-zinc-300"; // Iron
|
||||||
|
case 2: return "text-orange-700"; // Copper
|
||||||
|
default: return "text-zinc-400";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRankIcon = (index: number) => {
|
||||||
|
if (!isMc) return `#${index + 1}`;
|
||||||
|
switch (index) {
|
||||||
|
case 0: return "🏆";
|
||||||
|
case 1: return "🥈";
|
||||||
|
case 2: return "🥉";
|
||||||
|
default: return `#${index + 1}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
<h1 className={`text-xl font-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
|
||||||
{tx("全站排行榜", "Global Leaderboard")}
|
{isMc ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>🏰</span>
|
||||||
|
{tx("名人堂", "Hall of Fame")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
tx("全站排行榜", "Global Leaderboard")
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
|
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("正在读取卷轴...", "Reading scrolls...")}</p>}
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
<div className="mt-4 rounded-xl border bg-white">
|
<div className={`mt-4 rounded-xl border ${isMc ? "border-[3px] border-black bg-[color:var(--mc-deep-slate)] shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white" : "bg-white border-zinc-200"}`}>
|
||||||
<div className="divide-y md:hidden">
|
<div className="divide-y md:hidden">
|
||||||
{items.map((row, i) => (
|
{items.map((row, i) => (
|
||||||
<article key={row.user_id} className="space-y-1 p-3 text-sm">
|
<article key={row.user_id} className={`space-y-1 p-3 text-sm ${isMc ? "border-zinc-700" : ""}`}>
|
||||||
<p className="font-medium">
|
<div className="flex items-center justify-between">
|
||||||
#{i + 1} · {row.username}
|
<p className={`font-medium ${getRankColor(i)}`}>
|
||||||
|
<span className="mr-2 text-lg">{getRankIcon(i)}</span>
|
||||||
|
{row.username}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-zinc-600">Rating: {row.rating}</p>
|
<span className="text-[color:var(--mc-emerald)] font-bold">{row.rating}</span>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-zinc-500">
|
<p className="text-xs text-zinc-500">
|
||||||
{tx("注册时间:", "Registered: ")}
|
{tx("注册时间:", "Registered: ")}
|
||||||
{new Date(row.created_at * 1000).toLocaleString()}
|
{new Date(row.created_at * 1000).toLocaleString()}
|
||||||
@@ -58,28 +91,28 @@ export default function LeaderboardPage() {
|
|||||||
))}
|
))}
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<p className="px-3 py-5 text-center text-sm text-zinc-500">
|
<p className="px-3 py-5 text-center text-sm text-zinc-500">
|
||||||
{tx("暂无排行数据", "No ranking data yet")}
|
{tx("暂无数据", "No legends yet")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
<div className="hidden overflow-x-auto md:block">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead className="bg-zinc-100 text-left">
|
<thead className={`${isMc ? "bg-black/30 text-zinc-300" : "bg-zinc-100 text-left"}`}>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2">{tx("排名", "Rank")}</th>
|
<th className="px-3 py-2 text-left">{tx("排名", "Rank")}</th>
|
||||||
<th className="px-3 py-2">{tx("用户", "User")}</th>
|
<th className="px-3 py-2 text-left">{tx("用户", "User")}</th>
|
||||||
<th className="px-3 py-2">Rating</th>
|
<th className="px-3 py-2 text-left">Rating</th>
|
||||||
<th className="px-3 py-2">{tx("注册时间", "Registered At")}</th>
|
<th className="px-3 py-2 text-left">{tx("注册时间", "Registered At")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className={isMc ? "divide-y divide-zinc-700" : ""}>
|
||||||
{items.map((row, i) => (
|
{items.map((row, i) => (
|
||||||
<tr key={row.user_id} className="border-t">
|
<tr key={row.user_id} className={isMc ? "hover:bg-white/5 transition-colors" : "border-t"}>
|
||||||
<td className="px-3 py-2">{i + 1}</td>
|
<td className={`px-3 py-2 font-bold ${getRankColor(i)}`}>{getRankIcon(i)}</td>
|
||||||
<td className="px-3 py-2">{row.username}</td>
|
<td className={`px-3 py-2 font-medium ${getRankColor(i)}`}>{row.username}</td>
|
||||||
<td className="px-3 py-2">{row.rating}</td>
|
<td className="px-3 py-2 text-[color:var(--mc-emerald)]">{row.rating}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2 text-zinc-500">
|
||||||
{new Date(row.created_at * 1000).toLocaleString()}
|
{new Date(row.created_at * 1000).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -87,7 +120,7 @@ export default function LeaderboardPage() {
|
|||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-3 py-5 text-center text-zinc-500" colSpan={4}>
|
<td className="px-3 py-5 text-center text-zinc-500" colSpan={4}>
|
||||||
{tx("暂无排行数据", "No ranking data yet")}
|
{tx("暂无数据", "No legends yet")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ type Me = {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use a distinct style for inputs/selects
|
||||||
|
const inputClass = "w-full bg-[color:var(--mc-surface)] text-[color:var(--mc-plank-light)] border-2 border-[color:var(--mc-stone-dark)] px-3 py-2 text-base focus:border-[color:var(--mc-gold)] focus:outline-none transition-colors font-minecraft";
|
||||||
|
const labelClass = "text-sm text-[color:var(--mc-stone)] mb-1 block";
|
||||||
|
|
||||||
type RedeemItem = {
|
type RedeemItem = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -62,12 +66,21 @@ function fmtTs(v: number | null | undefined): string {
|
|||||||
return new Date(v * 1000).toLocaleString();
|
return new Date(v * 1000).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRank(rating: number): { label: string; color: string; icon: string } {
|
||||||
|
if (rating >= 2000) return { label: "Netherite", color: "text-[color:var(--mc-red)]", icon: "🔥" };
|
||||||
|
if (rating >= 1500) return { label: "Diamond", color: "text-[color:var(--mc-diamond)]", icon: "💎" };
|
||||||
|
if (rating >= 1200) return { label: "Iron", color: "text-zinc-200", icon: "⚔️" };
|
||||||
|
if (rating >= 1000) return { label: "Stone", color: "text-[color:var(--mc-stone)]", icon: "🪨" };
|
||||||
|
return { label: "Wood", color: "text-[color:var(--mc-wood)]", icon: "🪵" };
|
||||||
|
}
|
||||||
|
|
||||||
export default function MePage() {
|
export default function MePage() {
|
||||||
const { isZh, tx } = useI18nText();
|
const { isZh, tx } = useI18nText();
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [profile, setProfile] = useState<Me | null>(null);
|
const [profile, setProfile] = useState<Me | null>(null);
|
||||||
const [items, setItems] = useState<RedeemItem[]>([]);
|
const [items, setItems] = useState<RedeemItem[]>([]);
|
||||||
const [records, setRecords] = useState<RedeemRecord[]>([]);
|
const [records, setRecords] = useState<RedeemRecord[]>([]);
|
||||||
|
const [historyItems, setHistoryItems] = useState<RatingHistoryItem[]>([]);
|
||||||
const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]);
|
const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]);
|
||||||
const [dailyDayKey, setDailyDayKey] = useState("");
|
const [dailyDayKey, setDailyDayKey] = useState("");
|
||||||
const [dailyTotalReward, setDailyTotalReward] = useState(0);
|
const [dailyTotalReward, setDailyTotalReward] = useState(0);
|
||||||
@@ -99,34 +112,17 @@ export default function MePage() {
|
|||||||
if (isZh) return task.title;
|
if (isZh) return task.title;
|
||||||
if (task.code === "login_checkin") return "Daily Sign-in";
|
if (task.code === "login_checkin") return "Daily Sign-in";
|
||||||
if (task.code === "daily_submit") return "Daily Submission";
|
if (task.code === "daily_submit") return "Daily Submission";
|
||||||
if (task.code === "first_ac") return "Solve One Problem";
|
if (task.code === "first_ac") return "First Blood";
|
||||||
if (task.code === "code_quality") return "Code Quality";
|
if (task.code === "code_quality") return "Craftsman";
|
||||||
return task.title;
|
return task.title;
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskDesc = (task: DailyTaskItem): string => {
|
|
||||||
if (isZh) return task.description;
|
|
||||||
if (task.code === "login_checkin") return "Sign in once today to get 1 point.";
|
|
||||||
if (task.code === "daily_submit") return "Submit once today to get 1 point.";
|
|
||||||
if (task.code === "first_ac") return "Get AC once today to get 1 point.";
|
|
||||||
if (task.code === "code_quality") return "Submit code longer than 10 lines once today to get 1 point.";
|
|
||||||
return task.description;
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemName = (name: string): string => {
|
const itemName = (name: string): string => {
|
||||||
if (isZh) return name;
|
if (isZh) return name;
|
||||||
if (name === "私人玩游戏时间") return "Private Game Time";
|
if (name === "私人玩游戏时间") return "Game Time pass";
|
||||||
return name;
|
return name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemDesc = (text: string): string => {
|
|
||||||
if (isZh) return text;
|
|
||||||
if (text === "全局用户可兑换:假期 1 小时 5 Rating;学习日/非节假日 1 小时 25 Rating。") {
|
|
||||||
return "Global redeem item: holiday 1 hour = 5 rating; study day/non-holiday 1 hour = 25 rating.";
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -136,15 +132,17 @@ export default function MePage() {
|
|||||||
setToken(tk);
|
setToken(tk);
|
||||||
if (!tk) throw new Error(tx("请先登录", "Please sign in first"));
|
if (!tk) throw new Error(tx("请先登录", "Please sign in first"));
|
||||||
|
|
||||||
const [me, redeemItems, redeemRecords, daily] = await Promise.all([
|
const [me, redeemItems, redeemRecords, daily, history] = await Promise.all([
|
||||||
apiFetch<Me>("/api/v1/me", {}, tk),
|
apiFetch<Me>("/api/v1/me", {}, tk),
|
||||||
apiFetch<RedeemItem[]>("/api/v1/me/redeem/items", {}, tk),
|
apiFetch<RedeemItem[]>("/api/v1/me/redeem/items", {}, tk),
|
||||||
apiFetch<RedeemRecord[]>("/api/v1/me/redeem/records?limit=200", {}, tk),
|
apiFetch<RedeemRecord[]>("/api/v1/me/redeem/records?limit=200", {}, tk),
|
||||||
apiFetch<DailyTaskPayload>("/api/v1/me/daily-tasks", {}, tk),
|
apiFetch<DailyTaskPayload>("/api/v1/me/daily-tasks", {}, tk),
|
||||||
|
listRatingHistory(50),
|
||||||
]);
|
]);
|
||||||
setProfile(me);
|
setProfile(me);
|
||||||
setItems(redeemItems ?? []);
|
setItems(redeemItems ?? []);
|
||||||
setRecords(redeemRecords ?? []);
|
setRecords(redeemRecords ?? []);
|
||||||
|
setHistoryItems(history ?? []);
|
||||||
setDailyTasks(daily?.tasks ?? []);
|
setDailyTasks(daily?.tasks ?? []);
|
||||||
setDailyDayKey(daily?.day_key ?? "");
|
setDailyDayKey(daily?.day_key ?? "");
|
||||||
setDailyTotalReward(daily?.total_reward ?? 0);
|
setDailyTotalReward(daily?.total_reward ?? 0);
|
||||||
@@ -171,9 +169,9 @@ export default function MePage() {
|
|||||||
setMsg("");
|
setMsg("");
|
||||||
try {
|
try {
|
||||||
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
|
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
|
||||||
if (!selectedItemId) throw new Error(tx("请选择兑换物品", "Please select a redeem item"));
|
if (!selectedItemId) throw new Error(tx("请选择交易物品", "Select trade item"));
|
||||||
if (!Number.isFinite(quantity) || quantity <= 0) {
|
if (!Number.isFinite(quantity) || quantity <= 0) {
|
||||||
throw new Error(tx("兑换数量必须大于 0", "Quantity must be greater than 0"));
|
throw new Error(tx("数量必须大于 0", "Amount > 0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await apiFetch<RedeemCreateResp>(
|
const created = await apiFetch<RedeemCreateResp>(
|
||||||
@@ -192,12 +190,8 @@ export default function MePage() {
|
|||||||
|
|
||||||
setMsg(
|
setMsg(
|
||||||
isZh
|
isZh
|
||||||
? `兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
|
? `交易成功:${created.item_name} × ${created.quantity},花费 ${created.total_cost} 绿宝石。`
|
||||||
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : ""
|
: `Trade successful: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} Emeralds.`
|
||||||
}。`
|
|
||||||
: `Redeemed successfully: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} rating${
|
|
||||||
typeof created.rating_after === "number" ? `, current rating ${created.rating_after}` : ""
|
|
||||||
}.`
|
|
||||||
);
|
);
|
||||||
setNote("");
|
setNote("");
|
||||||
await loadAll();
|
await loadAll();
|
||||||
@@ -208,176 +202,186 @@ export default function MePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rank = resolveRank(profile?.rating ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||||
{tx("我的信息与积分兑换", "My Profile & Redeem")}
|
{tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")}
|
||||||
</h1>
|
</h1>
|
||||||
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
|
{loading && <p className="mt-3 text-sm text-[color:var(--mc-stone)]">{tx("读取存档中...", "Loading Save...")}</p>}
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-3 text-sm text-[color:var(--mc-red)]">{error}</p>}
|
||||||
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
|
{msg && <p className="mt-3 text-sm text-[color:var(--mc-green)]">{msg}</p>}
|
||||||
|
|
||||||
{profile && (
|
{profile && (
|
||||||
<section className="mt-4 rounded-xl border bg-white p-4 text-sm">
|
<div className="mt-4 grid gap-4 md:grid-cols-[1fr_2fr]">
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<section className="rounded-none border-[3px] border-black bg-[color:var(--mc-plank)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<div className="absolute inset-0 bg-black opacity-20 translate-x-1 translate-y-1 rounded-none"></div>
|
||||||
|
<div className="border-[4px] border-white p-1 bg-[color:var(--mc-stone-dark)]">
|
||||||
<PixelAvatar
|
<PixelAvatar
|
||||||
seed={`${profile.username}-${profile.id}`}
|
seed={`${profile.username}-${profile.id}`}
|
||||||
size={72}
|
size={100}
|
||||||
className="border-zinc-700"
|
className="border-none"
|
||||||
alt={`${profile.username} avatar`}
|
alt="avatar"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
|
||||||
<p>ID: {profile.id}</p>
|
|
||||||
<p>{tx("用户名", "Username")}: {profile.username}</p>
|
|
||||||
<p>Rating: {profile.rating}</p>
|
|
||||||
<p>{tx("创建时间", "Created At")}: {fmtTs(profile.created_at)}</p>
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
{tx("默认像素头像按账号随机生成,可作为主题角色形象。", "Default pixel avatar is randomly generated by account as your theme character.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
<h2 className="text-xl font-bold text-black mc-text-shadow-sm mb-1">{profile.username}</h2>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className={`text-sm font-bold ${rank.color} mb-2`}>
|
||||||
<h2 className="text-base font-semibold">{tx("每日任务", "Daily Tasks")}</h2>
|
{rank.icon} {rank.label} Rank
|
||||||
<p className="text-xs text-zinc-600">
|
|
||||||
{dailyDayKey ? `${dailyDayKey} · ` : ""}
|
|
||||||
{tx("已获", "Earned")} {dailyGainedReward}/{dailyTotalReward} {tx("分", "pts")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 divide-y">
|
|
||||||
{dailyTasks.map((task) => (
|
<div className="w-full bg-black h-4 border border-white relative mb-1">
|
||||||
<article key={task.code} className="py-2 text-sm">
|
<div
|
||||||
<div className="flex items-center justify-between gap-2">
|
className="h-full bg-[color:var(--mc-green)]"
|
||||||
<p className="font-medium">
|
style={{ width: `${Math.min(100, (profile.rating % 100))}%` }}
|
||||||
{taskTitle(task)} · +{task.reward}
|
></div>
|
||||||
</p>
|
<span className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-bold shadow-black drop-shadow-md">
|
||||||
<span
|
Level {Math.floor(profile.rating / 100)}
|
||||||
className={`rounded px-2 py-0.5 text-xs ${
|
|
||||||
task.completed ? "bg-emerald-100 text-emerald-700" : "bg-zinc-100 text-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{task.completed ? tx("已完成", "Completed") : tx("未完成", "Incomplete")}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-zinc-600">{taskDesc(task)}</p>
|
<p className="text-xs text-[color:var(--mc-stone-dark)]">UID: {profile.id}</p>
|
||||||
{task.completed && (
|
</div>
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
|
||||||
{tx("完成时间:", "Completed At: ")}
|
<div className="mt-4 space-y-2 border-t border-black/20 pt-4">
|
||||||
{fmtTs(task.completed_at)}
|
<div className="flex justify-between text-sm">
|
||||||
</p>
|
<span className="text-zinc-800">{tx("绿宝石 (Rating)", "Emeralds (Rating)")}</span>
|
||||||
)}
|
<span className="font-bold text-[color:var(--mc-green)] text-shadow-sm">{profile.rating}</span>
|
||||||
</article>
|
</div>
|
||||||
))}
|
<div className="flex justify-between text-sm">
|
||||||
{!loading && dailyTasks.length === 0 && (
|
<span className="text-zinc-800">{tx("加入时间", "Joined")}</span>
|
||||||
<p className="py-3 text-sm text-zinc-500">
|
<span className="text-zinc-600">{new Date(profile.created_at * 1000).toLocaleDateString()}</span>
|
||||||
{tx("今日任务尚未初始化,请稍后刷新。", "Today's tasks are not initialized yet. Please refresh later.")}
|
</div>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h2 className="text-base font-semibold">{tx("积分兑换物品", "Redeem Items")}</h2>
|
{/* Daily Tasks */}
|
||||||
<p className="mt-1 text-xs text-zinc-600">
|
<div className="bg-[color:var(--mc-surface)] border-4 border-black p-4 relative">
|
||||||
{tx(
|
<h2 className="text-xl text-[color:var(--mc-dirt)] mb-4 flex justify-between items-center font-minecraft">
|
||||||
"示例规则:私人玩游戏时间(假期 1 小时=5 积分;学习日/非节假日 1 小时=25 积分)",
|
<span>每日悬赏任务</span>
|
||||||
"Sample rule: Private Game Time (holiday 1h=5 points; study day/non-holiday 1h=25 points)"
|
<span className="text-xs text-[color:var(--mc-gold)]">进度: {dailyGainedReward} / {dailyTotalReward} XP</span>
|
||||||
)}
|
</h2>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
<div className="space-y-3">
|
||||||
{items.map((item) => (
|
{dailyTasks.map((task, idx) => (
|
||||||
<article key={item.id} className="rounded border bg-zinc-50 p-3 text-sm">
|
<div key={idx} className="bg-[color:var(--mc-surface-soft)] p-3 border-2 border-[color:var(--mc-stone-dark)] relative group hover:border-[color:var(--mc-stone)] transition-colors">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start gap-3">
|
||||||
<p className="font-medium">{itemName(item.name)}</p>
|
<div className={`w-5 h-5 border-2 border-[color:var(--mc-stone-dark)] flex items-center justify-center bg-black/30 mt-0.5 ${task.completed ? 'bg-[color:var(--mc-green)]/20' : ''}`}>
|
||||||
<button
|
{task.completed && <span className="text-[color:var(--mc-green)] text-sm">✓</span>}
|
||||||
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100"
|
|
||||||
onClick={() => setSelectedItemId(item.id)}
|
|
||||||
>
|
|
||||||
{tx("选中", "Select")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-zinc-600">{itemDesc(item.description) || "-"}</p>
|
<div className="flex-1">
|
||||||
<p className="mt-1 text-xs text-zinc-700">
|
<div className="flex justify-between items-start mb-1">
|
||||||
{tx("假期", "Holiday")}: {item.holiday_cost} / {item.unit_label}
|
<h3 className="text-[color:var(--mc-plank-light)] text-lg font-bold leading-tight">
|
||||||
|
{task.title}
|
||||||
|
<span className="ml-2 text-[color:var(--mc-gold)] text-base font-minecraft">+{task.reward} XP</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-[color:var(--mc-stone)] text-base leading-snug">
|
||||||
|
{task.description}
|
||||||
|
{task.completed && <span className="ml-2 text-[color:var(--mc-stone-dark)] italic text-sm">({fmtTs(task.completed_at)})</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-zinc-700">
|
</div>
|
||||||
{tx("学习日", "Study Day")}: {item.studyday_cost} / {item.unit_label}
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
{!loading && items.length === 0 && (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
{tx("管理员尚未配置可兑换物品。", "No redeem items configured by admin yet.")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="flex-1 rounded-none border-[3px] border-black bg-[color:var(--mc-stone)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white">
|
||||||
|
<h2 className="text-xl text-[color:var(--mc-obsidian)] mb-6 flex items-center gap-2 border-b-2 border-[color:var(--mc-stone)]/30 pb-2 font-minecraft">
|
||||||
|
<span className="text-2xl">💎</span>
|
||||||
|
<span>村民交易站</span>
|
||||||
|
<span className="ml-auto text-sm text-[color:var(--mc-stone-dark)]">消耗: RATING</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="mt-4 rounded-lg border p-3">
|
<div className="grid gap-2">
|
||||||
<h3 className="text-sm font-medium">{tx("兑换表单", "Redeem Form")}</h3>
|
<div className="flex gap-2 text-black">
|
||||||
<div className="mt-2 grid gap-2 md:grid-cols-2">
|
|
||||||
<select
|
<select
|
||||||
className="rounded border px-3 py-2 text-sm"
|
className="flex-1 rounded-none border-2 border-black bg-[color:var(--surface)] px-2 py-1 text-base font-bold"
|
||||||
value={selectedItemId}
|
value={selectedItemId}
|
||||||
onChange={(e) => setSelectedItemId(Number(e.target.value))}
|
onChange={(e) => setSelectedItemId(Number(e.target.value))}
|
||||||
>
|
>
|
||||||
<option value={0}>{tx("请选择兑换物品", "Please select an item")}</option>
|
<option value={0}>{tx("选择战利品...", "Select loot...")}</option>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
{itemName(item.name)}
|
{itemName(item.name)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<input
|
||||||
|
className="w-20 rounded-none border-2 border-black bg-[color:var(--surface)] px-2 py-1 text-base font-bold text-center"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={64}
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedItem && (
|
||||||
|
<div className="bg-[color:var(--mc-stone)]/20 p-3 border border-[color:var(--mc-stone)]/30 rounded-none text-base text-[color:var(--mc-obsidian)]">
|
||||||
|
<p>{selectedItem.description}</p>
|
||||||
|
<p className="mt-1 text-[color:var(--mc-wood-dark)]">
|
||||||
|
单价: {dayType === 'holiday' ? selectedItem.holiday_cost : selectedItem.studyday_cost} Rating / {selectedItem.unit_label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
className="rounded border px-3 py-2 text-sm"
|
className="flex-1 rounded-none border-2 border-black bg-[color:var(--stone-dark)] text-black px-2 py-1 text-base"
|
||||||
value={dayType}
|
value={dayType}
|
||||||
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
|
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
|
||||||
>
|
>
|
||||||
<option value="holiday">{tx("假期时间(按假期单价)", "Holiday time (holiday price)")}</option>
|
<option value="holiday">{tx("假期特惠", "Holiday Price")}</option>
|
||||||
<option value="studyday">{tx("学习日/非节假日(按学习日单价)", "Study day/non-holiday (study-day price)")}</option>
|
<option value="studyday">{tx("工作日价格", "Workday Price")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
|
||||||
className="rounded border px-3 py-2 text-sm"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={24}
|
|
||||||
value={quantity}
|
|
||||||
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
|
|
||||||
placeholder={tx("兑换时长(小时)", "Redeem duration (hours)")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input
|
|
||||||
className="rounded border px-3 py-2 text-sm"
|
|
||||||
value={note}
|
|
||||||
onChange={(e) => setNote(e.target.value)}
|
|
||||||
placeholder={tx("备注(可选)", "Note (optional)")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-2 text-xs text-zinc-600">
|
|
||||||
{tx("当前单价", "Current unit price")}: {unitCost} / {tx("小时", "hour")};{tx("预计扣分", "Estimated cost")}: {totalCost}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
|
className="mc-btn mc-btn-success text-xs px-4"
|
||||||
onClick={() => void redeem()}
|
onClick={() => void redeem()}
|
||||||
disabled={redeemLoading || !selectedItemId}
|
disabled={redeemLoading || !selectedItemId}
|
||||||
>
|
>
|
||||||
{redeemLoading ? tx("兑换中...", "Redeeming...") : tx("确认兑换", "Confirm Redeem")}
|
{tx("交易", "Trade")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rating History Section */}
|
||||||
|
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
|
<h2 className="text-base font-bold text-black mb-2">{tx("积分变动记录", "Rating History")}</h2>
|
||||||
|
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||||
|
{historyItems.map((item, idx) => (
|
||||||
|
<div key={idx} className="flex justify-between text-xs text-zinc-800 border-b border-zinc-200 pb-1">
|
||||||
|
<span>
|
||||||
|
<span className={`font-bold ${item.change > 0 ? 'text-[color:var(--mc-green)]' : 'text-[color:var(--mc-red)]'}`}>
|
||||||
|
{item.change > 0 ? `+${item.change}` : item.change}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2">{item.note}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[color:var(--mc-stone-dark)]">
|
||||||
|
{new Date(item.created_at * 1000).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!loading && historyItems.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-500">{tx("暂无记录。", "No history.")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
{/* Trades Section */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
<h2 className="text-base font-semibold">{tx("兑换记录", "Redeem Records")}</h2>
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<h2 className="text-base font-bold text-black">{tx("交易记录", "Trade History")}</h2>
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100"
|
className="text-xs text-[color:var(--mc-stone-dark)] underline"
|
||||||
onClick={() => void loadAll()}
|
onClick={() => void loadAll()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
@@ -385,21 +389,19 @@ export default function MePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 divide-y">
|
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||||
{records.map((row) => (
|
{records.map((row) => (
|
||||||
<article key={row.id} className="py-2 text-sm">
|
<div key={row.id} className="flex justify-between text-xs text-zinc-800 border-b border-zinc-200 pb-1">
|
||||||
<p>
|
<span>
|
||||||
#{row.id} · {itemName(row.item_name)} · {row.quantity} {tx("小时", "hour")} ·{" "}
|
{itemName(row.item_name)} × {row.quantity}
|
||||||
{row.day_type === "holiday" ? tx("假期", "Holiday") : tx("学习日", "Study Day")}
|
</span>
|
||||||
</p>
|
<span className="text-[color:var(--mc-stone-dark)]">
|
||||||
<p className="text-xs text-zinc-600">
|
-{row.total_cost} Gems · {new Date(row.created_at * 1000).toLocaleDateString()}
|
||||||
{tx("单价", "Unit cost")} {row.unit_cost},{tx("总扣分", "Total cost")} {row.total_cost} · {fmtTs(row.created_at)}
|
</span>
|
||||||
</p>
|
</div>
|
||||||
{row.note && <p className="text-xs text-zinc-500">{tx("备注:", "Note: ")}{row.note}</p>}
|
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
{!loading && records.length === 0 && (
|
{!loading && records.length === 0 && (
|
||||||
<p className="py-3 text-sm text-zinc-500">{tx("暂无兑换记录。", "No redeem records yet.")}</p>
|
<p className="text-xs text-zinc-500">{tx("暂无交易。", "No trades.")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -119,24 +119,24 @@ function resolveResultTone(
|
|||||||
): ResultTone {
|
): ResultTone {
|
||||||
const s = status.toUpperCase();
|
const s = status.toUpperCase();
|
||||||
if (s === "AC" && (score ?? 0) >= 100) {
|
if (s === "AC" && (score ?? 0) >= 100) {
|
||||||
return { title: tx("完美通关", "Perfect Clear"), icon: "🏆", badgeClass: "bg-emerald-100 text-emerald-700" };
|
return { title: tx("任务完成!", "Quest Complete!"), icon: "🏆", badgeClass: "bg-[color:var(--mc-grass-top)] text-white border-2 border-black" };
|
||||||
}
|
}
|
||||||
if (s === "AC") {
|
if (s === "AC") {
|
||||||
return { title: tx("通过", "Accepted"), icon: "✅", badgeClass: "bg-emerald-100 text-emerald-700" };
|
return { title: tx("通过", "Accepted"), icon: "✅", badgeClass: "bg-[color:var(--mc-grass-top)] text-white border-2 border-black" };
|
||||||
}
|
}
|
||||||
if (s === "WA") {
|
if (s === "WA") {
|
||||||
return { title: tx("再冲一次", "Try Again"), icon: "🎯", badgeClass: "bg-amber-100 text-amber-700" };
|
return { title: tx("咒语失效", "Spell Fizzled"), icon: "☠️", badgeClass: "bg-[color:var(--mc-red)] text-white border-2 border-black" };
|
||||||
}
|
}
|
||||||
if (s === "TLE" || s === "RE" || s === "MLE") {
|
if (s === "TLE" || s === "RE" || s === "MLE") {
|
||||||
return { title: tx("挑战中", "In Challenge"), icon: "⚔️", badgeClass: "bg-orange-100 text-orange-700" };
|
return { title: tx("时空乱流", "Time Warp"), icon: "⏳", badgeClass: "bg-orange-600 text-white border-2 border-black" };
|
||||||
}
|
}
|
||||||
if (s === "CE") {
|
if (s === "CE") {
|
||||||
return { title: tx("编译修复", "Fix Compile"), icon: "🛠️", badgeClass: "bg-rose-100 text-rose-700" };
|
return { title: tx("铭文错误", "Inscription Error"), icon: "📜", badgeClass: "bg-yellow-600 text-white border-2 border-black" };
|
||||||
}
|
}
|
||||||
if (s === "RUNNING") {
|
if (s === "RUNNING") {
|
||||||
return { title: tx("冲刺中", "Running"), icon: "🚀", badgeClass: "bg-blue-100 text-blue-700" };
|
return { title: tx("施法中...", "Casting..."), icon: "✨", badgeClass: "bg-blue-600 text-white border-2 border-black" };
|
||||||
}
|
}
|
||||||
return { title: tx("继续挑战", "Keep Going"), icon: "🎮", badgeClass: "bg-zinc-100 text-zinc-700" };
|
return { title: tx("准备就绪", "Ready"), icon: "🗡️", badgeClass: "bg-[color:var(--mc-stone)] text-white border-2 border-black" };
|
||||||
}
|
}
|
||||||
|
|
||||||
type Submission = {
|
type Submission = {
|
||||||
@@ -251,8 +251,8 @@ function buildPrintableAnswerMarkdown(
|
|||||||
return methodBlocks.join("\n\n---\n\n");
|
return methodBlocks.join("\n\n---\n\n");
|
||||||
}
|
}
|
||||||
return tx(
|
return tx(
|
||||||
"### 未有答案\n\nLLM 未生成可用答案,请先点击“按 C++14 重新生成”。",
|
"### 未有答案\n\n先知未生成可用启示,请点击“按 C++14 重新祈祷”。",
|
||||||
"### No Answer Yet\n\nNo usable LLM answer is available. Please click \"Regenerate in C++14\" first."
|
"### No Prophecy\n\nNo usable wisdom available. Click \"Regenerate in C++14\" to pray again."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +271,14 @@ int main() {
|
|||||||
|
|
||||||
const defaultRunInput = ``;
|
const defaultRunInput = ``;
|
||||||
|
|
||||||
|
function difficultyIcon(diff: number): string {
|
||||||
|
if (diff <= 2) return "🪵";
|
||||||
|
if (diff <= 4) return "🪨";
|
||||||
|
if (diff <= 6) return "⚔️";
|
||||||
|
if (diff <= 8) return "💎";
|
||||||
|
return "🔥";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProblemDetailPage() {
|
export default function ProblemDetailPage() {
|
||||||
const { tx } = useI18nText();
|
const { tx } = useI18nText();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -294,6 +302,8 @@ export default function ProblemDetailPage() {
|
|||||||
const [policyMsg, setPolicyMsg] = useState("");
|
const [policyMsg, setPolicyMsg] = useState("");
|
||||||
|
|
||||||
const [showSolutions, setShowSolutions] = useState(false);
|
const [showSolutions, setShowSolutions] = useState(false);
|
||||||
|
const [expandedCodes, setExpandedCodes] = useState<Set<number>>(new Set());
|
||||||
|
const [unlockConfirm, setUnlockConfirm] = useState(false);
|
||||||
const [solutionLoading, setSolutionLoading] = useState(false);
|
const [solutionLoading, setSolutionLoading] = useState(false);
|
||||||
const [solutionStatusLoading, setSolutionStatusLoading] = useState(false);
|
const [solutionStatusLoading, setSolutionStatusLoading] = useState(false);
|
||||||
const [solutionData, setSolutionData] = useState<SolutionResp | null>(null);
|
const [solutionData, setSolutionData] = useState<SolutionResp | null>(null);
|
||||||
@@ -397,9 +407,9 @@ export default function ProblemDetailPage() {
|
|||||||
return tx("待生成", "Pending");
|
return tx("待生成", "Pending");
|
||||||
}, [hasSolutionAnswer, solutionStatusLoading, tx]);
|
}, [hasSolutionAnswer, solutionStatusLoading, tx]);
|
||||||
const answerStatusClass = useMemo(() => {
|
const answerStatusClass = useMemo(() => {
|
||||||
if (hasSolutionAnswer) return "text-emerald-700";
|
if (hasSolutionAnswer) return "text-[color:var(--mc-grass-top)]";
|
||||||
if (solutionStatusLoading) return "text-zinc-600";
|
if (solutionStatusLoading) return "text-[color:var(--mc-stone)]";
|
||||||
return "text-amber-700";
|
return "text-[color:var(--mc-gold)]";
|
||||||
}, [hasSolutionAnswer, solutionStatusLoading]);
|
}, [hasSolutionAnswer, solutionStatusLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -425,8 +435,8 @@ export default function ProblemDetailPage() {
|
|||||||
if (policyErrorCount > 0) {
|
if (policyErrorCount > 0) {
|
||||||
setPolicyMsg(
|
setPolicyMsg(
|
||||||
tx(
|
tx(
|
||||||
`检测到 ${policyErrorCount} 条超出 C++14 的高风险写法,提交到旧评测环境可能直接 CE。`,
|
`检测到 ${policyErrorCount} 条高风险写法,提交到旧评测机可能直接 CE。`,
|
||||||
`Detected ${policyErrorCount} high-risk C++14 violations. Old judges may fail with CE.`
|
`Detected ${policyErrorCount} high-risk violations. Old judges may fail with CE.`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -434,8 +444,8 @@ export default function ProblemDetailPage() {
|
|||||||
if (policyWarningCount > 0) {
|
if (policyWarningCount > 0) {
|
||||||
setPolicyMsg(
|
setPolicyMsg(
|
||||||
tx(
|
tx(
|
||||||
`检测到 ${policyWarningCount} 条代码规范提醒,建议修正后再提交。`,
|
`检测到 ${policyWarningCount} 条代码规范提醒,建议修正。`,
|
||||||
`Detected ${policyWarningCount} policy warnings. Fix them before submit.`
|
`Detected ${policyWarningCount} policy warnings. Suggest fix.`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -480,7 +490,7 @@ export default function ProblemDetailPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// keep silent, avoid blocking coding flow when status API fails.
|
// keep silent
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setSolutionStatusLoading(false);
|
if (!cancelled) setSolutionStatusLoading(false);
|
||||||
}
|
}
|
||||||
@@ -511,10 +521,10 @@ export default function ProblemDetailPage() {
|
|||||||
}
|
}
|
||||||
draftLastSavedSigRef.current = buildDraftSignature(nextCode, nextStdin);
|
draftLastSavedSigRef.current = buildDraftSignature(nextCode, nextStdin);
|
||||||
if (hasDraft) {
|
if (hasDraft) {
|
||||||
setDraftMsg(tx("已自动加载草稿", "Draft auto-loaded"));
|
setDraftMsg(tx("草稿已加载", "Draft loaded"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore empty draft / unauthorized
|
// ignore
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void loadDraft();
|
void loadDraft();
|
||||||
@@ -541,10 +551,10 @@ export default function ProblemDetailPage() {
|
|||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
draftLastSavedSigRef.current = nextSig;
|
draftLastSavedSigRef.current = nextSig;
|
||||||
setDraftMsg(tx("草稿已自动保存(每60秒)", "Draft auto-saved (every 60s)"));
|
setDraftMsg(tx("草稿已自动保存", "Draft auto-saved"));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Keep silent to avoid noisy notifications on transient network errors.
|
// silent
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
draftAutoSavingRef.current = false;
|
draftAutoSavingRef.current = false;
|
||||||
@@ -639,7 +649,7 @@ export default function ProblemDetailPage() {
|
|||||||
if (mode === "full") setSolutionMsg("");
|
if (mode === "full") setSolutionMsg("");
|
||||||
try {
|
try {
|
||||||
const token = mode === "full" ? readToken() : undefined;
|
const token = mode === "full" ? readToken() : undefined;
|
||||||
if (mode === "full" && !token) throw new Error(tx("请先登录后再查看答案", "Please sign in before viewing answers"));
|
if (mode === "full" && !token) throw new Error(tx("请先登录", "Please sign in"));
|
||||||
const resp = await apiFetch<SolutionResp>(
|
const resp = await apiFetch<SolutionResp>(
|
||||||
`/api/v1/problems/${id}/solutions?mode=${mode}`,
|
`/api/v1/problems/${id}/solutions?mode=${mode}`,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -650,23 +660,23 @@ export default function ProblemDetailPage() {
|
|||||||
if (!resp.has_solutions) {
|
if (!resp.has_solutions) {
|
||||||
setSolutionMsg(
|
setSolutionMsg(
|
||||||
tx(
|
tx(
|
||||||
"当前暂无可展示答案,显示“未有答案”。可先点击“按 C++14 重新生成”。",
|
"当前暂无答案。",
|
||||||
"No answer available now. \"No Answer Yet\" will be shown. Click \"Regenerate in C++14\" first."
|
"No answer available."
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else if (resp.access?.charged) {
|
} else if (resp.access?.charged) {
|
||||||
const cost = resp.access.cost ?? 2;
|
const cost = resp.access.cost ?? 2;
|
||||||
const remaining = resp.access.rating_after;
|
const remaining = resp.access.rating_after;
|
||||||
const remainingText = typeof remaining === "number"
|
const remainingText = typeof remaining === "number"
|
||||||
? tx(`,当前 Rating:${remaining}`, `, current rating: ${remaining}`)
|
? tx(`,当前 Rating:${remaining}`, `, rating: ${remaining}`)
|
||||||
: "";
|
: "";
|
||||||
setSolutionMsg(
|
setSolutionMsg(
|
||||||
tx(`本次查看答案扣除 ${cost} 分${remainingText}。`, `Answer view cost ${cost} points${remainingText}.`)
|
tx(`扣除 ${cost} 分${remainingText}。`, `Cost ${cost} points${remainingText}.`)
|
||||||
);
|
);
|
||||||
} else if (resp.access?.daily_free) {
|
} else if (resp.access?.daily_free) {
|
||||||
setSolutionMsg(tx("今日首次查看答案免费。", "Today's first answer view is free."));
|
setSolutionMsg(tx("首次免费。", "Free view."));
|
||||||
} else {
|
} else {
|
||||||
setSolutionMsg(tx("答案已加载。", "Answer loaded."));
|
setSolutionMsg(tx("答案已加载。", "Loaded."));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSolutionData((prev) => {
|
setSolutionData((prev) => {
|
||||||
@@ -683,7 +693,7 @@ export default function ProblemDetailPage() {
|
|||||||
return resp;
|
return resp;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (mode === "full") {
|
if (mode === "full") {
|
||||||
setSolutionMsg(tx(`加载答案失败:${String(e)}`, `Failed to load answer: ${String(e)}`));
|
setSolutionMsg(tx(`加载失败:${String(e)}`, `Failed: ${String(e)}`));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -696,7 +706,7 @@ export default function ProblemDetailPage() {
|
|||||||
setSolutionMsg("");
|
setSolutionMsg("");
|
||||||
try {
|
try {
|
||||||
const token = readToken();
|
const token = readToken();
|
||||||
if (!token) throw new Error(tx("请先登录后再触发题解生成", "Please sign in before triggering generation"));
|
if (!token) throw new Error(tx("请先登录", "Please sign in"));
|
||||||
const resp = await apiFetch<{ started: boolean; job_id: number; pending_jobs?: number }>(
|
const resp = await apiFetch<{ started: boolean; job_id: number; pending_jobs?: number }>(
|
||||||
`/api/v1/problems/${id}/solutions/generate`,
|
`/api/v1/problems/${id}/solutions/generate`,
|
||||||
{
|
{
|
||||||
@@ -710,8 +720,8 @@ export default function ProblemDetailPage() {
|
|||||||
: "";
|
: "";
|
||||||
setSolutionMsg(
|
setSolutionMsg(
|
||||||
tx(
|
tx(
|
||||||
`题解生成任务已提交(按 C++14 规范生成),后台异步处理中。${pending}`,
|
`先知题解已请求。${pending}`,
|
||||||
`Generation job submitted (C++14 policy). Processing asynchronously.${pending}`
|
`Prophecy requested.${pending}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
await loadSolutions("preview", { silent: true });
|
await loadSolutions("preview", { silent: true });
|
||||||
@@ -725,18 +735,18 @@ export default function ProblemDetailPage() {
|
|||||||
const applySolutionCode = async (item: SolutionItem, runAfterWrite: boolean) => {
|
const applySolutionCode = async (item: SolutionItem, runAfterWrite: boolean) => {
|
||||||
const prepared = normalizeCodeText(item.code_cpp);
|
const prepared = normalizeCodeText(item.code_cpp);
|
||||||
if (!prepared) {
|
if (!prepared) {
|
||||||
setSolutionMsg(tx("该题解没有可写入的代码。", "This solution has no writable code."));
|
setSolutionMsg(tx("没有代码。", "No code."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCode(prepared);
|
setCode(prepared);
|
||||||
setDraftMsg(tx(`已将“解法 ${item.variant}”写入答题窗口`, `Inserted "Method ${item.variant}" into editor`));
|
setDraftMsg(tx(`已写入解法 ${item.variant}`, `Method ${item.variant} inserted`));
|
||||||
if (!runAfterWrite) return;
|
if (!runAfterWrite) return;
|
||||||
setSolutionMsg(tx(`已写入“解法 ${item.variant}”,正在试运行...`, `Inserted "Method ${item.variant}", running...`));
|
setSolutionMsg(tx(`已写入并运行解法 ${item.variant}...`, `Method ${item.variant} running...`));
|
||||||
await runCode(prepared);
|
await runCode(prepared);
|
||||||
setSolutionMsg(
|
setSolutionMsg(
|
||||||
tx(
|
tx(
|
||||||
`已写入“解法 ${item.variant}”,可在下方查看运行结果。`,
|
`已写入解法 ${item.variant},结果见下方。`,
|
||||||
`Inserted "Method ${item.variant}". Check run result below.`
|
`Method ${item.variant} done. Check below.`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -770,415 +780,428 @@ export default function ProblemDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-[1400px] px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
<main className="mx-auto max-w-[1400px] px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||||
{tx("题目详情与评测", "Problem Detail & Judge")}
|
{tx("任务详情与试炼", "Mission Details")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{loading && <p className="mt-4 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
|
{loading && <p className="mt-4 text-sm text-[color:var(--mc-stone)]">{tx("加载地图中...", "Loading Map...")}</p>}
|
||||||
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-4 text-sm text-[color:var(--mc-red)]">{error}</p>}
|
||||||
|
|
||||||
{problem && (
|
{problem && (
|
||||||
<div className="problem-detail-grid mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
<div className="problem-detail-grid mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||||
<section className="problem-print-section rounded-xl border bg-white p-4 sm:p-5">
|
<section className="problem-print-section rounded-none border-[3px] border-black bg-[color:var(--mc-plank-light)] p-4 sm:p-5 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
<h2 className="text-xl font-medium">{problem.title}</h2>
|
<h2 className="text-2xl font-bold text-black mc-text-shadow-sm">{problem.title}</h2>
|
||||||
<p className="mt-1 text-sm text-zinc-600">
|
<div className="mt-1 flex items-center gap-2 text-sm">
|
||||||
{tx("难度", "Difficulty")} {problem.difficulty} · {tx("来源", "Source")} {problem.source}
|
<span className={`font-bold ${problem.difficulty > 6 ? "text-[color:var(--mc-diamond)]" :
|
||||||
</p>
|
problem.difficulty > 4 ? "text-zinc-600" :
|
||||||
|
problem.difficulty > 2 ? "text-[color:var(--mc-stone-dark)]" :
|
||||||
|
"text-[color:var(--mc-wood-dark)]"
|
||||||
|
}`}>
|
||||||
|
Tier {problem.difficulty} {difficultyIcon(problem.difficulty)}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-500">·</span>
|
||||||
|
<span className="text-zinc-800">{tx("来源", "Origin")}: {problem.source}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="print-hidden mt-3 flex flex-wrap gap-2">
|
<div className="print-hidden mt-3 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 text-sm disabled:opacity-50"
|
className="mc-btn text-sm py-1"
|
||||||
onClick={() => void printProblemWithAnswer()}
|
onClick={() => void printProblemWithAnswer()}
|
||||||
disabled={solutionLoading}
|
disabled={solutionLoading}
|
||||||
>
|
>
|
||||||
{solutionLoading ? tx("准备打印内容...", "Preparing print content...") : tx("打印题干 + 答案", "Print Problem + Answer")}
|
{solutionLoading ? tx("卷轴准备中...", "Scribing...") : tx("打印卷轴", "Scribe Scroll")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 text-sm"
|
className="mc-btn text-sm py-1"
|
||||||
onClick={() => setShowPolicyTips((v) => !v)}
|
onClick={() => setShowPolicyTips((v) => !v)}
|
||||||
>
|
>
|
||||||
{showPolicyTips ? tx("收起代码规范 Tips", "Hide Coding Tips") : tx("代码规范 Tips", "Coding Tips")}
|
{showPolicyTips ? tx("收起公约", "Hide Rules") : tx("福建考场公约", "Fujian Rules")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPolicyTips && (
|
{showPolicyTips && (
|
||||||
<div className="print-hidden mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
|
<div className="mt-3 rounded border-[2px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[2px_2px_0_rgba(0,0,0,0.4)]">
|
||||||
<p className="font-semibold">{tx("福建 CSP-J/S 代码规范提示", "Fujian CSP-J/S Coding Tips")}</p>
|
<p className="font-bold text-black mb-1">{tx("考场生存指南:", "Survival Guide:")}</p>
|
||||||
<ul className="mt-2 list-disc space-y-1 pl-4">
|
<ul className="list-disc space-y-1 pl-4 text-xs text-black">
|
||||||
{policyTips.map((tip) => (
|
{policyTips.map((tip, idx) => (
|
||||||
<li key={tip}>{tip}</li>
|
<li key={idx}>{tip}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="problem-markdown-compact mt-4 rounded-lg border bg-zinc-50 p-4">
|
<div className={`print-content mt-4 ${printAnswerMarkdown ? "print-with-answer" : ""}`}>
|
||||||
<MarkdownRenderer markdown={statementMarkdown} />
|
<MarkdownRenderer markdown={statementMarkdown} className="problem-markdown text-black" />
|
||||||
</div>
|
|
||||||
|
|
||||||
{llmProfile?.knowledge_points && llmProfile.knowledge_points.length > 0 && (
|
{printAnswerMarkdown && (
|
||||||
<>
|
<div className="mt-8 break-before-page border-t-2 border-dashed border-black pt-8">
|
||||||
<h3 className="mt-4 text-[11px] font-medium">{tx("知识点考查", "Knowledge Points")}</h3>
|
<h2 className="text-lg font-bold text-black text-center mb-4">{tx("参考答案 / 解析", "Reference Answer")}</h2>
|
||||||
<div className="mt-1 flex flex-wrap gap-2">
|
<MarkdownRenderer markdown={printableAnswerMarkdown} className="problem-markdown text-black" />
|
||||||
{llmProfile.knowledge_points.map((kp) => (
|
|
||||||
<span key={kp} className="rounded-full bg-zinc-100 px-2 py-1 text-[10px]">
|
|
||||||
{kp}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3 className="mt-4 text-[11px] font-medium">{tx("样例输入", "Sample Input")}</h3>
|
|
||||||
<pre className="overflow-x-auto rounded bg-zinc-900 p-3 text-[10px] text-zinc-100">
|
|
||||||
{problem.sample_input}
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
<h3 className="mt-3 text-[11px] font-medium">{tx("样例输出", "Sample Output")}</h3>
|
|
||||||
<pre className="overflow-x-auto rounded bg-zinc-900 p-3 text-[10px] text-zinc-100">
|
|
||||||
{problem.sample_output}
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
<div className="print-only mt-4 rounded-lg border bg-white p-4">
|
|
||||||
<h3 className="text-[11px] font-medium">{tx("参考答案", "Reference Answer")}</h3>
|
|
||||||
<div className="problem-markdown-compact mt-2">
|
|
||||||
<MarkdownRenderer markdown={printAnswerMarkdown || printableAnswerMarkdown} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="print-hidden rounded-xl border bg-white p-4 sm:p-5">
|
<section className="flex flex-col gap-4 print:hidden">
|
||||||
<label className="text-sm font-medium">{tx("contest_id(可选)", "contest_id (optional)")}</label>
|
<div className="rounded-none border-[3px] border-black bg-[color:var(--mc-obsidian)] p-1 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
<input
|
<div className="relative">
|
||||||
className="mt-1 w-full rounded border px-3 py-2"
|
|
||||||
placeholder={tx("例如 1", "e.g. 1")}
|
|
||||||
value={contestId}
|
|
||||||
onChange={(e) => setContestId(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2 lg:grid-cols-4">
|
|
||||||
<button
|
|
||||||
className="w-full rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
|
|
||||||
onClick={() => void submit()}
|
|
||||||
disabled={submitLoading}
|
|
||||||
>
|
|
||||||
{submitLoading ? tx("提交中...", "Submitting...") : tx("提交评测", "Submit")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-full rounded border px-4 py-2 text-sm disabled:opacity-50"
|
|
||||||
onClick={() => void saveDraft()}
|
|
||||||
disabled={draftLoading}
|
|
||||||
>
|
|
||||||
{draftLoading ? tx("保存中...", "Saving...") : tx("保存草稿", "Save Draft")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-full rounded border px-4 py-2 text-sm disabled:opacity-50"
|
|
||||||
onClick={() => {
|
|
||||||
if (showSolutions) {
|
|
||||||
setShowSolutions(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowSolutions(true);
|
|
||||||
void loadSolutions("full");
|
|
||||||
}}
|
|
||||||
disabled={solutionLoading}
|
|
||||||
>
|
|
||||||
{solutionLoading
|
|
||||||
? tx("加载答案中...", "Loading answer...")
|
|
||||||
: showSolutions
|
|
||||||
? tx("收起答案", "Hide Answers")
|
|
||||||
: tx(`答案展示(${answerStatusLabel})`, `Answer Panel (${answerStatusLabel})`)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-full rounded border px-4 py-2 text-sm disabled:opacity-50"
|
|
||||||
onClick={() => void runCode()}
|
|
||||||
disabled={runLoading}
|
|
||||||
>
|
|
||||||
{runLoading ? tx("试运行中...", "Running...") : tx("试运行", "Run")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 rounded border bg-zinc-50 px-3 py-2 text-xs">
|
|
||||||
<span className={answerStatusClass}>
|
|
||||||
{tx("答案状态:", "Answer status: ")}
|
|
||||||
{answerStatusLabel}
|
|
||||||
</span>
|
|
||||||
<span className="mx-2 text-zinc-400">|</span>
|
|
||||||
<span className="text-zinc-600">{tx("当前模式:C++14 评测规范", "Current mode: C++14 judge policy")}</span>
|
|
||||||
<span className="mx-2 text-zinc-400">|</span>
|
|
||||||
<span className="text-zinc-600">{tx("答案查看:每日首免,后续每次 -2 Rating", "Answer view: first daily view free, then -2 rating each")}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-zinc-600">
|
|
||||||
{tx("当前试运行/提交按", "Run/submit compiles with")} <code>-std=gnu++14</code>{" "}
|
|
||||||
{tx(",超出 C++14 的写法会自动高亮提醒。", "; non-C++14 usage will be highlighted automatically.")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{draftMsg && <p className="mt-2 text-xs text-emerald-700">{draftMsg}</p>}
|
|
||||||
{(policyMsg || policyIssues.length > 0) && (
|
|
||||||
<div
|
|
||||||
className={`mt-3 rounded border px-3 py-2 text-xs ${
|
|
||||||
policyErrorCount > 0
|
|
||||||
? "border-red-200 bg-red-50 text-red-700"
|
|
||||||
: "border-amber-200 bg-amber-50 text-amber-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{policyMsg && <p className="font-medium">{policyMsg}</p>}
|
|
||||||
{!policyMsg && policyHintCount > 0 && (
|
|
||||||
<p className="font-medium">
|
|
||||||
{tx(`已检测到 ${policyHintCount} 条考场规范提示。`, `Detected ${policyHintCount} policy hints.`)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{visiblePolicyIssues.length > 0 && (
|
|
||||||
<ul className="mt-1 list-disc space-y-1 pl-4">
|
|
||||||
{visiblePolicyIssues.map((issue, idx) => (
|
|
||||||
<li key={`${issue.id}-${idx}`}>
|
|
||||||
L{issue.line}:{issue.message}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="mt-4 block text-sm font-medium">
|
|
||||||
{tx("C++ 代码(高亮 + 自动提示)", "C++ Code (Highlight + Auto Hint)")}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 overflow-hidden rounded border">
|
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
value={code}
|
value={code}
|
||||||
onChange={setCode}
|
onChange={(v) => {
|
||||||
height="min(58vh, 420px)"
|
setCode(v);
|
||||||
fontSize={9}
|
setDraftMsg("");
|
||||||
|
}}
|
||||||
|
height="500px"
|
||||||
|
fontSize={14}
|
||||||
onPolicyIssuesChange={setPolicyIssues}
|
onPolicyIssuesChange={setPolicyIssues}
|
||||||
/>
|
/>
|
||||||
|
<div className="pointer-events-none absolute right-4 top-2 flex flex-col items-end gap-1 opacity-80 hover:opacity-100 peer-hover:opacity-100">
|
||||||
|
{policyErrorCount > 0 && (
|
||||||
|
<div className="rounded bg-[color:var(--mc-red)] px-2 py-0.5 text-xs text-white shadow">
|
||||||
|
{policyErrorCount} Errors
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{policyWarningCount > 0 && (
|
||||||
|
<div className="rounded bg-[color:var(--mc-gold)] px-2 py-0.5 text-xs text-black shadow">
|
||||||
|
{policyWarningCount} Warnings
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="mt-4 block text-sm font-medium">{tx("试运行输入", "Run Input")}</label>
|
<div className="bg-[color:var(--surface)] p-2 border-t-[3px] border-black flex flex-wrap items-center justify-between gap-2">
|
||||||
<textarea
|
<div className="flex items-center gap-2 text-xs text-[color:var(--mc-stone)]">
|
||||||
className="mt-1 h-28 w-full rounded border p-2 font-mono text-xs sm:h-24"
|
<span>CPP14</span>
|
||||||
value={runInput}
|
{draftLoading && <span>{tx("保存中...", "Saving...")}</span>}
|
||||||
onChange={(e) => setRunInput(e.target.value)}
|
{!draftLoading && draftMsg && <span className="opacity-75">{draftMsg}</span>}
|
||||||
/>
|
{!draftLoading && !draftMsg && policyMsg && (
|
||||||
{runResp && (
|
<span className={`${policyErrorCount > 0 ? "text-[color:var(--mc-red)]" : "text-[color:var(--mc-gold)]"}`}>
|
||||||
<div className="mt-3 space-y-2 rounded border p-3 text-xs">
|
{policyMsg}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className={`rounded-full px-2 py-1 text-xs ${runTone.badgeClass}`}>
|
|
||||||
{runTone.icon} {runTone.title}
|
|
||||||
</span>
|
|
||||||
<p>
|
|
||||||
{tx("运行状态:", "Run status: ")}
|
|
||||||
<b>{runResp.status}</b> · {tx("耗时", "Time")} {runResp.time_ms}ms
|
|
||||||
</p>
|
|
||||||
<span className="text-zinc-500">
|
|
||||||
{tx("警告", "Warnings")} {runWarningCount} · {tx("错误", "Errors")} {runErrorCount}
|
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="mc-btn text-xs py-1 px-2"
|
||||||
|
onClick={() => void saveDraft()}
|
||||||
|
disabled={draftLoading || submitLoading}
|
||||||
|
title={tx("保存草稿(Ctrl+S)", "Save Draft")}
|
||||||
|
>
|
||||||
|
{tx("保存", "Save")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="mc-btn text-xs py-1 px-2"
|
||||||
|
onClick={() => setContestId((v) => (v ? "" : "0"))}
|
||||||
|
title={tx("输入比赛 ID(可选)", "Contest ID (Optional)")}
|
||||||
|
>
|
||||||
|
{contestId ? `Raid #${contestId}` : tx("Raid", "Raid")}
|
||||||
|
</button>
|
||||||
|
{contestId && (
|
||||||
|
<input
|
||||||
|
className="w-16 bg-black border border-white text-white px-1 text-xs"
|
||||||
|
value={contestId}
|
||||||
|
onChange={(e) => setContestId(e.target.value)}
|
||||||
|
placeholder="ID"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
className="mc-btn mc-btn-primary w-full py-3 text-sm"
|
||||||
|
onClick={() => void runCode()}
|
||||||
|
disabled={runLoading || submitLoading}
|
||||||
|
>
|
||||||
|
{runLoading ? tx("施法中...", "Casting...") : tx("试运行", "Test Run")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="mc-btn mc-btn-success w-full py-3 text-sm"
|
||||||
|
onClick={() => void submit()}
|
||||||
|
disabled={submitLoading || runLoading}
|
||||||
|
>
|
||||||
|
{submitLoading ? tx("施法中...", "Casting...") : tx("施放咒语", "Cast Spell")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(runResp || submitResp) && (
|
||||||
|
<div className="animation-slide-up space-y-3">
|
||||||
|
{runResp && (
|
||||||
|
<div className={`rounded-none border-[3px] border-black p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] ${resolveResultTone(runResp.status, tx).badgeClass.replace("bg-", "bg-opacity-20 bg-")}`}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">{runTone.icon}</span>
|
||||||
|
<span className="font-bold">{runTone.title}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono">{runResp.time_ms}ms</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{runResp.compile_log && (
|
{runResp.compile_log && (
|
||||||
<details className="rounded border bg-zinc-50 p-2">
|
<details className="mb-2">
|
||||||
<summary className="cursor-pointer text-zinc-700">{tx("查看编译日志", "View Compile Log")}</summary>
|
<summary className="cursor-pointer text-xs font-bold text-[color:var(--mc-red)]">{tx("编译日志", "Compile Log")}</summary>
|
||||||
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-2 text-zinc-100">
|
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-black p-2 text-xs text-red-300 font-mono">
|
||||||
{runResp.compile_log}
|
{runResp.compile_log}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2 text-xs font-mono">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">stdout</p>
|
<p className="font-bold mb-1 opacity-70">{tx("标准输出", "Output")}</p>
|
||||||
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-zinc-100">
|
<pre className="max-h-40 overflow-auto whitespace-pre-wrap rounded bg-black p-2 text-[color:var(--mc-plank-light)] border border-[color:var(--mc-stone-dark)]">
|
||||||
{runResp.stdout || "(empty)"}
|
{runResp.stdout || <span className="text-zinc-600 italic">Empty</span>}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
{runResp.stderr && (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">stderr</p>
|
<p className="font-bold mb-1 opacity-70 text-yellow-600">{tx("标准错误", "Stderr")}</p>
|
||||||
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-zinc-100">
|
<pre className="max-h-32 overflow-auto whitespace-pre-wrap rounded bg-black p-2 text-yellow-200 border border-[color:var(--mc-stone-dark)]">
|
||||||
{runResp.stderr || "(empty)"}
|
{runResp.stderr}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{submitResp && (
|
{submitResp && (
|
||||||
<div className="mt-4 space-y-2 rounded border p-3 text-sm">
|
<div className={`rounded-none border-[3px] border-black p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] ${submitTone.badgeClass}`}>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<span className={`rounded-full px-2 py-1 text-xs ${submitTone.badgeClass}`}>
|
<div className="flex items-center gap-2">
|
||||||
{submitTone.icon} {submitTone.title}
|
<span className="text-2xl">{submitTone.icon}</span>
|
||||||
</span>
|
<div>
|
||||||
<p>
|
<div className="font-bold text-lg">{submitTone.title}</div>
|
||||||
{tx("结果:", "Result: ")}
|
<div className="text-xs opacity-90">
|
||||||
<b>{submitResp.status}</b>
|
Score: {submitResp.score} · {submitResp.status}
|
||||||
{tx(",分数 ", ", score ")}
|
|
||||||
{submitResp.score}
|
|
||||||
</p>
|
|
||||||
<span className="text-xs text-zinc-500">
|
|
||||||
{tx("警告", "Warnings")} {submitWarningCount} · {tx("错误", "Errors")} {submitErrorCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 rounded border bg-zinc-50 p-2">
|
|
||||||
<p className="text-xs text-zinc-600">{tx("挑战进度", "Progress")}</p>
|
|
||||||
<div className="h-2 w-full overflow-hidden rounded bg-zinc-200">
|
|
||||||
<div
|
|
||||||
className="h-full bg-emerald-500"
|
|
||||||
style={{ width: `${scoreRatio(submitResp.score)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-zinc-600">
|
|
||||||
{tx("本次得分进度:", "Score progress: ")}
|
|
||||||
{scoreRatio(submitResp.score)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<Link href="/submissions" className="text-xs underline hover:text-white">
|
||||||
{tx("提交 ID:", "Submission ID:")}
|
{tx("查看详情", "Details")}
|
||||||
<Link className="ml-1 text-blue-600 underline" href={`/submissions/${submitResp.id}`}>
|
|
||||||
{submitResp.id}
|
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</div>
|
||||||
{submitWarningCount > 0 && (
|
</div>
|
||||||
<p className="text-xs text-amber-700">
|
|
||||||
{tx(
|
|
||||||
"提示:有编译警告但不影响 AC。可将循环下标改为",
|
|
||||||
"Tip: compile warnings do not block AC. You can use"
|
|
||||||
)}{" "}
|
|
||||||
<code>size_t</code> {tx("或对", "or cast")} <code>size()</code>{" "}
|
|
||||||
{tx("做显式类型转换,减少 signed/unsigned warning。", "explicitly to reduce signed/unsigned warnings.")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{submitResp.compile_log && (
|
|
||||||
<details className="rounded border bg-zinc-50 p-2">
|
|
||||||
<summary className="cursor-pointer text-xs text-zinc-700">{tx("查看编译日志", "View Compile Log")}</summary>
|
|
||||||
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-2 text-xs text-zinc-100">
|
|
||||||
{submitResp.compile_log}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
{submitResp.runtime_log && (
|
|
||||||
<details className="rounded border bg-zinc-50 p-2" open>
|
|
||||||
<summary className="cursor-pointer text-xs text-zinc-700">{tx("查看运行日志", "View Runtime Log")}</summary>
|
|
||||||
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-2 text-xs text-zinc-100">
|
|
||||||
{submitResp.runtime_log}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSolutions && (
|
<div className="rounded-none border-[3px] border-black bg-[color:var(--mc-stone-dark)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
<div className="mt-5 rounded-lg border bg-zinc-50 p-3">
|
<h3 className="font-bold text-white mb-2 mc-text-shadow-sm flex items-center justify-between">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<span>{tx("输入数据卷轴", "Input Scroll")}</span>
|
||||||
<h3 className="text-sm font-semibold">{tx("官方/LLM 题解", "Official/LLM Solutions")}</h3>
|
<button className="text-xs underline opacity-70 hover:opacity-100" onClick={() => setRunInput(sampleInput)}>
|
||||||
|
{tx("重置样例", "Reset Sample")}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-32 bg-black text-[color:var(--mc-plank-light)] border border-[color:var(--mc-stone)] p-2 font-mono text-sm resize-y"
|
||||||
|
value={runInput}
|
||||||
|
onChange={(e) => setRunInput(e.target.value)}
|
||||||
|
placeholder={tx("在此输入测试数据...", "Enter test input here...")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-none border-[3px] border-black bg-[color:var(--surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-bold text-[color:var(--mc-diamond)] mc-text-shadow-sm">{tx("先知启示 (AI 题解)", "Oracle's Wisdom")}</h3>
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
|
className="text-xs text-[color:var(--mc-gold)] hover:underline"
|
||||||
|
onClick={() => setShowSolutions(!showSolutions)}
|
||||||
|
>
|
||||||
|
{showSolutions ? tx("隐藏", "Hide") : tx("展开", "Reveal")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSolutions && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className={answerStatusClass}>{answerStatusLabel}</span>
|
||||||
|
<button
|
||||||
|
className="mc-btn text-xs py-1 px-2"
|
||||||
onClick={() => void triggerSolutions()}
|
onClick={() => void triggerSolutions()}
|
||||||
disabled={solutionLoading}
|
disabled={solutionLoading}
|
||||||
>
|
>
|
||||||
{tx("按 C++14 重新生成", "Regenerate in C++14")}
|
{tx("按 C++14 重新祈祷", "Regenerate (C++14)")}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
{solutionMsg && <p className="text-xs text-[color:var(--mc-stone)]">{solutionMsg}</p>}
|
||||||
|
|
||||||
|
{/* Unlock button: solutions exist but not yet fetched in full mode */}
|
||||||
|
{/* Unlock Logic: Check if we need to show the unlock button */}
|
||||||
|
{solutionData?.has_solutions && solutionData?.access?.mode !== "full" && (
|
||||||
|
<div className="bg-[color:var(--mc-wood-dark)]/10 p-4 border-2 border-[color:var(--mc-gold)]/50 rounded-none mb-4">
|
||||||
|
<p className="text-sm text-[color:var(--mc-red)] font-bold mb-3 flex items-start gap-2">
|
||||||
|
<span className="text-xl">⚠️</span>
|
||||||
|
<span>
|
||||||
|
{tx(
|
||||||
|
"查看题解后,本题分数将被锁定,再次提交无法获得更高评分。",
|
||||||
|
"Viewing the solution will LOCK your score for this problem. Future submissions will not increase your rating."
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{unlockConfirm ? (
|
||||||
|
<div className="flex gap-3 animate-in fade-in zoom-in duration-200">
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
|
className="mc-btn flex-1 py-2 text-sm bg-[color:var(--mc-red)] text-white hover:bg-red-600"
|
||||||
onClick={() => void loadSolutions("full")}
|
onClick={() => void loadSolutions("full")}
|
||||||
disabled={solutionLoading}
|
disabled={solutionLoading}
|
||||||
>
|
>
|
||||||
{tx("查看答案(首免,后续-2分)", "View answer (first daily free, then -2)")}
|
{solutionLoading ? tx("解锁中...", "Unlocking...") : tx("确认解锁", "Confirm Unlock")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
|
className="mc-btn flex-1 py-2 text-sm"
|
||||||
onClick={() => void loadSolutions("preview")}
|
onClick={() => setUnlockConfirm(false)}
|
||||||
disabled={solutionLoading}
|
disabled={solutionLoading}
|
||||||
>
|
>
|
||||||
{tx("刷新状态", "Refresh")}
|
{tx("取消", "Cancel")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{solutionMsg && <p className="mt-2 text-xs text-zinc-600">{solutionMsg}</p>}
|
<button
|
||||||
|
className={`mc-btn w-full py-2 text-sm ${(solutionData.access?.daily_free || solutionData.access?.charged || answerStatusLabel === tx("已有", "Available"))
|
||||||
{solutionData?.latest_job && (
|
? "mc-btn-success"
|
||||||
<>
|
: "mc-btn-primary"
|
||||||
<p className="mt-2 text-xs text-zinc-600">
|
}`}
|
||||||
{tx("任务", "Job")} #{solutionData.latest_job.id} · {solutionData.latest_job.status} ·
|
onClick={() => {
|
||||||
{tx("进度", "Progress")} {solutionData.latest_job.progress}%
|
// If it's free or already bought, or solved (Answer Status: Available/Accepted), load directly.
|
||||||
</p>
|
// Note: backend access check is authoritative, frontend logic is for UX.
|
||||||
{solutionData.latest_job.message && (
|
const isFree = solutionData.access?.daily_free || solutionData.access?.charged;
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
// We can also check if problem is solved if we had that info, but 'answerStatusLabel' might proxy it.
|
||||||
{tx("日志:", "Log: ")}
|
// User requirement: "submitted and rated (successfully)" -> free.
|
||||||
{solutionData.latest_job.message}
|
// Let's assume the backend 'access' object correctly returns 'charged=true' or 'daily_free' if conditions met.
|
||||||
</p>
|
// If cost > 0, we show confirm.
|
||||||
|
if (isFree || (solutionData.access?.cost ?? 0) <= 0) {
|
||||||
|
void loadSolutions("full");
|
||||||
|
} else {
|
||||||
|
setUnlockConfirm(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={solutionLoading}
|
||||||
|
>
|
||||||
|
{solutionLoading
|
||||||
|
? tx("📜 加载中...", "📜 Loading...")
|
||||||
|
: (solutionData.access?.daily_free)
|
||||||
|
? tx("🔓 每日首次免费查看", "🔓 First View Free (Daily)")
|
||||||
|
: (solutionData.access?.charged)
|
||||||
|
? tx("🔓 已解锁 - 免费查看", "🔓 Unlocked - View Free")
|
||||||
|
: tx(`🔒 解锁题解 (消耗 ${solutionData.access?.cost ?? 2} 积分)`, `🔒 Unlock Solution (Cost ${solutionData.access?.cost ?? 2})`)
|
||||||
|
}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-3">
|
|
||||||
{(solutionData?.items ?? []).map((item) => (
|
|
||||||
<article key={item.id} className="rounded border bg-white p-3">
|
|
||||||
<h4 className="text-sm font-semibold">
|
|
||||||
{tx("解法", "Method")} {item.variant}
|
|
||||||
{tx(":", ": ")}
|
|
||||||
{item.title || tx("未命名解法", "Untitled")}
|
|
||||||
</h4>
|
|
||||||
{item.complexity && (
|
|
||||||
<p className="mt-1 text-xs text-zinc-600">
|
|
||||||
{tx("复杂度:", "Complexity: ")}
|
|
||||||
{item.complexity}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{item.source === "fallback" && (
|
|
||||||
<p className="mt-1 text-xs text-amber-700">
|
|
||||||
{tx(
|
|
||||||
"当前为兜底模板题解(LLM 本次未返回可用结构化结果)。",
|
|
||||||
"This is a fallback template solution (LLM did not return a structured result this run)."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{item.idea_md && (
|
|
||||||
<div className="mt-2 rounded bg-zinc-50 p-2">
|
|
||||||
<MarkdownRenderer markdown={item.idea_md} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.code_cpp && (
|
|
||||||
<div className="mt-2 overflow-hidden rounded border border-zinc-800 bg-zinc-950">
|
{/* Divider before solution cards */}
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-zinc-800 px-2 py-2 text-xs text-zinc-300">
|
{(solutionData?.items?.length ?? 0) > 0 && (
|
||||||
<span>{tx("完整代码", "Full Code")}</span>
|
<div className="border-t border-[color:var(--mc-stone-dark)] pt-2" />
|
||||||
<span>
|
)}
|
||||||
{tx("共", "Total")} {codeLineCount(item.code_cpp)} {tx("行", "lines")}
|
|
||||||
|
{solutionData?.items.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="border-[2px] border-black bg-[color:var(--mc-obsidian)] p-3 shadow-[2px_2px_0_rgba(0,0,0,0.4)]"
|
||||||
|
>
|
||||||
|
{/* Solution header */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-bold text-[color:var(--mc-gold)] mc-text-shadow-sm text-sm">
|
||||||
|
⚔️ {item.title || `${tx("解法", "Method")} ${item.variant || idx + 1}`}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="w-full rounded border border-zinc-600 px-2 py-1 hover:bg-zinc-800 disabled:opacity-50 sm:ml-auto sm:w-auto"
|
className="mc-btn text-xs py-0.5 px-2"
|
||||||
onClick={() => void applySolutionCode(item, false)}
|
onClick={() => applySolutionCode(item, false)}
|
||||||
disabled={runLoading}
|
|
||||||
>
|
>
|
||||||
{tx("一键写入答题窗口", "Insert to Editor")}
|
{tx("📝 写入", "📝 Insert")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full rounded border border-zinc-600 px-2 py-1 hover:bg-zinc-800 disabled:opacity-50 sm:w-auto"
|
className="mc-btn text-xs py-0.5 px-2"
|
||||||
onClick={() => void applySolutionCode(item, true)}
|
onClick={() => applySolutionCode(item, true)}
|
||||||
disabled={runLoading}
|
|
||||||
>
|
>
|
||||||
{tx("写入并试运行", "Insert & Run")}
|
{tx("⚡ 运行", "⚡ Run")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="solution-code-block overflow-x-auto whitespace-pre p-3 text-sm leading-6 text-zinc-100">
|
</div>
|
||||||
{normalizeCodeText(item.code_cpp)}
|
|
||||||
|
{/* Complexity & tags */}
|
||||||
|
{(item.complexity || item.tags_json) && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{item.complexity && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 bg-[color:var(--mc-stone-dark)] text-[color:var(--mc-plank-light)] border border-black">
|
||||||
|
{item.complexity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
const tags: string[] = typeof item.tags_json === "string"
|
||||||
|
? JSON.parse(item.tags_json)
|
||||||
|
: item.tags_json ?? [];
|
||||||
|
return tags.map((tag, ti) => (
|
||||||
|
<span
|
||||||
|
key={ti}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 bg-[color:var(--mc-wood-dark)] text-[color:var(--mc-plank-light)] border border-black"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Idea summary — rendered as markdown */}
|
||||||
|
<div className="text-sm leading-relaxed [&_article]:text-[color:var(--mc-stone)] [&_h1]:text-[color:var(--mc-plank-light)] [&_h2]:text-[color:var(--mc-plank-light)] [&_h3]:text-[color:var(--mc-plank-light)] [&_strong]:text-[color:var(--mc-plank-light)] [&_code]:text-[color:var(--mc-gold)] [&_code]:bg-transparent [&_p]:text-[color:var(--mc-stone)] [&_li]:text-[color:var(--mc-stone)]">
|
||||||
|
<MarkdownRenderer
|
||||||
|
markdown={item.idea_md || tx("暂无思路摘要", "No summary.")}
|
||||||
|
className="problem-markdown-compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible code viewer */}
|
||||||
|
{item.code_cpp && (
|
||||||
|
<div className="mt-2 border-t border-[color:var(--mc-stone-dark)] pt-2">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 text-xs text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedCodes(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(item.id)) next.delete(item.id);
|
||||||
|
else next.add(item.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="inline-block transition-transform" style={{ transform: expandedCodes.has(item.id) ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
{expandedCodes.has(item.id)
|
||||||
|
? tx("收起代码", "Hide Code")
|
||||||
|
: tx("查看代码", "View Code")}
|
||||||
|
</button>
|
||||||
|
{expandedCodes.has(item.id) && (
|
||||||
|
<pre className="mt-2 p-3 bg-black/60 border border-[color:var(--mc-stone-dark)] overflow-x-auto text-[11px] leading-5 text-[color:var(--mc-plank-light)] font-mono max-h-[400px] overflow-y-auto">
|
||||||
|
<code>{item.code_cpp}</code>
|
||||||
</pre>
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.explanation_md && (
|
|
||||||
<div className="mt-2 rounded bg-zinc-50 p-2">
|
|
||||||
<MarkdownRenderer markdown={item.explanation_md} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
{!solutionLoading && (solutionData?.items.length ?? 0) === 0 && (
|
|
||||||
<p className="text-xs text-zinc-500">
|
{/* No solutions hint */}
|
||||||
{solutionData?.has_solutions
|
{solutionData && !solutionData.has_solutions && !solutionLoading && (
|
||||||
? tx(
|
<p className="text-xs text-[color:var(--mc-stone)] text-center py-2">
|
||||||
"已有答案,点击“查看答案(首免,后续-2分)”即可加载。",
|
{tx("尚无题解,点击「重新祈祷」生成", "No solutions yet. Click Regenerate.")}
|
||||||
"Answers exist. Click \"View answer\" to load."
|
|
||||||
)
|
|
||||||
: tx(
|
|
||||||
"暂无题解,点击“按 C++14 重新生成”可触发后台生成。",
|
|
||||||
"No solutions yet. Click \"Regenerate in C++14\" to trigger generation."
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,19 +43,19 @@ type Preset = {
|
|||||||
const PRESETS: Preset[] = [
|
const PRESETS: Preset[] = [
|
||||||
{
|
{
|
||||||
key: "csp-beginner-default",
|
key: "csp-beginner-default",
|
||||||
labelZh: "CSP J/S 入门默认",
|
labelZh: "CSP J/S 入门预设",
|
||||||
labelEn: "CSP J/S Beginner Default",
|
labelEn: "CSP J/S Beginner Preset",
|
||||||
tags: ["csp-j", "csp-s", "noip-junior", "noip-senior"],
|
tags: ["csp-j", "csp-s", "noip-junior", "noip-senior"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "csp-j",
|
key: "csp-j",
|
||||||
labelZh: "仅 CSP-J / 普及",
|
labelZh: "仅 CSP-J / 普及组",
|
||||||
labelEn: "CSP-J / Junior Only",
|
labelEn: "CSP-J / Junior Only",
|
||||||
tags: ["csp-j", "noip-junior"],
|
tags: ["csp-j", "noip-junior"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "csp-s",
|
key: "csp-s",
|
||||||
labelZh: "仅 CSP-S / 提高",
|
labelZh: "仅 CSP-S / 提高组",
|
||||||
labelEn: "CSP-S / Senior Only",
|
labelEn: "CSP-S / Senior Only",
|
||||||
tags: ["csp-s", "noip-senior"],
|
tags: ["csp-s", "noip-senior"],
|
||||||
},
|
},
|
||||||
@@ -67,14 +67,14 @@ const PRESETS: Preset[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "luogu-all",
|
key: "luogu-all",
|
||||||
labelZh: "洛谷导入全部",
|
labelZh: "洛谷全站导入",
|
||||||
labelEn: "All Luogu Imports",
|
labelEn: "All Luogu Imports",
|
||||||
sourcePrefix: "luogu:",
|
sourcePrefix: "luogu:",
|
||||||
tags: [],
|
tags: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "all",
|
key: "all",
|
||||||
labelZh: "全站全部来源",
|
labelZh: "全部来源",
|
||||||
labelEn: "All Sources",
|
labelEn: "All Sources",
|
||||||
tags: [],
|
tags: [],
|
||||||
},
|
},
|
||||||
@@ -83,39 +83,39 @@ const PRESETS: Preset[] = [
|
|||||||
const QUICK_CARDS = [
|
const QUICK_CARDS = [
|
||||||
{
|
{
|
||||||
presetKey: "csp-j",
|
presetKey: "csp-j",
|
||||||
titleZh: "CSP-J 真题",
|
titleZh: "CSP-J 试炼",
|
||||||
titleEn: "CSP-J Problems",
|
titleEn: "CSP-J Trials",
|
||||||
descZh: "普及组入门训练",
|
descZh: "普及组入门任务",
|
||||||
descEn: "Junior training set",
|
descEn: "Junior Tier Quests",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
presetKey: "csp-s",
|
presetKey: "csp-s",
|
||||||
titleZh: "CSP-S 真题",
|
titleZh: "CSP-S 挑战",
|
||||||
titleEn: "CSP-S Problems",
|
titleEn: "CSP-S Challenges",
|
||||||
descZh: "提高组进阶训练",
|
descZh: "提高组进阶任务",
|
||||||
descEn: "Senior advanced set",
|
descEn: "Senior Tier Quests",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
presetKey: "noip-junior",
|
presetKey: "noip-junior",
|
||||||
titleZh: "NOIP 入门",
|
titleZh: "NOIP 基础",
|
||||||
titleEn: "NOIP Junior",
|
titleEn: "NOIP Basics",
|
||||||
descZh: "基础算法与思维",
|
descZh: "算法与思维",
|
||||||
descEn: "Basic algorithm thinking",
|
descEn: "Algorithm & Logic",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DIFFICULTY_OPTIONS = [
|
const DIFFICULTY_OPTIONS = [
|
||||||
{ value: "0", labelZh: "全部难度", labelEn: "All Levels" },
|
{ value: "0", labelZh: "全部难度", labelEn: "All Tiers" },
|
||||||
{ value: "1", labelZh: "1", labelEn: "1" },
|
{ value: "1", labelZh: "1 - 木剑", labelEn: "1 - Wood" },
|
||||||
{ value: "2", labelZh: "2", labelEn: "2" },
|
{ value: "2", labelZh: "2 - 木剑", labelEn: "2 - Wood" },
|
||||||
{ value: "3", labelZh: "3", labelEn: "3" },
|
{ value: "3", labelZh: "3 - 石剑", labelEn: "3 - Stone" },
|
||||||
{ value: "4", labelZh: "4", labelEn: "4" },
|
{ value: "4", labelZh: "4 - 石剑", labelEn: "4 - Stone" },
|
||||||
{ value: "5", labelZh: "5", labelEn: "5" },
|
{ value: "5", labelZh: "5 - 铁剑", labelEn: "5 - Iron" },
|
||||||
{ value: "6", labelZh: "6", labelEn: "6" },
|
{ value: "6", labelZh: "6 - 铁剑", labelEn: "6 - Iron" },
|
||||||
{ value: "7", labelZh: "7", labelEn: "7" },
|
{ value: "7", labelZh: "7 - 钻石", labelEn: "7 - Diamond" },
|
||||||
{ value: "8", labelZh: "8", labelEn: "8" },
|
{ value: "8", labelZh: "8 - 钻石", labelEn: "8 - Diamond" },
|
||||||
{ value: "9", labelZh: "9", labelEn: "9" },
|
{ value: "9", labelZh: "9 - 下界合金", labelEn: "9 - Netherite" },
|
||||||
{ value: "10", labelZh: "10", labelEn: "10" },
|
{ value: "10", labelZh: "10 - 下界合金", labelEn: "10 - Netherite" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function parseProfile(raw: string): ProblemProfile | null {
|
function parseProfile(raw: string): ProblemProfile | null {
|
||||||
@@ -129,10 +129,19 @@ function parseProfile(raw: string): ProblemProfile | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function difficultyClass(diff: number): string {
|
function difficultyClass(diff: number): string {
|
||||||
if (diff <= 2) return "text-emerald-600";
|
if (diff <= 2) return "text-[color:var(--mc-wood)]";
|
||||||
if (diff <= 4) return "text-blue-600";
|
if (diff <= 4) return "text-[color:var(--mc-stone-dark)]";
|
||||||
if (diff <= 6) return "text-orange-600";
|
if (diff <= 6) return "text-zinc-100"; // Iron-ish
|
||||||
return "text-rose-600";
|
if (diff <= 8) return "text-[color:var(--mc-diamond)]";
|
||||||
|
return "text-[color:var(--mc-red)]"; // Netherite/Hard
|
||||||
|
}
|
||||||
|
|
||||||
|
function difficultyIcon(diff: number): string {
|
||||||
|
if (diff <= 2) return "🪵";
|
||||||
|
if (diff <= 4) return "🪨";
|
||||||
|
if (diff <= 6) return "⚔️";
|
||||||
|
if (diff <= 8) return "💎";
|
||||||
|
return "🔥";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePid(problem: Problem, profile: ProblemProfile | null): string {
|
function resolvePid(problem: Problem, profile: ProblemProfile | null): string {
|
||||||
@@ -232,23 +241,23 @@ export default function ProblemsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||||
{tx("题库(CSP J/S 入门)", "Problem Set (CSP J/S Beginner)")}
|
{tx("任务布告栏", "Quest Board")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-zinc-600">
|
<p className="mt-1 text-sm text-[color:var(--mc-stone)]">
|
||||||
{tx(
|
{tx(
|
||||||
"参考洛谷题库列表交互,默认聚焦 CSP-J / CSP-S / NOIP 入门训练。",
|
"接受任务,赚取 XP,提升等级!",
|
||||||
"Interaction style is inspired by Luogu problem list. Default focus: CSP-J / CSP-S / NOIP junior training."
|
"Accept Quests, Earn XP, Level Up!"
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-wrap items-center gap-3 text-sm sm:w-auto sm:justify-end">
|
<div className="flex w-full flex-wrap items-center gap-3 text-sm sm:w-auto sm:justify-end">
|
||||||
<p className="text-zinc-600">{tx("共", "Total")} {totalCount} {tx("题", "problems")}</p>
|
<p className="text-[color:var(--mc-gold)]">{tx("总任务数: ", "Total Quests: ")} {totalCount}</p>
|
||||||
<Link className="w-full rounded border px-3 py-1 text-center hover:bg-zinc-100 sm:w-auto" href="/backend-logs">
|
<Link className="mc-btn w-full text-center sm:w-auto" href="/backend-logs">
|
||||||
{tx("查看后台日志", "View Backend Logs")}
|
{tx("服务器日志", "Server Logs")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,15 +269,14 @@ export default function ProblemsPage() {
|
|||||||
<button
|
<button
|
||||||
key={card.presetKey}
|
key={card.presetKey}
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded-xl border px-4 py-3 text-left transition ${
|
className={`rounded-xl border px-4 py-3 text-left transition ${active
|
||||||
active
|
? "bg-[color:var(--mc-grass-dark)] text-white"
|
||||||
? "border-zinc-900 bg-zinc-900 text-white"
|
: "bg-[color:var(--mc-plank)] text-black hover:bg-[color:var(--mc-plank-light)]"
|
||||||
: "bg-white text-zinc-900 hover:border-zinc-400"
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => selectPreset(card.presetKey)}
|
onClick={() => selectPreset(card.presetKey)}
|
||||||
>
|
>
|
||||||
<p className="text-base font-semibold">{isZh ? card.titleZh : card.titleEn}</p>
|
<p className="text-base font-bold mc-text-shadow-sm">{isZh ? card.titleZh : card.titleEn}</p>
|
||||||
<p className={`mt-1 text-xs ${active ? "text-zinc-200" : "text-zinc-500"}`}>
|
<p className={`mt-1 text-xs ${active ? "text-zinc-100" : "text-zinc-800"}`}>
|
||||||
{isZh ? card.descZh : card.descEn}
|
{isZh ? card.descZh : card.descEn}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
@@ -276,9 +284,9 @@ export default function ProblemsPage() {
|
|||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-2 lg:grid-cols-6">
|
<section className="mt-4 grid gap-3 rounded-xl border bg-[color:var(--mc-stone-dark)] p-4 md:grid-cols-2 lg:grid-cols-6 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
<select
|
<select
|
||||||
className="rounded border px-3 py-2 text-sm"
|
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm"
|
||||||
value={presetKey}
|
value={presetKey}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
selectPreset(e.target.value);
|
selectPreset(e.target.value);
|
||||||
@@ -292,8 +300,8 @@ export default function ProblemsPage() {
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className="rounded border px-3 py-2 text-sm lg:col-span-2"
|
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm lg:col-span-2"
|
||||||
placeholder={tx("搜索题号/标题/题面关键词", "Search id/title/statement keywords")}
|
placeholder={tx("搜索任务 ID / 标题...", "Search Quest ID / Keyword...")}
|
||||||
value={keywordInput}
|
value={keywordInput}
|
||||||
onChange={(e) => setKeywordInput(e.target.value)}
|
onChange={(e) => setKeywordInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -302,7 +310,7 @@ export default function ProblemsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
className="rounded border px-3 py-2 text-sm"
|
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm"
|
||||||
value={difficulty}
|
value={difficulty}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDifficulty(e.target.value);
|
setDifficulty(e.target.value);
|
||||||
@@ -311,13 +319,13 @@ export default function ProblemsPage() {
|
|||||||
>
|
>
|
||||||
{DIFFICULTY_OPTIONS.map((item) => (
|
{DIFFICULTY_OPTIONS.map((item) => (
|
||||||
<option key={item.value} value={item.value}>
|
<option key={item.value} value={item.value}>
|
||||||
{tx("难度", "Difficulty")} {isZh ? item.labelZh : item.labelEn}
|
{tx("难度: ", "Tier: ")} {isZh ? item.labelZh : item.labelEn}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
className="rounded border px-3 py-2 text-sm"
|
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm"
|
||||||
value={`${orderBy}:${order}`}
|
value={`${orderBy}:${order}`}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const [ob, od] = e.target.value.split(":");
|
const [ob, od] = e.target.value.split(":");
|
||||||
@@ -326,16 +334,16 @@ export default function ProblemsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="id:asc">{tx("题号升序", "ID Asc")}</option>
|
<option value="id:asc">{tx("编号升序", "ID Asc")}</option>
|
||||||
<option value="id:desc">{tx("题号降序", "ID Desc")}</option>
|
<option value="id:desc">{tx("编号降序", "ID Desc")}</option>
|
||||||
<option value="difficulty:asc">{tx("难度升序", "Difficulty Asc")}</option>
|
<option value="difficulty:asc">{tx("难度升序", "Tier Asc")}</option>
|
||||||
<option value="difficulty:desc">{tx("难度降序", "Difficulty Desc")}</option>
|
<option value="difficulty:desc">{tx("难度降序", "Tier Desc")}</option>
|
||||||
<option value="created_at:desc">{tx("最新导入", "Newest Imported")}</option>
|
<option value="created_at:desc">{tx("最新发布", "Newest")}</option>
|
||||||
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
|
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
|
className="mc-btn mc-btn-primary"
|
||||||
onClick={applySearch}
|
onClick={applySearch}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
@@ -343,29 +351,28 @@ export default function ProblemsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-3 text-sm text-[color:var(--mc-red)]">{error}</p>}
|
||||||
|
|
||||||
<section className="mt-4 rounded-xl border bg-white">
|
<section className="mt-4 rounded-xl border bg-[color:var(--surface)] shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||||
<div className="divide-y md:hidden">
|
<div className="divide-y divide-black md:hidden">
|
||||||
{rows.map(({ problem, profile }) => {
|
{rows.map(({ problem, profile }) => {
|
||||||
const pid = resolvePid(problem, profile);
|
const pid = resolvePid(problem, profile);
|
||||||
const tags = resolveTags(profile);
|
const tags = resolveTags(profile);
|
||||||
return (
|
return (
|
||||||
<article key={problem.id} className="space-y-2 p-3">
|
<article key={problem.id} className="space-y-2 p-3 bg-[color:var(--surface)] text-zinc-100">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<Link className="font-medium text-blue-700 hover:underline" href={`/problems/${problem.id}`}>
|
<Link className="font-bold text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)] hover:underline" href={`/problems/${problem.id}`}>
|
||||||
{pid} · {problem.title}
|
{pid} · {problem.title}
|
||||||
</Link>
|
</Link>
|
||||||
<span className={`shrink-0 text-sm font-semibold ${difficultyClass(problem.difficulty)}`}>
|
<span className={`shrink-0 text-sm font-bold ${difficultyClass(problem.difficulty)}`}>
|
||||||
{tx("难度", "Difficulty")} {problem.difficulty}
|
{difficultyIcon(problem.difficulty)} T{problem.difficulty}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-zinc-600">{tx("通过/提交:", "Accepted/Submissions: ")}{resolvePassRate(profile)}</p>
|
<p className="text-xs text-[color:var(--mc-stone)]">{tx("完成率:", "Clear Rate: ")}{resolvePassRate(profile)}</p>
|
||||||
<p className="text-xs text-zinc-500 break-all">{tx("来源:", "Source: ")}{problem.source || "-"}</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{tags.length === 0 && <span className="text-xs text-zinc-400">-</span>}
|
{tags.length === 0 && <span className="text-xs text-[color:var(--mc-stone-dark)]">-</span>}
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs">
|
<span key={tag} className="border border-black bg-[color:var(--mc-stone-dark)] px-2 py-0.5 text-xs text-white">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -374,23 +381,23 @@ export default function ProblemsPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!loading && rows.length === 0 && (
|
{!loading && rows.length === 0 && (
|
||||||
<p className="px-3 py-6 text-center text-sm text-zinc-500">
|
<p className="px-3 py-6 text-center text-sm text-[color:var(--mc-stone)]">
|
||||||
{tx(
|
{tx(
|
||||||
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。",
|
"没有找到任务。请尝试其他频道或刷新地图。",
|
||||||
"No problems under current filters. Switch preset or run import first."
|
"No quests found. Try different channel or reload map."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
<div className="hidden overflow-x-auto md:block">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm text-zinc-200">
|
||||||
<thead className="bg-zinc-100 text-left text-zinc-700">
|
<thead className="bg-[color:var(--mc-wood-dark)] text-left text-white border-b-2 border-black">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2">{tx("题号", "ID")}</th>
|
<th className="px-3 py-2">{tx("编号", "ID")}</th>
|
||||||
<th className="px-3 py-2">{tx("标题", "Title")}</th>
|
<th className="px-3 py-2">{tx("任务标题", "Quest Title")}</th>
|
||||||
<th className="px-3 py-2">{tx("通过/提交", "Accepted/Submissions")}</th>
|
<th className="px-3 py-2">{tx("完成率", "Clear Rate")}</th>
|
||||||
<th className="px-3 py-2">{tx("难度", "Difficulty")}</th>
|
<th className="px-3 py-2">{tx("难度", "Tier")}</th>
|
||||||
<th className="px-3 py-2">{tx("标签", "Tags")}</th>
|
<th className="px-3 py-2">{tx("标签", "Tags")}</th>
|
||||||
<th className="px-3 py-2">{tx("来源", "Source")}</th>
|
<th className="px-3 py-2">{tx("来源", "Source")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -400,37 +407,37 @@ export default function ProblemsPage() {
|
|||||||
const pid = resolvePid(problem, profile);
|
const pid = resolvePid(problem, profile);
|
||||||
const tags = resolveTags(profile);
|
const tags = resolveTags(profile);
|
||||||
return (
|
return (
|
||||||
<tr key={problem.id} className="border-t hover:bg-zinc-50">
|
<tr key={problem.id} className="border-b border-black hover:bg-[color:var(--surface-soft)] transition-colors">
|
||||||
<td className="px-3 py-2 font-medium text-blue-700">{pid}</td>
|
<td className="px-3 py-2 font-bold text-[color:var(--mc-diamond)]">{pid}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Link className="hover:underline" href={`/problems/${problem.id}`}>
|
<Link className="hover:underline hover:text-[color:var(--mc-gold)]" href={`/problems/${problem.id}`}>
|
||||||
{problem.title}
|
{problem.title}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-zinc-600">{resolvePassRate(profile)}</td>
|
<td className="px-3 py-2 text-[color:var(--mc-stone)]">{resolvePassRate(profile)}</td>
|
||||||
<td className={`px-3 py-2 font-semibold ${difficultyClass(problem.difficulty)}`}>
|
<td className={`px-3 py-2 font-bold ${difficultyClass(problem.difficulty)}`}>
|
||||||
{problem.difficulty}
|
{difficultyIcon(problem.difficulty)} {problem.difficulty}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{tags.length === 0 && <span className="text-zinc-400">-</span>}
|
{tags.length === 0 && <span className="text-[color:var(--mc-stone-dark)]">-</span>}
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs">
|
<span key={tag} className="border border-black bg-[color:var(--mc-stone-dark)] px-2 py-0.5 text-xs text-white">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-zinc-500">{problem.source || "-"}</td>
|
<td className="px-3 py-2 text-[color:var(--mc-stone)]">{problem.source || "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!loading && rows.length === 0 && (
|
{!loading && rows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-3 py-6 text-center text-zinc-500" colSpan={6}>
|
<td className="px-3 py-6 text-center text-[color:var(--mc-stone)]" colSpan={6}>
|
||||||
{tx(
|
{tx(
|
||||||
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。",
|
"没有找到任务。请尝试其他频道或刷新地图。",
|
||||||
"No problems under current filters. Switch preset or run import first."
|
"No quests found. Try different channel or reload map."
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -443,17 +450,17 @@ export default function ProblemsPage() {
|
|||||||
<div className="mt-4 flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
<div className="mt-4 flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
className="mc-btn"
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={loading || page <= 1}
|
disabled={loading || page <= 1}
|
||||||
>
|
>
|
||||||
{tx("上一页", "Prev")}
|
{tx("上一页", "Prev")}
|
||||||
</button>
|
</button>
|
||||||
<span>
|
<span className="text-[color:var(--mc-diamond)] font-bold">
|
||||||
{isZh ? `第 ${page} / ${totalPages} 页` : `Page ${page} / ${totalPages}`}
|
{isZh ? `第 ${page} / ${totalPages} 页` : `Page ${page} / ${totalPages}`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
className="mc-btn"
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={loading || page >= totalPages}
|
disabled={loading || page >= totalPages}
|
||||||
>
|
>
|
||||||
@@ -461,10 +468,10 @@ export default function ProblemsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:justify-end">
|
<div className="flex items-center gap-2 sm:justify-end text-[color:var(--mc-stone)]">
|
||||||
<span>{tx("每页", "Per Page")}</span>
|
<span>{tx("每页", "Per Page")}</span>
|
||||||
<select
|
<select
|
||||||
className="rounded border px-2 py-1"
|
className="rounded border border-black bg-[color:var(--surface)] text-white px-2 py-1"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPageSize(Number(e.target.value));
|
setPageSize(Number(e.target.value));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { useI18nText } from "@/lib/i18n";
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||||
|
|
||||||
type Submission = {
|
type Submission = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -22,6 +23,8 @@ type ListResp = { items: Submission[]; page: number; page_size: number };
|
|||||||
|
|
||||||
export default function SubmissionsPage() {
|
export default function SubmissionsPage() {
|
||||||
const { tx } = useI18nText();
|
const { tx } = useI18nText();
|
||||||
|
const { theme } = useUiPreferences();
|
||||||
|
const isMc = theme === "minecraft";
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [problemId, setProblemId] = useState("");
|
const [problemId, setProblemId] = useState("");
|
||||||
const [contestId, setContestId] = useState("");
|
const [contestId, setContestId] = useState("");
|
||||||
@@ -35,9 +38,28 @@ export default function SubmissionsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ratingDeltaClass = (delta: number) => {
|
const ratingDeltaClass = (delta: number) => {
|
||||||
if (delta > 0) return "text-emerald-700";
|
if (delta > 0) return isMc ? "text-[color:var(--mc-green)]" : "text-emerald-700";
|
||||||
if (delta < 0) return "text-red-700";
|
if (delta < 0) return isMc ? "text-[color:var(--mc-red)]" : "text-red-700";
|
||||||
return "text-zinc-600";
|
return isMc ? "text-zinc-400" : "text-zinc-600";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map raw status codes to themed display text */
|
||||||
|
const statusLabel = (raw: string) => {
|
||||||
|
if (!isMc) return raw;
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
Accepted: "✅ " + tx("通过", "Accepted"),
|
||||||
|
AC: "✅ AC",
|
||||||
|
WA: "❌ WA",
|
||||||
|
"Wrong Answer": "❌ " + tx("答案错误", "Wrong Answer"),
|
||||||
|
TLE: "⏰ TLE",
|
||||||
|
"Time Limit Exceeded": "⏰ " + tx("超时", "TLE"),
|
||||||
|
MLE: "💾 MLE",
|
||||||
|
RE: "💥 RE",
|
||||||
|
"Runtime Error": "💥 " + tx("运行错误", "RE"),
|
||||||
|
CE: "🔧 CE",
|
||||||
|
"Compile Error": "🔧 " + tx("编译错误", "CE"),
|
||||||
|
};
|
||||||
|
return map[raw] ?? raw;
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -63,103 +85,133 @@ export default function SubmissionsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
<h1 className={`text-xl font-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
|
||||||
{tx("提交记录", "Submissions")}
|
{isMc ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>📜</span>
|
||||||
|
{tx("施法记录", "Spell Cast Log")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
tx("提交记录", "Submissions")
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-4">
|
{/* Filters */}
|
||||||
|
<div className={`mt-4 grid gap-3 rounded-xl border p-4 md:grid-cols-4 ${isMc
|
||||||
|
? "bg-[color:var(--mc-stone-dark)] border-[3px] border-black shadow-[4px_4px_0_rgba(0,0,0,0.5)]"
|
||||||
|
: "bg-white"}`}>
|
||||||
<input
|
<input
|
||||||
className="rounded border px-3 py-2"
|
className={`rounded border px-3 py-2 ${isMc
|
||||||
placeholder="user_id"
|
? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
|
||||||
|
: ""}`}
|
||||||
|
placeholder={isMc ? tx("冒险者 ID", "Adventurer ID") : "user_id"}
|
||||||
value={userId}
|
value={userId}
|
||||||
onChange={(e) => setUserId(e.target.value)}
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="rounded border px-3 py-2"
|
className={`rounded border px-3 py-2 ${isMc
|
||||||
placeholder="problem_id"
|
? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
|
||||||
|
: ""}`}
|
||||||
|
placeholder={isMc ? tx("任务 ID", "Quest ID") : "problem_id"}
|
||||||
value={problemId}
|
value={problemId}
|
||||||
onChange={(e) => setProblemId(e.target.value)}
|
onChange={(e) => setProblemId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="rounded border px-3 py-2"
|
className={`rounded border px-3 py-2 ${isMc
|
||||||
placeholder="contest_id"
|
? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
|
||||||
|
: ""}`}
|
||||||
|
placeholder={isMc ? tx("突袭 ID", "Raid ID") : "contest_id"}
|
||||||
value={contestId}
|
value={contestId}
|
||||||
onChange={(e) => setContestId(e.target.value)}
|
onChange={(e) => setContestId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
|
className={`px-4 py-2 disabled:opacity-50 ${isMc
|
||||||
|
? "mc-btn"
|
||||||
|
: "rounded bg-zinc-900 text-white"}`}
|
||||||
onClick={() => void load()}
|
onClick={() => void load()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? tx("加载中...", "Loading...") : tx("筛选", "Filter")}
|
{loading
|
||||||
|
? tx("搜索中...", "Searching...")
|
||||||
|
: isMc
|
||||||
|
? tx("🔍 搜索记录", "🔍 Search Logs")
|
||||||
|
: tx("筛选", "Filter")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
<div className="mt-4 rounded-xl border bg-white">
|
{/* Mobile cards */}
|
||||||
|
<div className={`mt-4 rounded-xl border ${isMc
|
||||||
|
? "bg-[color:var(--mc-stone-dark)] border-[3px] border-black shadow-[4px_4px_0_rgba(0,0,0,0.5)]"
|
||||||
|
: "bg-white"}`}>
|
||||||
<div className="divide-y md:hidden">
|
<div className="divide-y md:hidden">
|
||||||
{items.map((s) => (
|
{items.map((s) => (
|
||||||
<article key={s.id} className="space-y-2 p-3 text-sm">
|
<article key={s.id} className={`space-y-2 p-3 text-sm ${isMc ? "text-zinc-200 border-zinc-700" : ""}`}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="font-medium">{tx("提交", "Submission")} #{s.id}</p>
|
<p className={`font-medium ${isMc ? "text-[color:var(--mc-gold)]" : ""}`}>
|
||||||
<span className="text-xs text-zinc-500">{s.status}</span>
|
{isMc ? tx("施法", "Cast") : tx("提交", "Submission")} #{s.id}
|
||||||
|
</p>
|
||||||
|
<span className={`text-xs ${isMc ? "" : "text-zinc-500"}`}>{statusLabel(s.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-zinc-600">
|
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||||
{tx("用户", "User")} {s.user_id} · {tx("题目", "Problem")} {s.problem_id} · {tx("分数", "Score")} {s.score}
|
{isMc ? tx("冒险者", "Player") : tx("用户", "User")} {s.user_id} · {tx("任务", "Quest")} {s.problem_id} · {tx("分数", "Score")} {s.score}
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-xs ${ratingDeltaClass(s.rating_delta)}`}>
|
<p className={`text-xs ${ratingDeltaClass(s.rating_delta)}`}>
|
||||||
{tx("Rating 变化", "Rating Delta")} {fmtRatingDelta(s.rating_delta)}
|
{isMc ? tx("绿宝石变化", "Emerald Δ") : tx("Rating 变化", "Rating Delta")} {fmtRatingDelta(s.rating_delta)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-zinc-600">{tx("耗时", "Time")} {s.time_ms} ms</p>
|
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>{tx("耗时", "Time")} {s.time_ms} ms</p>
|
||||||
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
|
<Link className={`underline ${isMc ? "text-[color:var(--mc-diamond)]" : "text-blue-600"}`} href={`/submissions/${s.id}`}>
|
||||||
{tx("查看详情", "View Detail")}
|
{isMc ? tx("📜 查看详情", "📜 View Detail") : tx("查看详情", "View Detail")}
|
||||||
</Link>
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<p className="px-3 py-5 text-center text-sm text-zinc-500">{tx("暂无提交记录", "No submissions yet")}</p>
|
<p className={`px-3 py-5 text-center text-sm ${isMc ? "text-zinc-500" : "text-zinc-500"}`}>
|
||||||
|
{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop table */}
|
||||||
<div className="hidden overflow-x-auto md:block">
|
<div className="hidden overflow-x-auto md:block">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead className="bg-zinc-100 text-left">
|
<thead className={isMc ? "bg-black/30 text-zinc-300 text-left" : "bg-zinc-100 text-left"}>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2">ID</th>
|
<th className="px-3 py-2">ID</th>
|
||||||
<th className="px-3 py-2">{tx("用户", "User")}</th>
|
<th className="px-3 py-2">{isMc ? tx("冒险者", "Player") : tx("用户", "User")}</th>
|
||||||
<th className="px-3 py-2">{tx("题目", "Problem")}</th>
|
<th className="px-3 py-2">{tx("任务", "Quest")}</th>
|
||||||
<th className="px-3 py-2">{tx("状态", "Status")}</th>
|
<th className="px-3 py-2">{tx("状态", "Status")}</th>
|
||||||
<th className="px-3 py-2">{tx("分数", "Score")}</th>
|
<th className="px-3 py-2">{tx("分数", "Score")}</th>
|
||||||
<th className="px-3 py-2">{tx("Rating 变化", "Rating Delta")}</th>
|
<th className="px-3 py-2">{isMc ? tx("绿宝石 Δ", "Emerald Δ") : tx("Rating 变化", "Rating Delta")}</th>
|
||||||
<th className="px-3 py-2">{tx("耗时(ms)", "Time(ms)")}</th>
|
<th className="px-3 py-2">{tx("耗时(ms)", "Time(ms)")}</th>
|
||||||
<th className="px-3 py-2">{tx("详情", "Detail")}</th>
|
<th className="px-3 py-2">{tx("详情", "Detail")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((s) => (
|
{items.map((s) => (
|
||||||
<tr key={s.id} className="border-t">
|
<tr key={s.id} className={`border-t ${isMc ? "border-zinc-700" : ""}`}>
|
||||||
<td className="px-3 py-2">{s.id}</td>
|
<td className={`px-3 py-2 ${isMc ? "text-[color:var(--mc-gold)]" : ""}`}>{s.id}</td>
|
||||||
<td className="px-3 py-2">{s.user_id}</td>
|
<td className="px-3 py-2">{s.user_id}</td>
|
||||||
<td className="px-3 py-2">{s.problem_id}</td>
|
<td className="px-3 py-2">{s.problem_id}</td>
|
||||||
<td className="px-3 py-2">{s.status}</td>
|
<td className="px-3 py-2">{statusLabel(s.status)}</td>
|
||||||
<td className="px-3 py-2">{s.score}</td>
|
<td className="px-3 py-2">{s.score}</td>
|
||||||
<td className={`px-3 py-2 ${ratingDeltaClass(s.rating_delta)}`}>
|
<td className={`px-3 py-2 ${ratingDeltaClass(s.rating_delta)}`}>
|
||||||
{fmtRatingDelta(s.rating_delta)}
|
{fmtRatingDelta(s.rating_delta)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">{s.time_ms}</td>
|
<td className="px-3 py-2">{s.time_ms}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
|
<Link className={`underline ${isMc ? "text-[color:var(--mc-diamond)]" : "text-blue-600"}`} href={`/submissions/${s.id}`}>
|
||||||
{tx("查看", "View")}
|
{isMc ? tx("📜 查看", "📜 View") : tx("查看", "View")}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-3 py-5 text-center text-zinc-500" colSpan={8}>
|
<td className={`px-3 py-5 text-center ${isMc ? "text-zinc-500" : "text-zinc-500"}`} colSpan={8}>
|
||||||
{tx("暂无提交记录", "No submissions yet")}
|
{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { readToken } from "@/lib/auth";
|
import { readToken } from "@/lib/auth";
|
||||||
import { useI18nText } from "@/lib/i18n";
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||||
|
|
||||||
type WrongBookItem = {
|
type WrongBookItem = {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -23,6 +24,8 @@ function fmtTs(v: number): string {
|
|||||||
|
|
||||||
export default function WrongBookPage() {
|
export default function WrongBookPage() {
|
||||||
const { tx } = useI18nText();
|
const { tx } = useI18nText();
|
||||||
|
const { theme } = useUiPreferences();
|
||||||
|
const isMc = theme === "minecraft";
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [items, setItems] = useState<WrongBookItem[]>([]);
|
const [items, setItems] = useState<WrongBookItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -73,21 +76,36 @@ export default function WrongBookPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
<h1 className={`text-xl font-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
|
||||||
{tx("错题本", "Wrong Book")}
|
{isMc ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>📜</span>
|
||||||
|
{tx("诅咒卷轴", "Cursed Scrolls")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
tx("错题本", "Wrong Book")
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-600">
|
<p className={`mt-2 text-sm ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||||
{tx("未通过提交会自动进入错题本。", "Failed submissions are added to the wrong-book automatically.")}
|
{isMc
|
||||||
|
? tx("失败的咒语会自动记录在诅咒卷轴中,复习并重新挑战!", "Failed spells are recorded in your Cursed Scrolls. Review and retry!")
|
||||||
|
: tx("未通过提交会自动进入错题本。", "Failed submissions are added to the wrong-book automatically.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button
|
<button
|
||||||
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
|
className={`px-4 py-2 disabled:opacity-50 ${isMc
|
||||||
|
? "mc-btn"
|
||||||
|
: "rounded bg-zinc-900 text-white"}`}
|
||||||
onClick={() => void load()}
|
onClick={() => void load()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? tx("刷新中...", "Refreshing...") : tx("刷新", "Refresh")}
|
{loading
|
||||||
|
? tx("搜索中...", "Searching...")
|
||||||
|
: isMc
|
||||||
|
? tx("🔍 重新搜索", "🔍 Search Again")
|
||||||
|
: tx("刷新", "Refresh")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,46 +113,63 @@ export default function WrongBookPage() {
|
|||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.problem_id} className="rounded-xl border bg-white p-4">
|
<div
|
||||||
|
key={item.problem_id}
|
||||||
|
className={`rounded-xl border p-4 ${isMc
|
||||||
|
? "bg-[color:var(--mc-stone-dark)] border-[3px] border-black shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white"
|
||||||
|
: "bg-white"}`}
|
||||||
|
>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<Link className="font-medium text-blue-700 hover:underline" href={`/problems/${item.problem_id}`}>
|
<Link
|
||||||
|
className={`font-medium hover:underline ${isMc ? "text-[color:var(--mc-gold)]" : "text-blue-700"}`}
|
||||||
|
href={`/problems/${item.problem_id}`}
|
||||||
|
>
|
||||||
#{item.problem_id} {item.problem_title}
|
#{item.problem_id} {item.problem_title}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
|
className={`rounded border px-3 py-1 text-sm ${isMc
|
||||||
|
? "mc-btn text-xs"
|
||||||
|
: "hover:bg-zinc-100"}`}
|
||||||
href={`/problems/${item.problem_id}`}
|
href={`/problems/${item.problem_id}`}
|
||||||
>
|
>
|
||||||
{tx("查看题目", "View Problem")}
|
{isMc ? tx("⚔️ 重新挑战", "⚔️ Retry Quest") : tx("查看任务", "View Quest")}
|
||||||
</Link>
|
</Link>
|
||||||
{item.last_submission_id && (
|
{item.last_submission_id && (
|
||||||
<Link
|
<Link
|
||||||
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
|
className={`rounded border px-3 py-1 text-sm ${isMc
|
||||||
|
? "mc-btn text-xs"
|
||||||
|
: "hover:bg-zinc-100"}`}
|
||||||
href={`/submissions/${item.last_submission_id}`}
|
href={`/submissions/${item.last_submission_id}`}
|
||||||
>
|
>
|
||||||
{tx("查看最近提交", "View Latest Submission")}
|
{isMc ? tx("📜 查看战报", "📜 View Battle Log") : tx("查看最近提交", "View Latest Submission")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
<p className={`mt-1 text-xs ${isMc ? "text-zinc-400" : "text-zinc-500"}`}>
|
||||||
{tx("最近提交:", "Latest Submission:")} {item.last_submission_id ?? "-"} ·{" "}
|
{isMc ? tx("上次施法:", "Last Cast:") : tx("最近提交:", "Latest Submission:")} {item.last_submission_id ?? "-"} ·{" "}
|
||||||
{tx("更新时间:", "Updated:")} {fmtTs(item.updated_at)}
|
{tx("更新时间:", "Updated:")} {fmtTs(item.updated_at)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap justify-end gap-2">
|
<div className="mt-2 flex flex-wrap justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
|
className={`rounded border px-3 py-1 text-sm ${isMc
|
||||||
|
? "border-red-900 bg-red-900/40 text-red-300 hover:bg-red-900/60"
|
||||||
|
: "hover:bg-zinc-100"}`}
|
||||||
onClick={() => void removeItem(item.problem_id)}
|
onClick={() => void removeItem(item.problem_id)}
|
||||||
>
|
>
|
||||||
{tx("移除", "Remove")}
|
{isMc ? tx("🗑️ 移除诅咒", "🗑️ Remove Curse") : tx("移除", "Remove")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="mt-2 h-24 w-full rounded border p-2 text-sm"
|
className={`mt-2 h-24 w-full rounded border p-2 text-sm ${isMc
|
||||||
|
? "bg-black/40 border-zinc-600 text-zinc-200 placeholder:text-zinc-500"
|
||||||
|
: ""}`}
|
||||||
value={item.note}
|
value={item.note}
|
||||||
|
placeholder={isMc ? tx("记录你的笔记...", "Write your notes...") : ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.target.value;
|
const next = e.target.value;
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
@@ -145,18 +180,27 @@ export default function WrongBookPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="mt-2 rounded border px-3 py-1 text-sm hover:bg-zinc-100"
|
className={`mt-2 rounded border px-3 py-1 text-sm ${isMc
|
||||||
|
? "mc-btn text-xs"
|
||||||
|
: "hover:bg-zinc-100"}`}
|
||||||
onClick={() => void updateNote(item.problem_id, item.note)}
|
onClick={() => void updateNote(item.problem_id, item.note)}
|
||||||
>
|
>
|
||||||
{tx("保存备注", "Save Note")}
|
{isMc ? tx("💾 保存笔记", "💾 Save Notes") : tx("保存备注", "Save Note")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<div className="rounded-xl border bg-white p-6 text-center text-sm text-zinc-500">
|
<div className={`rounded-xl border p-6 text-center text-sm ${isMc
|
||||||
{tx(
|
? "bg-black/20 border-zinc-700 text-zinc-500"
|
||||||
"暂无错题。提交未通过后会自动加入错题本,可点击“查看题目/查看最近提交”快速复盘。",
|
: "bg-white text-zinc-500"}`}>
|
||||||
"No wrong-book entries yet. Failed submissions will be added automatically; use “View Problem/View Latest Submission” to review quickly."
|
{isMc
|
||||||
|
? tx(
|
||||||
|
"🎉 诅咒卷轴为空!你的每一个咒语都精准命中了目标。",
|
||||||
|
"🎉 No cursed scrolls! Every spell you cast hit its mark."
|
||||||
|
)
|
||||||
|
: tx(
|
||||||
|
"暂无错题。提交未通过后会自动加入错题本,可点击「查看题目/查看最近提交」快速复盘。",
|
||||||
|
'No wrong-book entries yet. Failed submissions will be added automatically.'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { PixelAvatar } from "@/components/pixel-avatar";
|
import { PixelAvatar } from "@/components/pixel-avatar";
|
||||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||||
|
import { XpBar } from "@/components/xp-bar";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { clearToken, readToken } from "@/lib/auth";
|
import { clearToken, readToken } from "@/lib/auth";
|
||||||
import type { ThemeId } from "@/themes/types";
|
import type { ThemeId } from "@/themes/types";
|
||||||
@@ -199,8 +200,7 @@ export function AppNav() {
|
|||||||
<div key={group.key} className="relative">
|
<div key={group.key} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded-md border px-3 py-1 text-sm ${
|
className={`rounded-md border px-3 py-1 text-sm ${active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||||
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
|
||||||
}`}
|
}`}
|
||||||
aria-expanded={opened}
|
aria-expanded={opened}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -211,8 +211,7 @@ export function AppNav() {
|
|||||||
</button>
|
</button>
|
||||||
{opened && (
|
{opened && (
|
||||||
<div
|
<div
|
||||||
className={`absolute left-0 top-full z-50 mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${
|
className={`absolute left-0 top-full z-50 mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
|
||||||
group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{group.links.map((item) => {
|
{group.links.map((item) => {
|
||||||
@@ -221,8 +220,7 @@ export function AppNav() {
|
|||||||
<button
|
<button
|
||||||
key={item.href}
|
key={item.href}
|
||||||
type="button"
|
type="button"
|
||||||
className={`block w-full rounded px-3 py-1.5 text-left text-sm ${
|
className={`block w-full rounded px-3 py-1.5 text-left text-sm ${linkActive ? "bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||||
linkActive ? "bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDesktopOpenGroup(null);
|
setDesktopOpenGroup(null);
|
||||||
@@ -306,8 +304,7 @@ export function AppNav() {
|
|||||||
<button
|
<button
|
||||||
key={group.key}
|
key={group.key}
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded-md border px-3 py-1 text-sm ${
|
className={`rounded-md border px-3 py-1 text-sm ${active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||||
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => router.push(group.links[0]?.href ?? "/")}
|
onClick={() => router.push(group.links[0]?.href ?? "/")}
|
||||||
>
|
>
|
||||||
@@ -391,12 +388,19 @@ export function AppNav() {
|
|||||||
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
|
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
|
||||||
</span>
|
</span>
|
||||||
{hasToken && (
|
{hasToken && (
|
||||||
|
<>
|
||||||
|
{theme === "minecraft" && (
|
||||||
|
<div className="hidden md:block w-32 mr-2">
|
||||||
|
<XpBar level={5} currentXp={750} nextLevelXp={1000} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<PixelAvatar
|
<PixelAvatar
|
||||||
seed={avatarSeed}
|
seed={avatarSeed}
|
||||||
size={24}
|
size={24}
|
||||||
className="border-zinc-700"
|
className="border-zinc-700"
|
||||||
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
|
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{hasToken && (
|
{hasToken && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import remarkMath from "remark-math";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeImageSrc(src: string): string {
|
function normalizeImageSrc(src: string): string {
|
||||||
@@ -18,9 +19,9 @@ function normalizeImageSrc(src: string): string {
|
|||||||
return src;
|
return src;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownRenderer({ markdown }: Props) {
|
export function MarkdownRenderer({ markdown, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<article className="space-y-3 text-sm leading-7 text-zinc-800">
|
<article className={`space-y-3 text-sm leading-7 text-zinc-800 ${className ?? ""}`}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
rehypePlugins={[rehypeKatex, rehypeHighlight]}
|
rehypePlugins={[rehypeKatex, rehypeHighlight]}
|
||||||
|
|||||||
@@ -12,18 +12,22 @@ function isActivePath(pathname: string, href: string): boolean {
|
|||||||
|
|
||||||
export function MobileTabBar() {
|
export function MobileTabBar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useUiPreferences();
|
const { t, theme } = useUiPreferences();
|
||||||
|
const isMc = theme === "minecraft";
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: t("mobile.tab.problems"), href: "/problems" },
|
{ label: t("mobile.tab.problems"), href: "/problems", icon: "📜" },
|
||||||
{ label: t("mobile.tab.submissions"), href: "/submissions" },
|
{ label: t("mobile.tab.submissions"), href: "/submissions", icon: "⏱️" },
|
||||||
{ label: t("mobile.tab.contests"), href: "/contests" },
|
{ label: t("mobile.tab.contests"), href: "/contests", icon: "⚔️" },
|
||||||
{ label: t("mobile.tab.kb"), href: "/kb" },
|
{ label: t("mobile.tab.kb"), href: "/kb", icon: "📘" },
|
||||||
{ label: t("mobile.tab.me"), href: "/me" },
|
{ label: t("mobile.tab.me"), href: "/me", icon: "👤" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="print-hidden fixed inset-x-0 bottom-0 z-40 border-t bg-[color:var(--surface)]/95 pb-[calc(0.3rem+env(safe-area-inset-bottom))] pt-1 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85 md:hidden">
|
<nav className={`print-hidden fixed inset-x-0 bottom-0 z-40 border-t pb-[calc(0.3rem+env(safe-area-inset-bottom))] pt-2 md:hidden ${isMc
|
||||||
|
? "bg-[color:var(--mc-stone-dark)] border-black border-t-[3px]"
|
||||||
|
: "bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85"
|
||||||
|
}`}>
|
||||||
<div className="mx-auto max-w-5xl px-2 max-[390px]:px-1.5">
|
<div className="mx-auto max-w-5xl px-2 max-[390px]:px-1.5">
|
||||||
<div className="grid grid-cols-5 gap-1 max-[390px]:gap-0.5">
|
<div className="grid grid-cols-5 gap-1 max-[390px]:gap-0.5">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
@@ -32,13 +36,17 @@ export function MobileTabBar() {
|
|||||||
<Link
|
<Link
|
||||||
key={tab.href}
|
key={tab.href}
|
||||||
href={tab.href}
|
href={tab.href}
|
||||||
className={`rounded-md px-1 py-1.5 text-center text-xs max-[390px]:text-[11px] ${
|
className={`flex flex-col items-center justify-center rounded-none px-1 py-1 text-center text-[10px] sm:text-xs ${isMc
|
||||||
active
|
? active
|
||||||
? "bg-zinc-900 font-semibold text-white"
|
? "bg-[color:var(--mc-diamond)] text-black font-bold border-2 border-black"
|
||||||
: "text-zinc-600 hover:bg-zinc-100"
|
: "bg-[color:var(--mc-stone)] text-zinc-300 border-2 border-black/50 hover:bg-[color:var(--mc-stone-light)]"
|
||||||
|
: active
|
||||||
|
? "bg-zinc-900 font-semibold text-white rounded-md"
|
||||||
|
: "text-zinc-600 hover:bg-zinc-100 rounded-md"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{isMc && <span className="text-sm mb-0.5">{tab.icon}</span>}
|
||||||
|
<span className="truncate w-full">{tab.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
level: number;
|
||||||
|
currentXp: number;
|
||||||
|
nextLevelXp: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function XpBar({ level, currentXp, nextLevelXp, className = "" }: Props) {
|
||||||
|
const { theme } = useUiPreferences();
|
||||||
|
const isMc = theme === "minecraft";
|
||||||
|
const progress = Math.min(100, Math.max(0, (currentXp / nextLevelXp) * 100));
|
||||||
|
|
||||||
|
if (!isMc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col items-center select-none ${className}`}>
|
||||||
|
<div className="relative w-full max-w-[400px]">
|
||||||
|
{/* Level Indicator */}
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 text-[#80ff20] drop-shadow-[2px_2px_0_#000] font-[PressStart2P] text-xs z-10">
|
||||||
|
{level}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* XP Bar Background */}
|
||||||
|
<div className="h-3 w-full bg-[#3a3a3a] border-2 border-black flex relative">
|
||||||
|
{/* XP Bar Progress */}
|
||||||
|
<div
|
||||||
|
className="h-full bg-[#80ff20] transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${progress}%`, boxShadow: "inset 0 2px 0 rgba(255,255,255,0.3), inset 0 -2px 0 rgba(0,0,0,0.2)" }}
|
||||||
|
/>
|
||||||
|
{/* Segmentation lines (every 10%) */}
|
||||||
|
{Array.from({ length: 9 }).map((_, i) => (
|
||||||
|
<div key={i} className="absolute top-0 bottom-0 w-[2px] bg-black/20" style={{ left: `${(i + 1) * 10}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* XP Text */}
|
||||||
|
<div className="mt-1 text-[10px] text-[#80ff20] font-[VT323] drop-shadow-[1px_1px_0_#000]">
|
||||||
|
{currentXp} / {nextLevelXp}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,11 +52,9 @@ export async function apiFetch<T>(
|
|||||||
} catch (retryErr) {
|
} catch (retryErr) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
uiText(
|
uiText(
|
||||||
`网络请求失败,请检查后端服务或代理连接(${
|
`网络请求失败,请检查后端服务或代理连接(${retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||||
retryErr instanceof Error ? retryErr.message : String(retryErr)
|
|
||||||
})`,
|
})`,
|
||||||
`Network request failed. Please check backend/proxy connectivity (${
|
`Network request failed. Please check backend/proxy connectivity (${retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||||
retryErr instanceof Error ? retryErr.message : String(retryErr)
|
|
||||||
}).`
|
}).`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -92,3 +90,14 @@ export async function apiFetch<T>(
|
|||||||
|
|
||||||
return payload as T;
|
return payload as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RatingHistoryItem {
|
||||||
|
type: string;
|
||||||
|
created_at: number;
|
||||||
|
change: number;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRatingHistory(limit: number = 100): Promise<RatingHistoryItem[]> {
|
||||||
|
return apiFetch<RatingHistoryItem[]>(`/api/v1/me/rating-history?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { enMessages } from "@/themes/default/messages/en";
|
import { enMessages } from "./messages/en";
|
||||||
import { zhMessages } from "@/themes/default/messages/zh";
|
import { zhMessages } from "./messages/zh";
|
||||||
import type { ThemeDefinition } from "@/themes/types";
|
import type { ThemeDefinition } from "@/themes/types";
|
||||||
|
|
||||||
export const minecraftTheme: ThemeDefinition = {
|
export const minecraftTheme: ThemeDefinition = {
|
||||||
@@ -13,3 +13,4 @@ export const minecraftTheme: ThemeDefinition = {
|
|||||||
zh: zhMessages,
|
zh: zhMessages,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ThemeMessages } from "@/themes/types";
|
||||||
|
|
||||||
|
export const enMessages: ThemeMessages = {
|
||||||
|
"app.title": "CSP Quest Chronicles",
|
||||||
|
|
||||||
|
"nav.menu": "Game Menu",
|
||||||
|
"nav.expand": "Open Inventory",
|
||||||
|
"nav.collapse": "Close",
|
||||||
|
"nav.secondary_menu": "Quick Slots",
|
||||||
|
"nav.logged_in": "Online",
|
||||||
|
"nav.logged_out": "Offline",
|
||||||
|
"nav.logout": "Disconnect",
|
||||||
|
|
||||||
|
"nav.group.learn": "Adventure",
|
||||||
|
"nav.group.contest": "Battle Area",
|
||||||
|
"nav.group.system": "Server Ops",
|
||||||
|
"nav.group.account": "Player",
|
||||||
|
|
||||||
|
"nav.link.home": "Spawn Point",
|
||||||
|
"nav.link.problems": "Quest Board",
|
||||||
|
"nav.link.submissions": "Adventure Log",
|
||||||
|
"nav.link.wrong_book": "Grimoire",
|
||||||
|
"nav.link.kb": "Enchanted Library",
|
||||||
|
"nav.link.run": "Craft Code",
|
||||||
|
"nav.link.contests": "Raids",
|
||||||
|
"nav.link.leaderboard": "Hall of Fame",
|
||||||
|
"nav.link.imports": "Import Maps",
|
||||||
|
"nav.link.backend_logs": "Server Logs",
|
||||||
|
"nav.link.admin_users": "XP Management",
|
||||||
|
"nav.link.admin_redeem": "Loot Config",
|
||||||
|
"nav.link.api_docs": "Redstone Logic",
|
||||||
|
"nav.link.auth": "Login to Server",
|
||||||
|
"nav.link.me": "Character Sheet",
|
||||||
|
|
||||||
|
"mobile.tab.problems": "Quests",
|
||||||
|
"mobile.tab.submissions": "History",
|
||||||
|
"mobile.tab.contests": "Raids",
|
||||||
|
"mobile.tab.kb": "Library",
|
||||||
|
"mobile.tab.me": "Char",
|
||||||
|
|
||||||
|
"prefs.theme": "Texture Pack",
|
||||||
|
"prefs.language": "Language",
|
||||||
|
"prefs.lang.en": "English",
|
||||||
|
"prefs.lang.zh": "Chinese",
|
||||||
|
|
||||||
|
"admin.entry.title": "OP Control Panel",
|
||||||
|
"admin.entry.desc": "Super User: admin / whoami139",
|
||||||
|
"admin.entry.login": "Enter Portal",
|
||||||
|
"admin.entry.user_rating": "Manage XP",
|
||||||
|
"admin.entry.redeem": "Manage Loot",
|
||||||
|
"admin.entry.logs": "Server Console",
|
||||||
|
"admin.entry.moved_to_platform": "Redirected to Server Ops module.",
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ThemeMessages } from "@/themes/types";
|
||||||
|
|
||||||
|
export const zhMessages: ThemeMessages = {
|
||||||
|
"app.title": "CSP 冒险传奇",
|
||||||
|
|
||||||
|
"nav.menu": "游戏菜单",
|
||||||
|
"nav.expand": "打开背包",
|
||||||
|
"nav.collapse": "关闭",
|
||||||
|
"nav.secondary_menu": "快捷栏",
|
||||||
|
"nav.logged_in": "在线",
|
||||||
|
"nav.logged_out": "离线",
|
||||||
|
"nav.logout": "断开连接",
|
||||||
|
|
||||||
|
"nav.group.learn": "冒险模式",
|
||||||
|
"nav.group.contest": "竞技场",
|
||||||
|
"nav.group.system": "服务器指令",
|
||||||
|
"nav.group.account": "玩家档案",
|
||||||
|
|
||||||
|
"nav.link.home": "出生点",
|
||||||
|
"nav.link.problems": "任务布告栏",
|
||||||
|
"nav.link.submissions": "冒险日志",
|
||||||
|
"nav.link.wrong_book": "错题卷轴",
|
||||||
|
"nav.link.kb": "附魔指南",
|
||||||
|
"nav.link.run": "代码工作台",
|
||||||
|
"nav.link.contests": "团队副本",
|
||||||
|
"nav.link.leaderboard": "英雄榜",
|
||||||
|
"nav.link.imports": "地图导入",
|
||||||
|
"nav.link.backend_logs": "服务器日志",
|
||||||
|
"nav.link.admin_users": "XP管理",
|
||||||
|
"nav.link.admin_redeem": "战利品配置",
|
||||||
|
"nav.link.api_docs": "红石电路图",
|
||||||
|
"nav.link.auth": "登录服务器",
|
||||||
|
"nav.link.me": "角色面板",
|
||||||
|
|
||||||
|
"mobile.tab.problems": "任务",
|
||||||
|
"mobile.tab.submissions": "日志",
|
||||||
|
"mobile.tab.contests": "副本",
|
||||||
|
"mobile.tab.kb": "指南",
|
||||||
|
"mobile.tab.me": "角色",
|
||||||
|
|
||||||
|
"prefs.theme": "材质包",
|
||||||
|
"prefs.language": "语言",
|
||||||
|
"prefs.lang.en": "英语",
|
||||||
|
"prefs.lang.zh": "中文",
|
||||||
|
|
||||||
|
"admin.entry.title": "OP 控制台",
|
||||||
|
"admin.entry.desc": "管理员账号:admin / whoami139",
|
||||||
|
"admin.entry.login": "进入传送门",
|
||||||
|
"admin.entry.user_rating": "XP 管理",
|
||||||
|
"admin.entry.redeem": "战利品管理",
|
||||||
|
"admin.entry.logs": "服务器日志",
|
||||||
|
"admin.entry.moved_to_platform": "已重新定向至服务器指令模块。",
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: "PressStart2P";
|
font-family: "PressStart2P";
|
||||||
src: url("https://fonts.gstatic.com/s/pressstart2p/v15/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2")
|
src: url("https://fonts.gstatic.com/s/pressstart2p/v15/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2") format("woff2");
|
||||||
format("woff2");
|
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +57,18 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
text-shadow: 2px 2px 0 #000000;
|
text-shadow: 2px 2px 0 #000000;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="minecraft"] .mc-text-shadow {
|
||||||
|
text-shadow: 2px 2px 0 #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="minecraft"] .mc-text-shadow-sm {
|
||||||
|
text-shadow: 1px 1px 0 #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
:root[data-theme="minecraft"] ::-webkit-scrollbar {
|
:root[data-theme="minecraft"] ::-webkit-scrollbar {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
@@ -157,36 +166,62 @@
|
|||||||
color: var(--mc-red) !important;
|
color: var(--mc-red) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="minecraft"] button {
|
:root[data-theme="minecraft"] button:not(.mc-reset),
|
||||||
|
:root[data-theme="minecraft"] .mc-btn {
|
||||||
background: linear-gradient(180deg, var(--mc-wood) 0%, var(--mc-wood-dark) 100%) !important;
|
background: linear-gradient(180deg, var(--mc-wood) 0%, var(--mc-wood-dark) 100%) !important;
|
||||||
border: 3px solid #000 !important;
|
border: 3px solid #000 !important;
|
||||||
border-bottom-width: 7px !important;
|
border-bottom-width: 7px !important;
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
font-family: "PressStart2P", "VT323", sans-serif !important;
|
font-family: "PressStart2P", "VT323", sans-serif !important;
|
||||||
font-size: 0.62rem !important;
|
font-size: 0.75rem !important;
|
||||||
|
/* Increased for better readability */
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
text-shadow: 1px 1px 0 #000;
|
text-shadow: 1px 1px 0 #000;
|
||||||
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.48);
|
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.48);
|
||||||
transition: transform 0.08s ease, filter 0.08s ease;
|
transition: transform 0.08s ease, filter 0.08s ease;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.5rem 1rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="minecraft"] button:hover:not(:disabled) {
|
:root[data-theme="minecraft"] button:not(.mc-reset):hover:not(:disabled),
|
||||||
filter: brightness(1.07);
|
:root[data-theme="minecraft"] .mc-btn:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
filter: brightness(1.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="minecraft"] button:active:not(:disabled) {
|
:root[data-theme="minecraft"] button:not(.mc-reset):active:not(:disabled),
|
||||||
|
:root[data-theme="minecraft"] .mc-btn:active:not(:disabled) {
|
||||||
border-bottom-width: 3px !important;
|
border-bottom-width: 3px !important;
|
||||||
transform: translateY(3px);
|
transform: translateY(4px);
|
||||||
|
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.48);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="minecraft"] button:disabled {
|
:root[data-theme="minecraft"] button:not(.mc-reset):disabled,
|
||||||
opacity: 0.68;
|
:root[data-theme="minecraft"] .mc-btn:disabled {
|
||||||
filter: saturate(0.28);
|
opacity: 0.6;
|
||||||
|
filter: grayscale(0.8);
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Variant: Primary/Diamond */
|
||||||
|
:root[data-theme="minecraft"] .mc-btn-primary {
|
||||||
|
background: linear-gradient(180deg, var(--mc-diamond) 0%, #008ba3 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variant: Danger/Red */
|
||||||
|
:root[data-theme="minecraft"] .mc-btn-danger {
|
||||||
|
background: linear-gradient(180deg, var(--mc-red) 0%, #c62828 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variant: Success/Emerald */
|
||||||
|
:root[data-theme="minecraft"] .mc-btn-success {
|
||||||
|
background: linear-gradient(180deg, var(--mc-grass-top) 0%, var(--mc-grass-dark) 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
:root[data-theme="minecraft"] input,
|
:root[data-theme="minecraft"] input,
|
||||||
:root[data-theme="minecraft"] textarea,
|
:root[data-theme="minecraft"] textarea,
|
||||||
:root[data-theme="minecraft"] select {
|
:root[data-theme="minecraft"] select {
|
||||||
@@ -290,3 +325,42 @@
|
|||||||
:root[data-theme="minecraft"] .problem-markdown-compact th {
|
:root[data-theme="minecraft"] .problem-markdown-compact th {
|
||||||
background: #3a3a3a !important;
|
background: #3a3a3a !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Problem detail page markdown: dark text on light plank background ── */
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown,
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown-compact {
|
||||||
|
font-family: "MiSans", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown,
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown article {
|
||||||
|
color: #3e2723 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown h1,
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown h2,
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown h3 {
|
||||||
|
color: #3e2723 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown p,
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown li,
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown span,
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown td,
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown blockquote {
|
||||||
|
color: #4e342e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown th {
|
||||||
|
color: #3e2723 !important;
|
||||||
|
background: #d7ccc8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown code:not([class*="hljs"]) {
|
||||||
|
color: #4e342e !important;
|
||||||
|
background: #d7ccc8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="minecraft"] .problem-markdown a {
|
||||||
|
color: #1565c0 !important;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export type ThemeId = "default" | "minecraft";
|
export type ThemeId = "default" | "minecraft";
|
||||||
export type UiLanguage = "en" | "zh";
|
export type UiLanguage = "en" | "zh";
|
||||||
|
|
||||||
export const DEFAULT_THEME: ThemeId = "default";
|
export const DEFAULT_THEME: ThemeId = "minecraft";
|
||||||
export const DEFAULT_LANGUAGE: UiLanguage = "zh";
|
export const DEFAULT_LANGUAGE: UiLanguage = "zh";
|
||||||
|
|
||||||
export type ThemeMessages = Record<string, string>;
|
export type ThemeMessages = Record<string, string>;
|
||||||
|
|||||||
24
start_adventure.sh
可执行文件
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
|
||||||
在新工单中引用
屏蔽一个用户