feat: expand platform management, admin controls, and learning workflows

这个提交包含在:
Codex CLI
2026-02-15 15:41:56 +08:00
父节点 ad29a9f62d
当前提交 f209ae82da
修改 75 个文件,包含 9663 行新增794 行删除

3
.gitignore vendored
查看文件

@@ -21,3 +21,6 @@
.DS_Store
.idea/
.vscode/
# Local PDF corpus for RAG generation
data/local_pdfs/

查看文件

@@ -106,6 +106,12 @@ OI_IMPORT_SCRIPT_PATH=/app/scripts/import_luogu_csp.py
OI_IMPORT_CLEAR_ALL_PROBLEMS=true
```
## 6.1 本地 PDF + RAG + LLM 扩充题库CSP-J/S
`/imports` 页面支持切换到 `local_pdf_rag` 模式:从本地 PDF 抽取文本做 RAG,调用 LLM 生成 CSP-J/S 题目,并按现有题库相似度去重,跳过雷同题目,直到目标题量(如 5000
默认目录:`/data/local_pdfs`Compose 已挂载 `./data/local_pdfs:/data/local_pdfs`),建议先把 PDF 放到该目录。
如果你还要使用旧的 PDF + LLM 导入流程,可手动运行 `scripts/import_winterant_oi.py`
## 7. CSP-J 题目自动生成RAG + 去重)

查看文件

@@ -0,0 +1,54 @@
#pragma once
#include <drogon/HttpController.h>
#include <cstdint>
namespace csp::controllers {
class AdminController : public drogon::HttpController<AdminController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(AdminController::listUsers, "/api/v1/admin/users", drogon::Get);
ADD_METHOD_TO(AdminController::updateUserRating,
"/api/v1/admin/users/{1}/rating",
drogon::Patch);
ADD_METHOD_TO(AdminController::listRedeemItems, "/api/v1/admin/redeem-items", drogon::Get);
ADD_METHOD_TO(AdminController::createRedeemItem, "/api/v1/admin/redeem-items", drogon::Post);
ADD_METHOD_TO(AdminController::updateRedeemItem,
"/api/v1/admin/redeem-items/{1}",
drogon::Patch);
ADD_METHOD_TO(AdminController::deleteRedeemItem,
"/api/v1/admin/redeem-items/{1}",
drogon::Delete);
ADD_METHOD_TO(AdminController::listRedeemRecords,
"/api/v1/admin/redeem-records",
drogon::Get);
METHOD_LIST_END
void listUsers(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void updateUserRating(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t user_id);
void listRedeemItems(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void createRedeemItem(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void updateRedeemItem(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t item_id);
void deleteRedeemItem(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t item_id);
void listRedeemRecords(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};
} // namespace csp::controllers

查看文件

@@ -8,12 +8,32 @@ class MetaController : public drogon::HttpController<MetaController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(MetaController::openapi, "/api/openapi.json", drogon::Get);
ADD_METHOD_TO(MetaController::backendLogs, "/api/v1/backend/logs", drogon::Get);
ADD_METHOD_TO(MetaController::kbRefreshStatus, "/api/v1/backend/kb/refresh", drogon::Get);
ADD_METHOD_TO(MetaController::triggerKbRefresh, "/api/v1/backend/kb/refresh", drogon::Post);
ADD_METHOD_TO(MetaController::triggerMissingSolutions, "/api/v1/backend/solutions/generate-missing",
drogon::Post);
ADD_METHOD_TO(MetaController::mcp, "/api/v1/mcp", drogon::Post);
METHOD_LIST_END
void openapi(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void backendLogs(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void kbRefreshStatus(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void triggerKbRefresh(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void triggerMissingSolutions(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void mcp(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};

查看文件

@@ -12,6 +12,7 @@ class SubmissionController : public drogon::HttpController<SubmissionController>
ADD_METHOD_TO(SubmissionController::submitProblem, "/api/v1/problems/{1}/submit", drogon::Post);
ADD_METHOD_TO(SubmissionController::listSubmissions, "/api/v1/submissions", drogon::Get);
ADD_METHOD_TO(SubmissionController::getSubmission, "/api/v1/submissions/{1}", drogon::Get);
ADD_METHOD_TO(SubmissionController::analyzeSubmission, "/api/v1/submissions/{1}/analysis", drogon::Post);
ADD_METHOD_TO(SubmissionController::runCpp, "/api/v1/run/cpp", drogon::Post);
METHOD_LIST_END
@@ -26,6 +27,10 @@ class SubmissionController : public drogon::HttpController<SubmissionController>
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t submission_id);
void analyzeSubmission(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t submission_id);
void runCpp(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};

查看文件

@@ -73,6 +73,7 @@ struct Submission {
std::string code;
SubmissionStatus status = SubmissionStatus::Pending;
int32_t score = 0;
int32_t rating_delta = 0;
int32_t time_ms = 0;
int32_t memory_kb = 0;
std::string compile_log;

查看文件

@@ -20,6 +20,7 @@ class AuthService {
// Throws on error; for controller we will catch and convert to JSON.
AuthResult Register(const std::string& username, const std::string& password);
AuthResult Login(const std::string& username, const std::string& password);
void ResetPassword(const std::string& username, const std::string& new_password);
std::optional<int> VerifyToken(const std::string& token);

查看文件

@@ -8,7 +8,11 @@
namespace csp::services {
struct ImportRunOptions {
std::string mode = "luogu";
bool clear_all_problems = false;
std::string local_pdf_dir;
int target_total = 5000;
int workers = 3;
};
class ImportRunner {

查看文件

@@ -0,0 +1,37 @@
#pragma once
#include <cstdint>
#include <mutex>
#include <optional>
#include <string>
namespace csp::services {
class KbImportRunner {
public:
static KbImportRunner& Instance();
void Configure(std::string db_path);
bool TriggerAsync(const std::string& trigger);
bool IsRunning() const;
std::string LastCommand() const;
std::optional<int> LastExitCode() const;
int64_t LastStartedAt() const;
int64_t LastFinishedAt() const;
std::string LastTrigger() const;
private:
KbImportRunner() = default;
mutable std::mutex mu_;
std::string db_path_;
bool running_ = false;
std::string last_command_;
std::optional<int> last_exit_code_;
int64_t last_started_at_ = 0;
int64_t last_finished_at_ = 0;
std::string last_trigger_;
};
} // namespace csp::services

查看文件

@@ -1,28 +1,59 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <cstddef>
#include <deque>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <unordered_map>
namespace csp::services {
class ProblemSolutionRunner {
public:
struct TriggerMissingSummary {
int missing_total = 0;
int candidate_count = 0;
int queued_count = 0;
};
static ProblemSolutionRunner& Instance();
void Configure(std::string db_path);
bool TriggerAsync(int64_t problem_id, int64_t job_id, int max_solutions);
TriggerMissingSummary TriggerMissingAsync(db::SqliteDb& db,
int64_t created_by,
int max_solutions,
int limit);
void AutoStartMissingIfEnabled(db::SqliteDb& db);
bool IsRunning(int64_t problem_id) const;
size_t PendingCount() const;
private:
struct Task {
int64_t problem_id = 0;
int64_t job_id = 0;
int max_solutions = 3;
};
ProblemSolutionRunner() = default;
void StartWorkerIfNeededLocked();
void WorkerLoop();
void RecoverQueuedJobsLocked();
void StartAutoPumpIfNeeded(db::SqliteDb* db, int max_solutions, int limit, int interval_sec);
std::string db_path_;
mutable std::mutex mu_;
std::set<int64_t> running_problem_ids_;
std::deque<Task> queue_;
std::unordered_map<int64_t, size_t> pending_problem_counts_;
size_t pending_jobs_ = 0;
bool worker_running_ = false;
bool recovered_from_db_ = false;
bool auto_pump_started_ = false;
};
} // namespace csp::services

查看文件

@@ -34,6 +34,7 @@ struct ProblemSolution {
struct ProblemSolutionJob {
int64_t id = 0;
int64_t problem_id = 0;
std::string problem_title;
std::string status;
int progress = 0;
std::string message;
@@ -60,7 +61,13 @@ class ProblemWorkspaceService {
int64_t CreateSolutionJob(int64_t problem_id, int64_t created_by, int max_solutions);
std::optional<ProblemSolutionJob> GetLatestSolutionJob(int64_t problem_id);
std::vector<ProblemSolutionJob> ListRecentSolutionJobs(int limit);
std::vector<ProblemSolutionJob> ListSolutionJobsByStatus(const std::string& status,
int limit);
std::vector<ProblemSolution> ListSolutions(int64_t problem_id);
int CountProblemsWithoutSolutions();
std::vector<int64_t> ListProblemIdsWithoutSolutions(int limit,
bool exclude_queued_or_running_jobs);
private:
db::SqliteDb& db_;

查看文件

@@ -0,0 +1,77 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace csp::services {
struct RedeemItem {
int64_t id = 0;
std::string name;
std::string description;
std::string unit_label;
int holiday_cost = 0;
int studyday_cost = 0;
bool is_active = true;
bool is_global = true;
int64_t created_by = 0;
int64_t created_at = 0;
int64_t updated_at = 0;
};
struct RedeemItemWrite {
std::string name;
std::string description;
std::string unit_label = "小时";
int holiday_cost = 5;
int studyday_cost = 25;
bool is_active = true;
bool is_global = true;
};
struct RedeemRecord {
int64_t id = 0;
int64_t user_id = 0;
int64_t item_id = 0;
std::string item_name;
int quantity = 1;
std::string day_type;
int unit_cost = 0;
int total_cost = 0;
std::string note;
int64_t created_at = 0;
std::string username;
};
struct RedeemRequest {
int64_t user_id = 0;
int64_t item_id = 0;
int quantity = 1;
std::string day_type = "studyday";
std::string note;
};
class RedeemService {
public:
explicit RedeemService(db::SqliteDb& db) : db_(db) {}
std::vector<RedeemItem> ListItems(bool include_inactive);
std::optional<RedeemItem> GetItemById(int64_t item_id);
RedeemItem CreateItem(int64_t admin_user_id, const RedeemItemWrite& input);
RedeemItem UpdateItem(int64_t item_id, const RedeemItemWrite& input);
void DeactivateItem(int64_t item_id);
std::vector<RedeemRecord> ListRecordsByUser(int64_t user_id, int limit);
std::vector<RedeemRecord> ListRecordsAll(std::optional<int64_t> user_id, int limit);
RedeemRecord Redeem(const RedeemRequest& request);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,44 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <optional>
#include <string>
namespace csp::services {
struct SolutionViewChargeResult {
bool granted = true;
bool charged = false;
bool daily_free = false;
int cost = 0;
int rating_before = 0;
int rating_after = 0;
int daily_used_count = 0;
int64_t viewed_at = 0;
std::string day_key;
std::string deny_reason;
};
struct SolutionViewStats {
bool has_viewed = false;
int total_views = 0;
int total_cost = 0;
std::optional<int64_t> last_viewed_at;
};
class SolutionAccessService {
public:
explicit SolutionAccessService(db::SqliteDb& db) : db_(db) {}
// Daily policy: first answer view is free, then each full view costs 2 rating.
SolutionViewChargeResult ConsumeSolutionView(int64_t user_id, int64_t problem_id);
SolutionViewStats QueryUserProblemViewStats(int64_t user_id, int64_t problem_id);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,36 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include "csp/domain/entities.h"
#include <cstdint>
#include <optional>
#include <string>
namespace csp::services {
struct SubmissionFeedback {
int64_t submission_id = 0;
std::string feedback_md;
std::string links_json;
std::string model_name;
std::string status;
int64_t created_at = 0;
int64_t updated_at = 0;
};
class SubmissionFeedbackService {
public:
explicit SubmissionFeedbackService(db::SqliteDb& db) : db_(db) {}
std::optional<SubmissionFeedback> GetBySubmissionId(int64_t submission_id);
SubmissionFeedback GenerateAndSave(const domain::Submission& submission,
const domain::Problem& problem,
bool force_refresh);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -9,12 +9,19 @@
namespace csp::services {
struct UserListResult {
std::vector<domain::GlobalLeaderboardEntry> items;
int total_count = 0;
};
class UserService {
public:
explicit UserService(db::SqliteDb& db) : db_(db) {}
std::optional<domain::User> GetById(int64_t id);
std::vector<domain::GlobalLeaderboardEntry> GlobalLeaderboard(int limit = 100);
UserListResult ListUsers(int page, int page_size);
void SetRating(int64_t user_id, int rating);
private:
db::SqliteDb& db_;

查看文件

@@ -0,0 +1,320 @@
#include "csp/controllers/admin_controller.h"
#include "csp/app_state.h"
#include "csp/services/redeem_service.h"
#include "csp/services/user_service.h"
#include "http_auth.h"
#include <algorithm>
#include <cctype>
#include <exception>
#include <optional>
#include <stdexcept>
#include <string>
namespace csp::controllers {
namespace {
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
const std::string& msg) {
Json::Value j;
j["ok"] = false;
j["error"] = msg;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(code);
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
int ParseClampedInt(const std::string& s,
int default_value,
int min_value,
int max_value) {
if (s.empty()) return default_value;
const int v = std::stoi(s);
return std::max(min_value, std::min(max_value, v));
}
bool ParseBoolLike(const std::string& raw, bool default_value) {
if (raw.empty()) return default_value;
std::string v = raw;
std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
if (v == "1" || v == "true" || v == "yes" || v == "on") return true;
if (v == "0" || v == "false" || v == "no" || v == "off") return false;
return default_value;
}
std::optional<int64_t> ParseOptionalInt64(const std::string& raw) {
if (raw.empty()) return std::nullopt;
return std::stoll(raw);
}
services::RedeemItemWrite ParseRedeemItemWrite(const Json::Value& json) {
services::RedeemItemWrite write;
write.name = json.get("name", "").asString();
write.description = json.get("description", "").asString();
write.unit_label = json.get("unit_label", "小时").asString();
write.holiday_cost = json.get("holiday_cost", 5).asInt();
write.studyday_cost = json.get("studyday_cost", 25).asInt();
write.is_active = json.get("is_active", true).asBool();
write.is_global = json.get("is_global", true).asBool();
return write;
}
Json::Value ToJson(const services::RedeemItem& item) {
Json::Value j;
j["id"] = Json::Int64(item.id);
j["name"] = item.name;
j["description"] = item.description;
j["unit_label"] = item.unit_label;
j["holiday_cost"] = item.holiday_cost;
j["studyday_cost"] = item.studyday_cost;
j["is_active"] = item.is_active;
j["is_global"] = item.is_global;
j["created_by"] = Json::Int64(item.created_by);
j["created_at"] = Json::Int64(item.created_at);
j["updated_at"] = Json::Int64(item.updated_at);
return j;
}
std::optional<int64_t> RequireAdminUserId(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>& cb) {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return std::nullopt;
}
services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id);
if (!user.has_value() || user->username != "admin") {
cb(JsonError(drogon::k403Forbidden, "admin only"));
return std::nullopt;
}
return user_id;
}
} // namespace
void AdminController::listUsers(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const int page = ParseClampedInt(req->getParameter("page"), 1, 1, 100000);
const int page_size =
ParseClampedInt(req->getParameter("page_size"), 50, 1, 200);
services::UserService users(csp::AppState::Instance().db());
const auto result = users.ListUsers(page, page_size);
Json::Value arr(Json::arrayValue);
for (const auto& item : result.items) {
Json::Value one;
one["id"] = Json::Int64(item.user_id);
one["username"] = item.username;
one["rating"] = item.rating;
one["created_at"] = Json::Int64(item.created_at);
arr.append(one);
}
Json::Value payload;
payload["items"] = arr;
payload["total_count"] = result.total_count;
payload["page"] = page;
payload["page_size"] = page_size;
cb(JsonOk(payload));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
} catch (const std::out_of_range&) {
cb(JsonError(drogon::k400BadRequest, "query parameter out of range"));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::updateUserRating(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t user_id) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
if (!(*json).isMember("rating")) {
cb(JsonError(drogon::k400BadRequest, "rating is required"));
return;
}
const int rating = (*json)["rating"].asInt();
if (rating < 0) {
cb(JsonError(drogon::k400BadRequest, "rating must be >= 0"));
return;
}
services::UserService users(csp::AppState::Instance().db());
users.SetRating(user_id, rating);
const auto updated = users.GetById(user_id);
if (!updated.has_value()) {
cb(JsonError(drogon::k404NotFound, "user not found"));
return;
}
Json::Value payload;
payload["id"] = Json::Int64(updated->id);
payload["username"] = updated->username;
payload["rating"] = updated->rating;
payload["updated"] = true;
cb(JsonOk(payload));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid rating"));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::listRedeemItems(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const bool include_inactive =
ParseBoolLike(req->getParameter("include_inactive"), true);
services::RedeemService redeem(csp::AppState::Instance().db());
const auto items = redeem.ListItems(include_inactive);
Json::Value arr(Json::arrayValue);
for (const auto& item : items) arr.append(ToJson(item));
cb(JsonOk(arr));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::createRedeemItem(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto admin_user_id = RequireAdminUserId(req, cb);
if (!admin_user_id.has_value()) return;
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
const auto input = ParseRedeemItemWrite(*json);
services::RedeemService redeem(csp::AppState::Instance().db());
const auto item = redeem.CreateItem(*admin_user_id, input);
cb(JsonOk(ToJson(item)));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::updateRedeemItem(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t item_id) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
const auto input = ParseRedeemItemWrite(*json);
services::RedeemService redeem(csp::AppState::Instance().db());
const auto item = redeem.UpdateItem(item_id, input);
cb(JsonOk(ToJson(item)));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::deleteRedeemItem(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t item_id) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
services::RedeemService redeem(csp::AppState::Instance().db());
redeem.DeactivateItem(item_id);
Json::Value payload;
payload["id"] = Json::Int64(item_id);
payload["deleted"] = true;
cb(JsonOk(payload));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::listRedeemRecords(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const auto user_id = ParseOptionalInt64(req->getParameter("user_id"));
const int limit = ParseClampedInt(req->getParameter("limit"), 200, 1, 500);
services::RedeemService redeem(csp::AppState::Instance().db());
const auto rows = redeem.ListRecordsAll(user_id, limit);
Json::Value arr(Json::arrayValue);
for (const auto& row : rows) {
Json::Value j;
j["id"] = Json::Int64(row.id);
j["user_id"] = Json::Int64(row.user_id);
j["username"] = row.username;
j["item_id"] = Json::Int64(row.item_id);
j["item_name"] = row.item_name;
j["quantity"] = row.quantity;
j["day_type"] = row.day_type;
j["unit_cost"] = row.unit_cost;
j["total_cost"] = row.total_cost;
j["note"] = row.note;
j["created_at"] = Json::Int64(row.created_at);
arr.append(j);
}
cb(JsonOk(arr));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
} catch (const std::out_of_range&) {
cb(JsonError(drogon::k400BadRequest, "query parameter out of range"));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
} // namespace csp::controllers

查看文件

@@ -3,9 +3,12 @@
#include "csp/app_state.h"
#include "csp/services/import_runner.h"
#include "csp/services/import_service.h"
#include "csp/services/user_service.h"
#include "http_auth.h"
#include <algorithm>
#include <exception>
#include <optional>
#include <string>
namespace csp::controllers {
@@ -40,6 +43,25 @@ int ParsePositiveInt(const std::string& s,
return std::max(min_value, std::min(max_value, v));
}
std::optional<int64_t> RequireAdminUserId(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>& cb) {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return std::nullopt;
}
services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id);
if (!user.has_value() || user->username != "admin") {
cb(JsonError(drogon::k403Forbidden, "admin only"));
return std::nullopt;
}
return user_id;
}
Json::Value ToJson(const services::ImportJob& job) {
Json::Value j;
j["id"] = Json::Int64(job.id);
@@ -94,9 +116,11 @@ Json::Value ToJson(const services::ImportJobItem& item) {
} // namespace
void ImportController::latestJob(
const drogon::HttpRequestPtr& /*req*/,
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
services::ImportService svc(csp::AppState::Instance().db());
Json::Value payload;
const auto job = svc.GetLatestJob();
@@ -113,10 +137,12 @@ void ImportController::latestJob(
}
void ImportController::jobById(
const drogon::HttpRequestPtr& /*req*/,
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t job_id) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
services::ImportService svc(csp::AppState::Instance().db());
const auto job = svc.GetById(job_id);
if (!job.has_value()) {
@@ -136,6 +162,8 @@ void ImportController::jobItems(
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t job_id) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
services::ImportJobItemQuery query;
query.status = req->getParameter("status");
query.page = ParsePositiveInt(req->getParameter("page"), 1, 1, 100000);
@@ -164,12 +192,26 @@ void ImportController::runJob(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
services::ImportRunOptions opts;
const auto json = req->getJsonObject();
if (json) {
if ((*json).isMember("mode")) {
opts.mode = (*json)["mode"].asString();
}
opts.clear_all_problems =
(*json).isMember("clear_all_problems") &&
(*json)["clear_all_problems"].asBool();
if ((*json).isMember("local_pdf_dir")) {
opts.local_pdf_dir = (*json)["local_pdf_dir"].asString();
}
if ((*json).isMember("target_total")) {
opts.target_total = std::max(1, std::min(50000, (*json)["target_total"].asInt()));
}
if ((*json).isMember("workers")) {
opts.workers = std::max(1, std::min(16, (*json)["workers"].asInt()));
}
}
const bool started =
services::ImportRunner::Instance().TriggerAsync("manual", opts);
@@ -181,6 +223,7 @@ void ImportController::runJob(
Json::Value payload;
payload["started"] = true;
payload["running"] = true;
payload["mode"] = opts.mode;
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));

查看文件

@@ -9,10 +9,12 @@
#include "csp/services/problem_solution_runner.h"
#include "csp/services/problem_workspace_service.h"
#include "csp/services/submission_service.h"
#include "csp/services/user_service.h"
#include "http_auth.h"
#include <algorithm>
#include <exception>
#include <optional>
#include <string>
namespace csp::controllers {
@@ -47,6 +49,25 @@ int ParsePositiveInt(const std::string& s,
return std::max(min_value, std::min(max_value, v));
}
std::optional<int64_t> RequireAdminUserId(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>& cb) {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return std::nullopt;
}
services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id);
if (!user.has_value() || user->username != "admin") {
cb(JsonError(drogon::k403Forbidden, "admin only"));
return std::nullopt;
}
return user_id;
}
Json::Value BuildOpenApiSpec() {
Json::Value root;
root["openapi"] = "3.1.0";
@@ -173,6 +194,8 @@ void MetaController::backendLogs(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const int limit =
ParsePositiveInt(req->getParameter("limit"), 100, 1, 500);
const int running_limit =
@@ -274,9 +297,11 @@ void MetaController::backendLogs(
}
void MetaController::kbRefreshStatus(
const drogon::HttpRequestPtr& /*req*/,
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const auto& runner = services::KbImportRunner::Instance();
Json::Value payload;
payload["running"] = runner.IsRunning();
@@ -299,12 +324,7 @@ void MetaController::triggerKbRefresh(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
if (!RequireAdminUserId(req, cb).has_value()) return;
auto& runner = services::KbImportRunner::Instance();
const bool started = runner.TriggerAsync("manual");
@@ -332,12 +352,8 @@ void MetaController::triggerMissingSolutions(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
const auto user_id = RequireAdminUserId(req, cb);
if (!user_id.has_value()) return;
int limit = 50000;
int max_solutions = 3;

查看文件

@@ -5,6 +5,7 @@
#include "csp/services/problem_service.h"
#include "csp/services/problem_solution_runner.h"
#include "csp/services/problem_workspace_service.h"
#include "csp/services/solution_access_service.h"
#include "http_auth.h"
#include <algorithm>
@@ -192,7 +193,7 @@ void ProblemController::saveDraft(
}
void ProblemController::listSolutions(
const drogon::HttpRequestPtr& /*req*/,
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id) {
try {
@@ -203,28 +204,72 @@ void ProblemController::listSolutions(
}
const auto rows = svc.ListSolutions(problem_id);
const bool has_solutions = !rows.empty();
const auto latest_job = svc.GetLatestSolutionJob(problem_id);
const std::string mode = req->getParameter("mode");
const bool need_full = mode == "full";
Json::Value arr(Json::arrayValue);
for (const auto& item : rows) {
Json::Value j;
j["id"] = Json::Int64(item.id);
j["problem_id"] = Json::Int64(item.problem_id);
j["variant"] = item.variant;
j["title"] = item.title;
j["idea_md"] = item.idea_md;
j["explanation_md"] = item.explanation_md;
j["code_cpp"] = item.code_cpp;
j["complexity"] = item.complexity;
j["tags_json"] = item.tags_json;
j["source"] = item.source;
j["created_at"] = Json::Int64(item.created_at);
j["updated_at"] = Json::Int64(item.updated_at);
arr.append(j);
Json::Value access(Json::objectValue);
access["required"] = true;
access["daily_free_quota"] = 1;
access["cost_after_free"] = 2;
if (need_full && has_solutions) {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
services::SolutionAccessService access_svc(csp::AppState::Instance().db());
const auto charge = access_svc.ConsumeSolutionView(*user_id, problem_id);
if (!charge.granted) {
cb(JsonError(drogon::k402PaymentRequired,
"rating 不足:首次免费后每次查看答案需 2 分"));
return;
}
access["mode"] = "full";
access["charged"] = charge.charged;
access["daily_free"] = charge.daily_free;
access["cost"] = charge.cost;
access["day_key"] = charge.day_key;
access["daily_used_count"] = charge.daily_used_count;
access["rating_before"] = charge.rating_before;
access["rating_after"] = charge.rating_after;
access["viewed_at"] = Json::Int64(charge.viewed_at);
for (const auto& item : rows) {
Json::Value j;
j["id"] = Json::Int64(item.id);
j["problem_id"] = Json::Int64(item.problem_id);
j["variant"] = item.variant;
j["title"] = item.title;
j["idea_md"] = item.idea_md;
j["explanation_md"] = item.explanation_md;
j["code_cpp"] = item.code_cpp;
j["complexity"] = item.complexity;
j["tags_json"] = item.tags_json;
j["source"] = item.source;
j["created_at"] = Json::Int64(item.created_at);
j["updated_at"] = Json::Int64(item.updated_at);
arr.append(j);
}
} else {
access["mode"] = "preview";
access["charged"] = false;
access["daily_free"] = false;
access["cost"] = 0;
access["daily_used_count"] = 0;
}
Json::Value payload;
payload["items"] = arr;
payload["has_solutions"] = has_solutions;
payload["answer_status"] = has_solutions ? "已有" : "待生成";
payload["access"] = access;
payload["runner_running"] =
services::ProblemSolutionRunner::Instance().IsRunning(problem_id);
if (latest_job.has_value()) {
@@ -280,16 +325,20 @@ void ProblemController::generateSolutions(
}
const int64_t job_id = svc.CreateSolutionJob(problem_id, *user_id, max_solutions);
const bool started = services::ProblemSolutionRunner::Instance().TriggerAsync(
auto& runner = services::ProblemSolutionRunner::Instance();
const bool queued = runner.TriggerAsync(
problem_id, job_id, max_solutions);
if (!started) {
cb(JsonError(drogon::k409Conflict, "solution generation is already running"));
if (!queued) {
cb(JsonError(drogon::k500InternalServerError,
"solution generation queue is unavailable"));
return;
}
Json::Value payload;
payload["queued"] = true;
payload["started"] = true;
payload["job_id"] = Json::Int64(job_id);
payload["pending_jobs"] = Json::UInt64(runner.PendingCount());
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));

查看文件

@@ -4,11 +4,16 @@
#include "csp/domain/enum_strings.h"
#include "csp/domain/json.h"
#include "csp/services/contest_service.h"
#include "csp/services/problem_service.h"
#include "csp/services/solution_access_service.h"
#include "csp/services/submission_feedback_service.h"
#include "csp/services/submission_service.h"
#include "http_auth.h"
#include <algorithm>
#include <cctype>
#include <exception>
#include <memory>
#include <optional>
#include <string>
@@ -49,6 +54,32 @@ std::optional<int64_t> ParseOptionalInt64(const std::string& s) {
return std::stoll(s);
}
bool ParseBoolLike(const std::string& s, bool default_value) {
if (s.empty()) return default_value;
std::string v = s;
std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
if (v == "1" || v == "true" || v == "yes" || v == "on") return true;
if (v == "0" || v == "false" || v == "no" || v == "off") return false;
return default_value;
}
Json::Value ParseLinksArray(const std::string& links_json) {
Json::CharReaderBuilder builder;
Json::Value parsed;
std::string errs;
std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
if (!reader->parse(links_json.data(),
links_json.data() + links_json.size(),
&parsed,
&errs) ||
!parsed.isArray()) {
return Json::Value(Json::arrayValue);
}
return parsed;
}
} // namespace
void SubmissionController::submitProblem(
@@ -153,7 +184,91 @@ void SubmissionController::getSubmission(
cb(JsonError(drogon::k404NotFound, "submission not found"));
return;
}
cb(JsonOk(domain::ToJson(*s)));
Json::Value payload = domain::ToJson(*s);
payload["code"] = s->code;
services::SolutionAccessService access_svc(csp::AppState::Instance().db());
const auto stats =
access_svc.QueryUserProblemViewStats(s->user_id, s->problem_id);
payload["has_viewed_answer"] = stats.has_viewed;
payload["answer_view_count"] = stats.total_views;
payload["answer_view_total_cost"] = stats.total_cost;
if (stats.last_viewed_at.has_value()) {
payload["last_answer_view_at"] = Json::Int64(*stats.last_viewed_at);
} else {
payload["last_answer_view_at"] = Json::nullValue;
}
services::SubmissionFeedbackService feedback_svc(csp::AppState::Instance().db());
if (const auto feedback = feedback_svc.GetBySubmissionId(s->id);
feedback.has_value()) {
Json::Value analysis;
analysis["feedback_md"] = feedback->feedback_md;
analysis["links"] = ParseLinksArray(feedback->links_json);
analysis["model_name"] = feedback->model_name;
analysis["status"] = feedback->status;
analysis["created_at"] = Json::Int64(feedback->created_at);
analysis["updated_at"] = Json::Int64(feedback->updated_at);
payload["analysis"] = analysis;
} else {
payload["analysis"] = Json::nullValue;
}
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void SubmissionController::analyzeSubmission(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t submission_id) {
try {
std::string auth_error;
if (!GetAuthedUserId(req, auth_error).has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
bool force_refresh = ParseBoolLike(req->getParameter("refresh"), false);
if (const auto json = req->getJsonObject();
json && (*json).isMember("refresh")) {
force_refresh = (*json)["refresh"].asBool();
}
services::SubmissionService submission_svc(csp::AppState::Instance().db());
const auto submission = submission_svc.GetById(submission_id);
if (!submission.has_value()) {
cb(JsonError(drogon::k404NotFound, "submission not found"));
return;
}
services::ProblemService problem_svc(csp::AppState::Instance().db());
const auto problem = problem_svc.GetById(submission->problem_id);
if (!problem.has_value()) {
cb(JsonError(drogon::k404NotFound, "problem not found"));
return;
}
services::SubmissionFeedbackService feedback_svc(csp::AppState::Instance().db());
const auto feedback =
feedback_svc.GenerateAndSave(*submission, *problem, force_refresh);
Json::Value payload;
payload["submission_id"] = Json::Int64(feedback.submission_id);
payload["feedback_md"] = feedback.feedback_md;
payload["links"] = ParseLinksArray(feedback.links_json);
payload["model_name"] = feedback.model_name;
payload["status"] = feedback.status;
payload["created_at"] = Json::Int64(feedback.created_at);
payload["updated_at"] = Json::Int64(feedback.updated_at);
payload["refresh"] = force_refresh;
cb(JsonOk(payload));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid request field"));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}

查看文件

@@ -40,6 +40,7 @@ Json::Value ToJson(const Submission& s) {
j["language"] = ToString(s.language);
j["status"] = ToString(s.status);
j["score"] = s.score;
j["rating_delta"] = s.rating_delta;
j["time_ms"] = s.time_ms;
j["memory_kb"] = s.memory_kb;
j["compile_log"] = s.compile_log;

查看文件

@@ -3,6 +3,7 @@
#include "csp/app_state.h"
#include "csp/services/auth_service.h"
#include "csp/services/import_runner.h"
#include "csp/services/kb_import_runner.h"
#include "csp/services/problem_gen_runner.h"
#include "csp/services/problem_solution_runner.h"
@@ -16,6 +17,7 @@ int main(int argc, char** argv) {
csp::AppState::Instance().Init(db_path);
csp::services::ImportRunner::Instance().Configure(db_path);
csp::services::KbImportRunner::Instance().Configure(db_path);
csp::services::ProblemSolutionRunner::Instance().Configure(db_path);
csp::services::ProblemGenRunner::Instance().Configure(db_path);
@@ -26,10 +28,14 @@ int main(int argc, char** argv) {
if (u && p && std::string(u).size() > 0 && std::string(p).size() > 0) {
try {
csp::services::AuthService auth(csp::AppState::Instance().db());
auth.Register(u, p);
LOG_INFO << "seed admin user created: " << u;
try {
auth.Register(u, p);
LOG_INFO << "seed admin user created: " << u;
} catch (const std::exception&) {
auth.ResetPassword(u, p);
LOG_INFO << "seed admin password reset: " << u;
}
} catch (const std::exception& e) {
// Most likely UNIQUE constraint (already exists)
LOG_INFO << "seed admin user skipped: " << e.what();
}
}
@@ -37,6 +43,9 @@ int main(int argc, char** argv) {
// Auto-run PDF -> LLM import workflow on startup unless explicitly disabled.
csp::services::ImportRunner::Instance().AutoStartIfEnabled();
// Auto-queue missing problem solutions on startup (C++14 async generation).
csp::services::ProblemSolutionRunner::Instance().AutoStartMissingIfEnabled(
csp::AppState::Instance().db());
// Auto-generate one CSP-J new problem (RAG + dedupe) on startup by default.
csp::services::ProblemGenRunner::Instance().AutoStartIfEnabled();

查看文件

@@ -1,11 +1,16 @@
#include "csp/services/import_runner.h"
#include <drogon/drogon.h>
#include <json/json.h>
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <memory>
#include <string>
#include <thread>
#include <utility>
#include <vector>
@@ -39,6 +44,13 @@ int EnvInt(const char* key, int default_value) {
}
}
std::string EnvString(const char* key, const std::string& default_value) {
const char* raw = std::getenv(key);
if (!raw) return default_value;
const std::string value(raw);
return value.empty() ? default_value : value;
}
std::string ShellQuote(const std::string& text) {
std::string out = "'";
for (char c : text) {
@@ -52,7 +64,16 @@ std::string ShellQuote(const std::string& text) {
return out;
}
std::string ResolveScriptPath() {
std::string NormalizeMode(std::string mode) {
for (auto& c : mode) c = static_cast<char>(::tolower(static_cast<unsigned char>(c)));
if (mode == "local_pdf_rag" || mode == "local-pdf-rag" || mode == "local_rag" ||
mode == "rag") {
return "local_pdf_rag";
}
return "luogu";
}
std::string ResolveLuoguScriptPath() {
const char* env_path = std::getenv("OI_IMPORT_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
@@ -72,11 +93,28 @@ std::string ResolveScriptPath() {
return "/app/scripts/import_luogu_csp.py";
}
std::string BuildCommand(const std::string& db_path,
const std::string& trigger,
const ImportRunOptions& options) {
const std::string script_path = ResolveScriptPath();
const int workers = std::max(1, EnvInt("OI_IMPORT_WORKERS", 3));
std::string ResolveLocalRagScriptPath() {
const char* env_path = std::getenv("OI_IMPORT_LOCAL_RAG_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
const std::vector<std::string> candidates = {
"/app/scripts/import_local_pdf_rag.py",
"scripts/import_local_pdf_rag.py",
"../scripts/import_local_pdf_rag.py",
"../../scripts/import_local_pdf_rag.py",
};
for (const auto& p : candidates) {
if (std::filesystem::exists(p)) return p;
}
return "/app/scripts/import_local_pdf_rag.py";
}
std::string BuildLuoguCommand(const std::string& db_path,
const std::string& trigger,
const ImportRunOptions& options) {
const std::string script_path = ResolveLuoguScriptPath();
const int workers =
std::max(1, options.workers > 0 ? options.workers : EnvInt("OI_IMPORT_WORKERS", 3));
const int llm_limit = EnvInt("OI_IMPORT_LLM_LIMIT", 0);
const int max_problems = EnvInt("OI_IMPORT_MAX_PROBLEMS", 0);
const bool skip_llm = EnvBool("OI_IMPORT_SKIP_LLM", false);
@@ -88,9 +126,8 @@ std::string BuildCommand(const std::string& db_path,
: std::string("winterant/oi");
std::string cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path) + " --workers " +
std::to_string(workers) + " --job-trigger " +
ShellQuote(trigger);
ShellQuote(db_path) + " --workers " + std::to_string(workers) +
" --job-trigger " + ShellQuote(trigger);
if (max_problems > 0) cmd += " --max-problems " + std::to_string(max_problems);
if (skip_llm) cmd += " --skip-llm";
@@ -107,6 +144,125 @@ std::string BuildCommand(const std::string& db_path,
return cmd;
}
std::string BuildLocalRagCommand(const std::string& db_path,
const std::string& trigger,
const ImportRunOptions& options) {
const std::string script_path = ResolveLocalRagScriptPath();
const int workers =
std::max(1, options.workers > 0 ? options.workers : EnvInt("OI_IMPORT_WORKERS", 3));
const int target_total = std::max(
1,
options.target_total > 0 ? options.target_total : EnvInt("OI_IMPORT_TARGET_TOTAL", 5000));
const int attempt_multiplier =
std::max(2, EnvInt("OI_IMPORT_RAG_ATTEMPT_MULTIPLIER", 8));
const std::string pdf_dir = options.local_pdf_dir.empty()
? EnvString("OI_LOCAL_PDF_DIR", "/data/local_pdfs")
: options.local_pdf_dir;
std::string cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path) + " --workers " + std::to_string(workers) +
" --target-total " + std::to_string(target_total) +
" --job-trigger " + ShellQuote(trigger) +
" --max-attempt-multiplier " +
std::to_string(attempt_multiplier);
if (!pdf_dir.empty()) {
cmd += " --pdf-dir " + ShellQuote(pdf_dir);
}
const char* dedupe = std::getenv("OI_IMPORT_RAG_DEDUPE_THRESHOLD");
if (dedupe && std::string(dedupe).size() > 0) {
cmd += " --dedupe-threshold " + ShellQuote(dedupe);
}
return cmd;
}
std::string BuildCommand(const std::string& db_path,
const std::string& trigger,
const ImportRunOptions& options) {
const std::string mode = NormalizeMode(options.mode);
if (mode == "local_pdf_rag") {
return BuildLocalRagCommand(db_path, trigger, options);
}
return BuildLuoguCommand(db_path, trigger, options);
}
std::optional<ImportRunOptions> ParseRunOptionsFromJson(const std::string& raw_json) {
if (raw_json.empty()) return std::nullopt;
Json::CharReaderBuilder builder;
Json::Value root;
std::string errs;
std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
if (!reader->parse(raw_json.data(), raw_json.data() + raw_json.size(), &root,
&errs) ||
!root.isObject()) {
return std::nullopt;
}
ImportRunOptions opts;
opts.mode = NormalizeMode(root.get("mode", root.get("source", "luogu")).asString());
opts.clear_all_problems = root.get("clear_all_problems", false).asBool();
opts.workers = std::max(1, root.get("workers", 3).asInt());
opts.target_total = std::max(1, root.get("target_total", 5000).asInt());
if (root.isMember("local_pdf_dir")) {
opts.local_pdf_dir = root["local_pdf_dir"].asString();
} else if (root.isMember("pdf_dir")) {
opts.local_pdf_dir = root["pdf_dir"].asString();
}
return opts;
}
std::optional<ImportRunOptions> RecoverInterruptedRunOptions(const std::string& db_path) {
if (!EnvBool("OI_IMPORT_RESUME_ON_RESTART", true)) return std::nullopt;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path.c_str(), &db, SQLITE_OPEN_READWRITE, nullptr) !=
SQLITE_OK) {
if (db) sqlite3_close(db);
return std::nullopt;
}
sqlite3_busy_timeout(db, 5000);
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT options_json FROM import_jobs WHERE status='running' ORDER BY id DESC LIMIT 1";
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return std::nullopt;
}
std::optional<ImportRunOptions> options;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const unsigned char* txt = sqlite3_column_text(stmt, 0);
const std::string raw = txt ? reinterpret_cast<const char*>(txt) : std::string();
options = ParseRunOptionsFromJson(raw);
if (!options.has_value()) {
ImportRunOptions fallback;
fallback.mode = "luogu";
options = fallback;
}
}
sqlite3_finalize(stmt);
if (options.has_value()) {
sqlite3_exec(
db,
"UPDATE import_jobs "
"SET status='interrupted',"
"last_error='backend restarted, auto-resume scheduled',"
"finished_at=strftime('%s','now'),updated_at=strftime('%s','now') "
"WHERE status='running'",
nullptr,
nullptr,
nullptr);
}
sqlite3_close(db);
return options;
}
} // namespace
ImportRunner& ImportRunner::Instance() {
@@ -115,8 +271,21 @@ ImportRunner& ImportRunner::Instance() {
}
void ImportRunner::Configure(std::string db_path) {
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
std::optional<ImportRunOptions> resume_opts;
{
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
resume_opts = RecoverInterruptedRunOptions(db_path_);
}
if (resume_opts.has_value()) {
const bool started = TriggerAsync("resume", *resume_opts);
if (started) {
LOG_INFO << "import runner resumed interrupted job";
} else {
LOG_INFO << "import runner resume skipped";
}
}
}
bool ImportRunner::TriggerAsync(const std::string& trigger,
@@ -147,7 +316,14 @@ bool ImportRunner::TriggerAsync(const std::string& trigger,
void ImportRunner::AutoStartIfEnabled() {
if (!EnvBool("OI_IMPORT_AUTO_RUN", true)) return;
const bool started = TriggerAsync("auto", ImportRunOptions{});
ImportRunOptions opts;
opts.mode = NormalizeMode(EnvString("OI_IMPORT_AUTO_MODE", "luogu"));
opts.workers = std::max(1, EnvInt("OI_IMPORT_WORKERS", 3));
opts.target_total = std::max(1, EnvInt("OI_IMPORT_TARGET_TOTAL", 5000));
opts.local_pdf_dir = EnvString("OI_LOCAL_PDF_DIR", "/data/local_pdfs");
const bool started = TriggerAsync("auto", opts);
if (started) {
LOG_INFO << "import runner auto-started";
} else {

查看文件

@@ -0,0 +1,130 @@
#include "csp/services/kb_import_runner.h"
#include <drogon/drogon.h>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <mutex>
#include <thread>
#include <vector>
namespace csp::services {
namespace {
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
std::string ShellQuote(const std::string& text) {
std::string out = "'";
for (char c : text) {
if (c == '\'') {
out += "'\"'\"'";
} else {
out.push_back(c);
}
}
out.push_back('\'');
return out;
}
std::string ResolveScriptPath() {
const char* env_path = std::getenv("CSP_KB_IMPORT_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
const std::vector<std::string> candidates = {
"/app/scripts/import_kb_learning_resources.py",
"scripts/import_kb_learning_resources.py",
"../scripts/import_kb_learning_resources.py",
"../../scripts/import_kb_learning_resources.py",
};
for (const auto& path : candidates) {
if (std::filesystem::exists(path)) return path;
}
return "/app/scripts/import_kb_learning_resources.py";
}
std::string BuildCommand(const std::string& db_path) {
const std::string script_path = ResolveScriptPath();
return "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path) +
" --timeout 20 --retries 5 --max-sources-per-track 3 --max-chars-per-source 900";
}
} // namespace
KbImportRunner& KbImportRunner::Instance() {
static KbImportRunner inst;
return inst;
}
void KbImportRunner::Configure(std::string db_path) {
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
}
bool KbImportRunner::TriggerAsync(const std::string& trigger) {
std::string command;
{
std::lock_guard<std::mutex> lock(mu_);
if (running_) return false;
if (db_path_.empty()) return false;
command = BuildCommand(db_path_);
running_ = true;
last_command_ = command;
last_trigger_ = trigger;
last_started_at_ = NowSec();
last_exit_code_.reset();
}
std::thread([this, command = std::move(command)]() {
LOG_INFO << "kb import started: " << command;
const int rc = std::system(command.c_str());
{
std::lock_guard<std::mutex> lock(mu_);
running_ = false;
last_exit_code_ = rc;
last_finished_at_ = NowSec();
}
LOG_INFO << "kb import finished, code=" << rc;
}).detach();
return true;
}
bool KbImportRunner::IsRunning() const {
std::lock_guard<std::mutex> lock(mu_);
return running_;
}
std::string KbImportRunner::LastCommand() const {
std::lock_guard<std::mutex> lock(mu_);
return last_command_;
}
std::optional<int> KbImportRunner::LastExitCode() const {
std::lock_guard<std::mutex> lock(mu_);
return last_exit_code_;
}
int64_t KbImportRunner::LastStartedAt() const {
std::lock_guard<std::mutex> lock(mu_);
return last_started_at_;
}
int64_t KbImportRunner::LastFinishedAt() const {
std::lock_guard<std::mutex> lock(mu_);
return last_finished_at_;
}
std::string KbImportRunner::LastTrigger() const {
std::lock_guard<std::mutex> lock(mu_);
return last_trigger_;
}
} // namespace csp::services

查看文件

@@ -1,6 +1,13 @@
#include "csp/services/problem_solution_runner.h"
#include "csp/services/problem_workspace_service.h"
#include <drogon/drogon.h>
#include <sqlite3.h>
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <thread>
@@ -39,6 +46,43 @@ std::string ResolveScriptPath() {
return "/app/scripts/generate_problem_solutions.py";
}
bool EnvBool(const char* key, bool default_value) {
const char* raw = std::getenv(key);
if (!raw) return default_value;
std::string val(raw);
for (auto& c : val) c = static_cast<char>(::tolower(static_cast<unsigned char>(c)));
if (val == "1" || val == "true" || val == "yes" || val == "on") return true;
if (val == "0" || val == "false" || val == "no" || val == "off") return false;
return default_value;
}
int EnvInt(const char* key, int default_value) {
const char* raw = std::getenv(key);
if (!raw) return default_value;
try {
return std::stoi(raw);
} catch (...) {
return default_value;
}
}
bool IsDatabaseLockedMessage(const std::string& msg) {
return msg.find("database is locked") != std::string::npos ||
msg.find("database is busy") != std::string::npos;
}
std::string BuildCommand(const std::string& db_path,
int64_t problem_id,
int64_t job_id,
int max_solutions) {
const std::string script_path = ResolveScriptPath();
const int clamped = std::max(1, std::min(5, max_solutions));
return "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path) + " --problem-id " + std::to_string(problem_id) +
" --job-id " + std::to_string(job_id) + " --max-solutions " +
std::to_string(clamped);
}
} // namespace
ProblemSolutionRunner& ProblemSolutionRunner::Instance() {
@@ -49,38 +93,225 @@ ProblemSolutionRunner& ProblemSolutionRunner::Instance() {
void ProblemSolutionRunner::Configure(std::string db_path) {
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
RecoverQueuedJobsLocked();
StartWorkerIfNeededLocked();
}
bool ProblemSolutionRunner::TriggerAsync(int64_t problem_id,
int64_t job_id,
int max_solutions) {
std::string cmd;
{
std::lock_guard<std::mutex> lock(mu_);
if (db_path_.empty()) return false;
if (running_problem_ids_.count(problem_id) > 0) return false;
running_problem_ids_.insert(problem_id);
const std::string script_path = ResolveScriptPath();
const int clamped = std::max(1, std::min(5, max_solutions));
cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path_) + " --problem-id " + std::to_string(problem_id) +
" --job-id " + std::to_string(job_id) + " --max-solutions " +
std::to_string(clamped);
}
std::thread([this, problem_id, command = std::move(cmd)]() {
std::system(command.c_str());
std::lock_guard<std::mutex> lock(mu_);
running_problem_ids_.erase(problem_id);
}).detach();
std::lock_guard<std::mutex> lock(mu_);
if (db_path_.empty()) return false;
Task task;
task.problem_id = problem_id;
task.job_id = job_id;
task.max_solutions = std::max(1, std::min(5, max_solutions));
queue_.push_back(task);
++pending_problem_counts_[problem_id];
++pending_jobs_;
StartWorkerIfNeededLocked();
return true;
}
ProblemSolutionRunner::TriggerMissingSummary
ProblemSolutionRunner::TriggerMissingAsync(db::SqliteDb& db,
int64_t created_by,
int max_solutions,
int limit) {
services::ProblemWorkspaceService workspace(db);
TriggerMissingSummary summary;
summary.missing_total = workspace.CountProblemsWithoutSolutions();
const int clamped_limit = std::max(1, std::min(200000, limit));
const int clamped_max_solutions = std::max(1, std::min(5, max_solutions));
const auto problem_ids = workspace.ListProblemIdsWithoutSolutions(
clamped_limit, true);
summary.candidate_count = static_cast<int>(problem_ids.size());
for (int64_t problem_id : problem_ids) {
int64_t job_id = 0;
bool created = false;
for (int attempt = 1; attempt <= 4; ++attempt) {
try {
job_id = workspace.CreateSolutionJob(
problem_id, created_by, clamped_max_solutions);
created = true;
break;
} catch (const std::exception& e) {
const std::string msg = e.what();
if (!IsDatabaseLockedMessage(msg) || attempt >= 4) {
LOG_ERROR << "create solution job failed for problem_id=" << problem_id
<< ", err=" << msg;
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(120 * attempt));
}
}
if (!created) continue;
if (TriggerAsync(problem_id, job_id, clamped_max_solutions)) {
++summary.queued_count;
}
}
return summary;
}
void ProblemSolutionRunner::AutoStartMissingIfEnabled(db::SqliteDb& db) {
try {
if (!EnvBool("CSP_SOLUTION_AUTO_RUN_MISSING", true)) {
LOG_INFO << "solution auto-run missing skipped by env";
return;
}
const int limit = std::max(1, std::min(200000, EnvInt("CSP_SOLUTION_AUTO_LIMIT", 50000)));
const int max_solutions =
std::max(1, std::min(5, EnvInt("CSP_SOLUTION_AUTO_MAX_SOLUTIONS", 3)));
const int interval_sec =
std::max(3, std::min(300, EnvInt("CSP_SOLUTION_AUTO_INTERVAL_SEC", 12)));
const auto summary = TriggerMissingAsync(db, /*created_by=*/0, max_solutions, limit);
LOG_INFO << "solution auto-run missing queued=" << summary.queued_count
<< ", candidates=" << summary.candidate_count
<< ", missing_total=" << summary.missing_total
<< ", pending_jobs=" << PendingCount();
StartAutoPumpIfNeeded(&db, max_solutions, limit, interval_sec);
} catch (const std::exception& e) {
LOG_ERROR << "solution auto-run missing failed: " << e.what();
}
}
bool ProblemSolutionRunner::IsRunning(int64_t problem_id) const {
std::lock_guard<std::mutex> lock(mu_);
return running_problem_ids_.count(problem_id) > 0;
return pending_problem_counts_.count(problem_id) > 0;
}
size_t ProblemSolutionRunner::PendingCount() const {
std::lock_guard<std::mutex> lock(mu_);
return pending_jobs_;
}
void ProblemSolutionRunner::StartWorkerIfNeededLocked() {
if (worker_running_ || queue_.empty()) return;
worker_running_ = true;
std::thread([this]() { WorkerLoop(); }).detach();
}
void ProblemSolutionRunner::WorkerLoop() {
while (true) {
Task task;
std::string db_path;
{
std::lock_guard<std::mutex> lock(mu_);
if (queue_.empty()) {
worker_running_ = false;
return;
}
task = queue_.front();
queue_.pop_front();
db_path = db_path_;
}
const std::string cmd =
BuildCommand(db_path, task.problem_id, task.job_id, task.max_solutions);
const int rc = std::system(cmd.c_str());
if (rc != 0) {
LOG_INFO << "solution job " << task.job_id
<< " finished with non-zero rc: " << rc;
}
std::lock_guard<std::mutex> lock(mu_);
auto it = pending_problem_counts_.find(task.problem_id);
if (it != pending_problem_counts_.end()) {
if (it->second <= 1) {
pending_problem_counts_.erase(it);
} else {
--it->second;
}
}
if (pending_jobs_ > 0) --pending_jobs_;
}
}
void ProblemSolutionRunner::RecoverQueuedJobsLocked() {
if (recovered_from_db_ || db_path_.empty()) return;
recovered_from_db_ = true;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path_.c_str(), &db, SQLITE_OPEN_READWRITE, nullptr) !=
SQLITE_OK) {
if (db) sqlite3_close(db);
return;
}
sqlite3_busy_timeout(db, 5000);
sqlite3_exec(
db,
"UPDATE problem_solution_jobs "
"SET status='queued',progress=0,message='requeued after backend restart',"
"started_at=NULL,finished_at=NULL,updated_at=strftime('%s','now') "
"WHERE status='running'",
nullptr,
nullptr,
nullptr);
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,problem_id,max_solutions FROM problem_solution_jobs "
"WHERE status='queued' ORDER BY id ASC";
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return;
}
int recovered = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
Task task;
task.job_id = sqlite3_column_int64(stmt, 0);
task.problem_id = sqlite3_column_int64(stmt, 1);
task.max_solutions = sqlite3_column_int(stmt, 2);
queue_.push_back(task);
++pending_problem_counts_[task.problem_id];
++pending_jobs_;
++recovered;
}
sqlite3_finalize(stmt);
sqlite3_close(db);
if (recovered > 0) {
LOG_INFO << "recovered queued solution jobs: " << recovered;
}
}
void ProblemSolutionRunner::StartAutoPumpIfNeeded(db::SqliteDb* db,
int max_solutions,
int limit,
int interval_sec) {
std::lock_guard<std::mutex> lock(mu_);
if (auto_pump_started_ || !db) return;
auto_pump_started_ = true;
std::thread([this, db, max_solutions, limit, interval_sec]() {
while (true) {
std::this_thread::sleep_for(std::chrono::seconds(interval_sec));
if (!EnvBool("CSP_SOLUTION_AUTO_RUN_MISSING", true)) continue;
bool idle = false;
{
std::lock_guard<std::mutex> inner(mu_);
idle = pending_jobs_ == 0;
}
if (!idle) continue;
try {
const auto summary = TriggerMissingAsync(
*db, /*created_by=*/0, max_solutions, limit);
if (summary.queued_count > 0) {
LOG_INFO << "solution auto-pump queued=" << summary.queued_count
<< ", candidates=" << summary.candidate_count
<< ", missing_total=" << summary.missing_total
<< ", pending_jobs=" << PendingCount();
}
} catch (const std::exception& e) {
LOG_ERROR << "solution auto-pump failed: " << e.what();
}
}
}).detach();
}
} // namespace csp::services

查看文件

@@ -6,6 +6,7 @@
#include <chrono>
#include <stdexcept>
#include <string>
#include <utility>
namespace csp::services {
@@ -191,6 +192,116 @@ std::optional<ProblemSolutionJob> ProblemWorkspaceService::GetLatestSolutionJob(
return row;
}
std::vector<ProblemSolutionJob> ProblemWorkspaceService::ListRecentSolutionJobs(
int limit) {
const int capped = std::max(1, std::min(500, limit));
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT j.id,j.problem_id,j.status,j.progress,j.message,j.created_by,j.max_solutions,"
"j.created_at,j.started_at,j.finished_at,j.updated_at,p.title "
"FROM problem_solution_jobs j "
"LEFT JOIN problems p ON p.id=j.problem_id "
"ORDER BY j.id DESC LIMIT ?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare recent solution jobs");
CheckSqlite(sqlite3_bind_int(stmt, 1, capped), db, "bind recent jobs limit");
std::vector<ProblemSolutionJob> rows;
while (sqlite3_step(stmt) == SQLITE_ROW) {
auto row = ReadSolutionJob(stmt);
row.problem_title = ColText(stmt, 11);
rows.push_back(std::move(row));
}
sqlite3_finalize(stmt);
return rows;
}
std::vector<ProblemSolutionJob> ProblemWorkspaceService::ListSolutionJobsByStatus(
const std::string& status,
int limit) {
const int capped = std::max(1, std::min(1000, limit));
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const bool queued = status == "queued";
const char* sql = queued
? "SELECT j.id,j.problem_id,j.status,j.progress,j.message,j.created_by,j.max_solutions,"
"j.created_at,j.started_at,j.finished_at,j.updated_at,p.title "
"FROM problem_solution_jobs j "
"LEFT JOIN problems p ON p.id=j.problem_id "
"WHERE j.status=? "
"ORDER BY j.id ASC LIMIT ?"
: "SELECT j.id,j.problem_id,j.status,j.progress,j.message,j.created_by,j.max_solutions,"
"j.created_at,j.started_at,j.finished_at,j.updated_at,p.title "
"FROM problem_solution_jobs j "
"LEFT JOIN problems p ON p.id=j.problem_id "
"WHERE j.status=? "
"ORDER BY j.updated_at DESC, j.id DESC LIMIT ?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare solution jobs by status");
CheckSqlite(sqlite3_bind_text(stmt, 1, status.c_str(), -1, SQLITE_TRANSIENT), db,
"bind solution jobs status");
CheckSqlite(sqlite3_bind_int(stmt, 2, capped), db, "bind solution jobs limit");
std::vector<ProblemSolutionJob> rows;
while (sqlite3_step(stmt) == SQLITE_ROW) {
auto row = ReadSolutionJob(stmt);
row.problem_title = ColText(stmt, 11);
rows.push_back(std::move(row));
}
sqlite3_finalize(stmt);
return rows;
}
int ProblemWorkspaceService::CountProblemsWithoutSolutions() {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT COUNT(*) "
"FROM problems p "
"WHERE NOT EXISTS (SELECT 1 FROM problem_solutions s WHERE s.problem_id=p.id)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare count missing solutions");
CheckSqlite(sqlite3_step(stmt), db, "step count missing solutions");
const int count = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return count;
}
std::vector<int64_t> ProblemWorkspaceService::ListProblemIdsWithoutSolutions(
int limit,
bool exclude_queued_or_running_jobs) {
const int capped = std::max(1, std::min(200000, limit));
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql_all =
"SELECT p.id "
"FROM problems p "
"WHERE NOT EXISTS (SELECT 1 FROM problem_solutions s WHERE s.problem_id=p.id) "
"ORDER BY p.id ASC LIMIT ?";
const char* sql_skip_busy =
"SELECT p.id "
"FROM problems p "
"WHERE NOT EXISTS (SELECT 1 FROM problem_solutions s WHERE s.problem_id=p.id) "
"AND NOT EXISTS ("
" SELECT 1 FROM problem_solution_jobs j "
" WHERE j.problem_id=p.id AND j.status IN ('queued','running')"
") "
"ORDER BY p.id ASC LIMIT ?";
const char* sql = exclude_queued_or_running_jobs ? sql_skip_busy : sql_all;
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare list missing solution problem ids");
CheckSqlite(sqlite3_bind_int(stmt, 1, capped), db,
"bind list missing solution problem ids limit");
std::vector<int64_t> rows;
while (sqlite3_step(stmt) == SQLITE_ROW) {
rows.push_back(sqlite3_column_int64(stmt, 0));
}
sqlite3_finalize(stmt);
return rows;
}
std::vector<ProblemSolution> ProblemWorkspaceService::ListSolutions(int64_t problem_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;

查看文件

@@ -0,0 +1,360 @@
#include "csp/services/redeem_service.h"
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <stdexcept>
#include <string>
#include <utility>
namespace csp::services {
namespace {
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
RedeemItem ReadItem(sqlite3_stmt* stmt) {
RedeemItem item;
item.id = sqlite3_column_int64(stmt, 0);
item.name = ColText(stmt, 1);
item.description = ColText(stmt, 2);
item.unit_label = ColText(stmt, 3);
item.holiday_cost = sqlite3_column_int(stmt, 4);
item.studyday_cost = sqlite3_column_int(stmt, 5);
item.is_active = sqlite3_column_int(stmt, 6) != 0;
item.is_global = sqlite3_column_int(stmt, 7) != 0;
item.created_by = sqlite3_column_int64(stmt, 8);
item.created_at = sqlite3_column_int64(stmt, 9);
item.updated_at = sqlite3_column_int64(stmt, 10);
return item;
}
RedeemRecord ReadRecord(sqlite3_stmt* stmt) {
RedeemRecord row;
row.id = sqlite3_column_int64(stmt, 0);
row.user_id = sqlite3_column_int64(stmt, 1);
row.item_id = sqlite3_column_int64(stmt, 2);
row.item_name = ColText(stmt, 3);
row.quantity = sqlite3_column_int(stmt, 4);
row.day_type = ColText(stmt, 5);
row.unit_cost = sqlite3_column_int(stmt, 6);
row.total_cost = sqlite3_column_int(stmt, 7);
row.note = ColText(stmt, 8);
row.created_at = sqlite3_column_int64(stmt, 9);
row.username = ColText(stmt, 10);
return row;
}
std::string NormalizeDayType(std::string day_type) {
for (auto& c : day_type) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
if (day_type == "holiday" || day_type == "vacation" || day_type == "weekend") {
return "holiday";
}
return "studyday";
}
void ValidateItemWrite(const RedeemItemWrite& input) {
if (input.name.empty()) throw std::runtime_error("item name required");
if (input.name.size() > 120) throw std::runtime_error("item name too long");
if (input.description.size() > 1000) throw std::runtime_error("description too long");
if (input.unit_label.empty() || input.unit_label.size() > 20) {
throw std::runtime_error("invalid unit label");
}
if (input.holiday_cost < 0 || input.studyday_cost < 0) {
throw std::runtime_error("cost must be >= 0");
}
}
int QueryUserRating(sqlite3* db, int64_t user_id) {
sqlite3_stmt* stmt = nullptr;
const char* sql = "SELECT rating FROM users WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query user rating");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
throw std::runtime_error("user not found");
}
const int rating = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return rating;
}
void DeductUserRating(sqlite3* db, int64_t user_id, int delta) {
sqlite3_stmt* stmt = nullptr;
const char* sql = "UPDATE users SET rating=rating-? WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare deduct user rating");
CheckSqlite(sqlite3_bind_int(stmt, 1, delta), db, "bind delta");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
CheckSqlite(sqlite3_step(stmt), db, "exec deduct user rating");
sqlite3_finalize(stmt);
}
} // namespace
std::vector<RedeemItem> RedeemService::ListItems(bool include_inactive) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql_all =
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at "
"FROM redeem_items ORDER BY id ASC";
const char* sql_active =
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at "
"FROM redeem_items WHERE is_active=1 ORDER BY id ASC";
CheckSqlite(sqlite3_prepare_v2(db, include_inactive ? sql_all : sql_active, -1, &stmt, nullptr),
db,
"prepare list redeem items");
std::vector<RedeemItem> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
out.push_back(ReadItem(stmt));
}
sqlite3_finalize(stmt);
return out;
}
std::optional<RedeemItem> RedeemService::GetItemById(int64_t item_id) {
if (item_id <= 0) return std::nullopt;
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at "
"FROM redeem_items WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get redeem item");
CheckSqlite(sqlite3_bind_int64(stmt, 1, item_id), db, "bind item_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
auto item = ReadItem(stmt);
sqlite3_finalize(stmt);
return item;
}
RedeemItem RedeemService::CreateItem(int64_t admin_user_id, const RedeemItemWrite& input) {
ValidateItemWrite(input);
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const int64_t now = NowSec();
const char* sql =
"INSERT INTO redeem_items(name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare create redeem item");
CheckSqlite(sqlite3_bind_text(stmt, 1, input.name.c_str(), -1, SQLITE_TRANSIENT), db,
"bind name");
CheckSqlite(sqlite3_bind_text(stmt, 2, input.description.c_str(), -1, SQLITE_TRANSIENT), db,
"bind description");
CheckSqlite(sqlite3_bind_text(stmt, 3, input.unit_label.c_str(), -1, SQLITE_TRANSIENT), db,
"bind unit_label");
CheckSqlite(sqlite3_bind_int(stmt, 4, input.holiday_cost), db, "bind holiday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 5, input.studyday_cost), db, "bind studyday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 6, input.is_active ? 1 : 0), db, "bind is_active");
CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_global ? 1 : 0), db, "bind is_global");
CheckSqlite(sqlite3_bind_int64(stmt, 8, admin_user_id), db, "bind created_by");
CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind created_at");
CheckSqlite(sqlite3_bind_int64(stmt, 10, now), db, "bind updated_at");
CheckSqlite(sqlite3_step(stmt), db, "create redeem item");
sqlite3_finalize(stmt);
const auto item = GetItemById(sqlite3_last_insert_rowid(db));
if (!item.has_value()) throw std::runtime_error("create redeem item failed");
return *item;
}
RedeemItem RedeemService::UpdateItem(int64_t item_id, const RedeemItemWrite& input) {
ValidateItemWrite(input);
if (item_id <= 0) throw std::runtime_error("invalid item_id");
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const int64_t now = NowSec();
const char* sql =
"UPDATE redeem_items "
"SET name=?,description=?,unit_label=?,holiday_cost=?,studyday_cost=?,is_active=?,is_global=?,updated_at=? "
"WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare update redeem item");
CheckSqlite(sqlite3_bind_text(stmt, 1, input.name.c_str(), -1, SQLITE_TRANSIENT), db,
"bind name");
CheckSqlite(sqlite3_bind_text(stmt, 2, input.description.c_str(), -1, SQLITE_TRANSIENT), db,
"bind description");
CheckSqlite(sqlite3_bind_text(stmt, 3, input.unit_label.c_str(), -1, SQLITE_TRANSIENT), db,
"bind unit_label");
CheckSqlite(sqlite3_bind_int(stmt, 4, input.holiday_cost), db, "bind holiday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 5, input.studyday_cost), db, "bind studyday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 6, input.is_active ? 1 : 0), db, "bind is_active");
CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_global ? 1 : 0), db, "bind is_global");
CheckSqlite(sqlite3_bind_int64(stmt, 8, now), db, "bind updated_at");
CheckSqlite(sqlite3_bind_int64(stmt, 9, item_id), db, "bind item_id");
CheckSqlite(sqlite3_step(stmt), db, "update redeem item");
sqlite3_finalize(stmt);
if (sqlite3_changes(db) <= 0) throw std::runtime_error("redeem item not found");
const auto item = GetItemById(item_id);
if (!item.has_value()) throw std::runtime_error("reload redeem item failed");
return *item;
}
void RedeemService::DeactivateItem(int64_t item_id) {
if (item_id <= 0) throw std::runtime_error("invalid item_id");
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql = "UPDATE redeem_items SET is_active=0,updated_at=? WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare deactivate redeem item");
CheckSqlite(sqlite3_bind_int64(stmt, 1, NowSec()), db, "bind updated_at");
CheckSqlite(sqlite3_bind_int64(stmt, 2, item_id), db, "bind item_id");
CheckSqlite(sqlite3_step(stmt), db, "deactivate redeem item");
sqlite3_finalize(stmt);
if (sqlite3_changes(db) <= 0) throw std::runtime_error("redeem item not found");
}
std::vector<RedeemRecord> RedeemService::ListRecordsByUser(int64_t user_id, int limit) {
return ListRecordsAll(user_id, limit);
}
std::vector<RedeemRecord> RedeemService::ListRecordsAll(std::optional<int64_t> user_id,
int limit) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const int safe_limit = std::max(1, std::min(500, limit));
const char* sql_all =
"SELECT r.id,r.user_id,r.item_id,r.item_name,r.quantity,r.day_type,r.unit_cost,r.total_cost,r.note,r.created_at,"
"COALESCE(u.username,'') "
"FROM redeem_records r LEFT JOIN users u ON u.id=r.user_id "
"ORDER BY r.id DESC LIMIT ?";
const char* sql_by_user =
"SELECT r.id,r.user_id,r.item_id,r.item_name,r.quantity,r.day_type,r.unit_cost,r.total_cost,r.note,r.created_at,"
"COALESCE(u.username,'') "
"FROM redeem_records r LEFT JOIN users u ON u.id=r.user_id "
"WHERE r.user_id=? ORDER BY r.id DESC LIMIT ?";
CheckSqlite(sqlite3_prepare_v2(db, user_id.has_value() ? sql_by_user : sql_all, -1, &stmt, nullptr),
db,
"prepare list redeem records");
if (user_id.has_value()) {
CheckSqlite(sqlite3_bind_int64(stmt, 1, *user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int(stmt, 2, safe_limit), db, "bind limit");
} else {
CheckSqlite(sqlite3_bind_int(stmt, 1, safe_limit), db, "bind limit");
}
std::vector<RedeemRecord> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
out.push_back(ReadRecord(stmt));
}
sqlite3_finalize(stmt);
return out;
}
RedeemRecord RedeemService::Redeem(const RedeemRequest& request) {
if (request.user_id <= 0 || request.item_id <= 0) {
throw std::runtime_error("invalid user_id/item_id");
}
if (request.quantity <= 0 || request.quantity > 24) {
throw std::runtime_error("quantity must be in [1,24]");
}
if (request.note.size() > 1000) {
throw std::runtime_error("note too long");
}
sqlite3* db = db_.raw();
db_.Exec("BEGIN IMMEDIATE");
bool committed = false;
try {
const auto item = GetItemById(request.item_id);
if (!item.has_value()) {
throw std::runtime_error("redeem item not found");
}
if (!item->is_active) {
throw std::runtime_error("redeem item is inactive");
}
const std::string day_type = NormalizeDayType(request.day_type);
const int unit_cost = day_type == "holiday" ? item->holiday_cost : item->studyday_cost;
const int total_cost = unit_cost * request.quantity;
const int current_rating = QueryUserRating(db, request.user_id);
if (current_rating < total_cost) {
throw std::runtime_error("rating not enough");
}
DeductUserRating(db, request.user_id, total_cost);
sqlite3_stmt* stmt = nullptr;
const int64_t now = NowSec();
const char* sql =
"INSERT INTO redeem_records(user_id,item_id,item_name,quantity,day_type,unit_cost,total_cost,note,created_at) "
"VALUES(?,?,?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert redeem record");
CheckSqlite(sqlite3_bind_int64(stmt, 1, request.user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, request.item_id), db, "bind item_id");
CheckSqlite(sqlite3_bind_text(stmt, 3, item->name.c_str(), -1, SQLITE_TRANSIENT), db,
"bind item_name");
CheckSqlite(sqlite3_bind_int(stmt, 4, request.quantity), db, "bind quantity");
CheckSqlite(sqlite3_bind_text(stmt, 5, day_type.c_str(), -1, SQLITE_TRANSIENT), db,
"bind day_type");
CheckSqlite(sqlite3_bind_int(stmt, 6, unit_cost), db, "bind unit_cost");
CheckSqlite(sqlite3_bind_int(stmt, 7, total_cost), db, "bind total_cost");
CheckSqlite(sqlite3_bind_text(stmt, 8, request.note.c_str(), -1, SQLITE_TRANSIENT), db,
"bind note");
CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind created_at");
CheckSqlite(sqlite3_step(stmt), db, "insert redeem record");
sqlite3_finalize(stmt);
const int64_t record_id = sqlite3_last_insert_rowid(db);
db_.Exec("COMMIT");
committed = true;
auto rows = ListRecordsAll(request.user_id, 1);
if (!rows.empty() && rows.front().id == record_id) {
return rows.front();
}
RedeemRecord fallback;
fallback.id = record_id;
fallback.user_id = request.user_id;
fallback.item_id = request.item_id;
fallback.item_name = item->name;
fallback.quantity = request.quantity;
fallback.day_type = day_type;
fallback.unit_cost = unit_cost;
fallback.total_cost = total_cost;
fallback.note = request.note;
fallback.created_at = now;
return fallback;
} catch (...) {
if (!committed) {
try {
db_.Exec("ROLLBACK");
} catch (...) {
}
}
throw;
}
}
} // namespace csp::services

查看文件

@@ -0,0 +1,203 @@
#include "csp/services/solution_access_service.h"
#include <sqlite3.h>
#include <chrono>
#include <ctime>
#include <stdexcept>
#include <string>
namespace csp::services {
namespace {
constexpr int kViewCost = 2;
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
std::string BuildDayKeyChina(int64_t ts_sec) {
const std::time_t shifted = static_cast<std::time_t>(ts_sec + 8 * 3600);
std::tm tm {};
gmtime_r(&shifted, &tm);
char buf[16] = {0};
std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm);
return std::string(buf);
}
int QueryRating(sqlite3* db, int64_t user_id) {
sqlite3_stmt* stmt = nullptr;
const char* sql = "SELECT rating FROM users WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query rating");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
throw std::runtime_error("user not found");
}
const int rating = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return rating;
}
int QueryDailyUsage(sqlite3* db, int64_t user_id, const std::string& day_key) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT COUNT(1) FROM problem_solution_view_logs WHERE user_id=? AND day_key=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query daily usage");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
"bind day_key");
CheckSqlite(sqlite3_step(stmt), db, "step query daily usage");
const int used = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return used;
}
void InsertViewLog(sqlite3* db,
int64_t user_id,
int64_t problem_id,
const std::string& day_key,
int64_t viewed_at,
bool charged,
int cost) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO problem_solution_view_logs(user_id,problem_id,day_key,viewed_at,charged,cost,created_at) "
"VALUES(?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert solution view log");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
"bind day_key");
CheckSqlite(sqlite3_bind_int64(stmt, 4, viewed_at), db, "bind viewed_at");
CheckSqlite(sqlite3_bind_int(stmt, 5, charged ? 1 : 0), db, "bind charged");
CheckSqlite(sqlite3_bind_int(stmt, 6, cost), db, "bind cost");
CheckSqlite(sqlite3_bind_int64(stmt, 7, viewed_at), db, "bind created_at");
CheckSqlite(sqlite3_step(stmt), db, "insert solution view log");
sqlite3_finalize(stmt);
}
void DeductRating(sqlite3* db, int64_t user_id, int cost) {
sqlite3_stmt* stmt = nullptr;
const char* sql = "UPDATE users SET rating=rating-? WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare deduct rating");
CheckSqlite(sqlite3_bind_int(stmt, 1, cost), db, "bind cost");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
CheckSqlite(sqlite3_step(stmt), db, "deduct rating");
sqlite3_finalize(stmt);
}
} // namespace
SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
int64_t user_id,
int64_t problem_id) {
if (user_id <= 0 || problem_id <= 0) {
throw std::runtime_error("invalid user_id/problem_id");
}
sqlite3* db = db_.raw();
const int64_t now = NowSec();
const std::string day_key = BuildDayKeyChina(now);
db_.Exec("BEGIN IMMEDIATE");
bool committed = false;
try {
SolutionViewChargeResult result;
result.day_key = day_key;
result.viewed_at = now;
const int used_before = QueryDailyUsage(db, user_id, day_key);
result.daily_used_count = used_before;
const int rating_before = QueryRating(db, user_id);
result.rating_before = rating_before;
if (used_before <= 0) {
result.daily_free = true;
result.charged = false;
result.cost = 0;
result.rating_after = rating_before;
} else {
if (rating_before < kViewCost) {
result.granted = false;
result.charged = false;
result.cost = kViewCost;
result.rating_after = rating_before;
result.deny_reason = "rating not enough";
db_.Exec("ROLLBACK");
return result;
}
result.daily_free = false;
result.charged = true;
result.cost = kViewCost;
result.rating_after = rating_before - kViewCost;
DeductRating(db, user_id, kViewCost);
}
InsertViewLog(db,
user_id,
problem_id,
day_key,
now,
result.charged,
result.cost);
result.daily_used_count = used_before + 1;
db_.Exec("COMMIT");
committed = true;
return result;
} catch (...) {
if (!committed) {
try {
db_.Exec("ROLLBACK");
} catch (...) {
}
}
throw;
}
}
SolutionViewStats SolutionAccessService::QueryUserProblemViewStats(
int64_t user_id,
int64_t problem_id) {
SolutionViewStats stats;
if (user_id <= 0 || problem_id <= 0) return stats;
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT COUNT(1),COALESCE(SUM(cost),0),MAX(viewed_at) "
"FROM problem_solution_view_logs WHERE user_id=? AND problem_id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query solution view stats");
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 solution view stats");
stats.total_views = sqlite3_column_int(stmt, 0);
stats.total_cost = sqlite3_column_int(stmt, 1);
if (sqlite3_column_type(stmt, 2) != SQLITE_NULL) {
stats.last_viewed_at = sqlite3_column_int64(stmt, 2);
}
stats.has_viewed = stats.total_views > 0;
sqlite3_finalize(stmt);
return stats;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,228 @@
#include "csp/services/submission_feedback_service.h"
#include "csp/domain/enum_strings.h"
#include "csp/services/crypto.h"
#include <json/json.h>
#include <sqlite3.h>
#include <chrono>
#include <cstdlib>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <string>
#include <sys/wait.h>
#include <vector>
namespace csp::services {
namespace {
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
std::string ShellQuote(const std::string& text) {
std::string out = "'";
for (char c : text) {
if (c == '\'') {
out += "'\"'\"'";
} else {
out.push_back(c);
}
}
out.push_back('\'');
return out;
}
std::string ResolveScriptPath() {
const char* env_path = std::getenv("CSP_SUBMISSION_ANALYSIS_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
const std::vector<std::string> candidates = {
"/app/scripts/analyze_submission_feedback.py",
"scripts/analyze_submission_feedback.py",
"../scripts/analyze_submission_feedback.py",
"../../scripts/analyze_submission_feedback.py",
};
for (const auto& path : candidates) {
if (std::filesystem::exists(path)) return path;
}
return "/app/scripts/analyze_submission_feedback.py";
}
int ExitCodeFromSystem(int rc) {
if (rc == -1) return -1;
if (WIFEXITED(rc)) return WEXITSTATUS(rc);
if (WIFSIGNALED(rc)) return 128 + WTERMSIG(rc);
return -1;
}
SubmissionFeedback ReadSubmissionFeedback(sqlite3_stmt* stmt) {
SubmissionFeedback row;
row.submission_id = sqlite3_column_int64(stmt, 0);
row.feedback_md = ColText(stmt, 1);
row.links_json = ColText(stmt, 2);
row.model_name = ColText(stmt, 3);
row.status = ColText(stmt, 4);
row.created_at = sqlite3_column_int64(stmt, 5);
row.updated_at = sqlite3_column_int64(stmt, 6);
return row;
}
std::string JsonToString(const Json::Value& value) {
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
return Json::writeString(builder, value);
}
} // namespace
std::optional<SubmissionFeedback> SubmissionFeedbackService::GetBySubmissionId(
int64_t submission_id) {
if (submission_id <= 0) return std::nullopt;
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT submission_id,feedback_md,links_json,model_name,status,created_at,updated_at "
"FROM submission_feedback WHERE submission_id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get submission feedback");
CheckSqlite(sqlite3_bind_int64(stmt, 1, submission_id), db,
"bind submission_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
auto row = ReadSubmissionFeedback(stmt);
sqlite3_finalize(stmt);
return row;
}
SubmissionFeedback SubmissionFeedbackService::GenerateAndSave(
const domain::Submission& submission,
const domain::Problem& problem,
bool force_refresh) {
if (!force_refresh) {
if (const auto existing = GetBySubmissionId(submission.id); existing.has_value()) {
return *existing;
}
}
Json::Value input;
input["submission_id"] = Json::Int64(submission.id);
input["problem_id"] = Json::Int64(problem.id);
input["problem_title"] = problem.title;
input["problem_statement"] = problem.statement_md;
input["sample_input"] = problem.sample_input;
input["sample_output"] = problem.sample_output;
input["status"] = domain::ToString(submission.status);
input["score"] = submission.score;
input["time_ms"] = submission.time_ms;
input["language"] = domain::ToString(submission.language);
input["code"] = submission.code;
input["compile_log"] = submission.compile_log;
input["runtime_log"] = submission.runtime_log;
namespace fs = std::filesystem;
const fs::path temp_file =
fs::path("/tmp") / ("csp_submission_feedback_" + crypto::RandomHex(8) + ".json");
{
std::ofstream out(temp_file, std::ios::out | std::ios::binary | std::ios::trunc);
if (!out) throw std::runtime_error("failed to create temp input file");
out << JsonToString(input);
}
const std::string script = ResolveScriptPath();
const std::string cmd =
"/usr/bin/timeout 45s python3 " + ShellQuote(script) + " --input-file " +
ShellQuote(temp_file.string()) + " 2>&1";
std::string output;
int exit_code = -1;
{
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) {
fs::remove(temp_file);
throw std::runtime_error("failed to start feedback script");
}
char buffer[4096];
while (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
output += buffer;
}
exit_code = ExitCodeFromSystem(pclose(pipe));
}
fs::remove(temp_file);
if (exit_code != 0) {
throw std::runtime_error("feedback script failed: " + output);
}
Json::CharReaderBuilder builder;
Json::Value parsed;
std::string errs;
std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
if (!reader->parse(output.data(), output.data() + output.size(), &parsed, &errs) ||
!parsed.isObject()) {
throw std::runtime_error("feedback script returned invalid json");
}
const std::string feedback_md = parsed.get("feedback_md", "").asString();
const std::string model_name = parsed.get("model_name", "").asString();
const std::string status = parsed.get("status", "ready").asString();
const Json::Value links = parsed.isMember("links") ? parsed["links"] : Json::Value(Json::arrayValue);
const std::string links_json = JsonToString(links);
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const int64_t now = NowSec();
const char* upsert_sql =
"INSERT INTO submission_feedback(submission_id,feedback_md,links_json,model_name,status,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?) "
"ON CONFLICT(submission_id) DO UPDATE SET "
"feedback_md=excluded.feedback_md,"
"links_json=excluded.links_json,"
"model_name=excluded.model_name,"
"status=excluded.status,"
"updated_at=excluded.updated_at";
CheckSqlite(sqlite3_prepare_v2(db, upsert_sql, -1, &stmt, nullptr), db,
"prepare upsert submission feedback");
CheckSqlite(sqlite3_bind_int64(stmt, 1, submission.id), db, "bind submission_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, feedback_md.c_str(), -1, SQLITE_TRANSIENT), db,
"bind feedback_md");
CheckSqlite(sqlite3_bind_text(stmt, 3, links_json.c_str(), -1, SQLITE_TRANSIENT), db,
"bind links_json");
CheckSqlite(sqlite3_bind_text(stmt, 4, model_name.c_str(), -1, SQLITE_TRANSIENT), db,
"bind model_name");
CheckSqlite(sqlite3_bind_text(stmt, 5, status.c_str(), -1, SQLITE_TRANSIENT), db,
"bind status");
CheckSqlite(sqlite3_bind_int64(stmt, 6, now), db, "bind created_at");
CheckSqlite(sqlite3_bind_int64(stmt, 7, now), db, "bind updated_at");
CheckSqlite(sqlite3_step(stmt), db, "upsert submission feedback");
sqlite3_finalize(stmt);
const auto saved = GetBySubmissionId(submission.id);
if (!saved.has_value()) {
throw std::runtime_error("submission feedback saved but reload failed");
}
return *saved;
}
} // namespace csp::services

查看文件

@@ -341,12 +341,16 @@ std::vector<domain::Submission> SubmissionService::List(std::optional<int64_t> u
sqlite3_stmt* stmt = nullptr;
std::string sql =
"SELECT id,user_id,problem_id,contest_id,language,code,status,score,time_ms,memory_kb,compile_log,runtime_log,created_at "
"FROM submissions WHERE 1=1 ";
if (user_id.has_value()) sql += "AND user_id=? ";
if (problem_id.has_value()) sql += "AND problem_id=? ";
if (contest_id.has_value()) sql += "AND contest_id=? ";
sql += "ORDER BY id DESC LIMIT ? OFFSET ?";
"SELECT s.id,s.user_id,s.problem_id,s.contest_id,s.language,s.code,s.status,s.score,s.time_ms,s.memory_kb,s.compile_log,s.runtime_log,s.created_at,"
"CASE WHEN s.status='AC' AND NOT EXISTS ("
" SELECT 1 FROM submissions s2 "
" WHERE s2.user_id=s.user_id AND s2.problem_id=s.problem_id AND s2.status='AC' AND s2.id<s.id"
") THEN 2 ELSE 0 END AS rating_delta "
"FROM submissions s WHERE 1=1 ";
if (user_id.has_value()) sql += "AND s.user_id=? ";
if (problem_id.has_value()) sql += "AND s.problem_id=? ";
if (contest_id.has_value()) sql += "AND s.contest_id=? ";
sql += "ORDER BY s.id DESC LIMIT ? OFFSET ?";
CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db,
"prepare list submissions");
@@ -385,6 +389,7 @@ std::vector<domain::Submission> SubmissionService::List(std::optional<int64_t> u
s.compile_log = ColText(stmt, 10);
s.runtime_log = ColText(stmt, 11);
s.created_at = sqlite3_column_int64(stmt, 12);
s.rating_delta = sqlite3_column_int(stmt, 13);
out.push_back(std::move(s));
}
sqlite3_finalize(stmt);
@@ -395,8 +400,12 @@ std::optional<domain::Submission> SubmissionService::GetById(int64_t id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,user_id,problem_id,contest_id,language,code,status,score,time_ms,memory_kb,compile_log,runtime_log,created_at "
"FROM submissions WHERE id=?";
"SELECT s.id,s.user_id,s.problem_id,s.contest_id,s.language,s.code,s.status,s.score,s.time_ms,s.memory_kb,s.compile_log,s.runtime_log,s.created_at,"
"CASE WHEN s.status='AC' AND NOT EXISTS ("
" SELECT 1 FROM submissions s2 "
" WHERE s2.user_id=s.user_id AND s2.problem_id=s.problem_id AND s2.status='AC' AND s2.id<s.id"
") THEN 2 ELSE 0 END AS rating_delta "
"FROM submissions s WHERE s.id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get submission");
CheckSqlite(sqlite3_bind_int64(stmt, 1, id), db, "bind submission_id");
@@ -424,6 +433,7 @@ std::optional<domain::Submission> SubmissionService::GetById(int64_t id) {
s.compile_log = ColText(stmt, 10);
s.runtime_log = ColText(stmt, 11);
s.created_at = sqlite3_column_int64(stmt, 12);
s.rating_delta = sqlite3_column_int(stmt, 13);
sqlite3_finalize(stmt);
return s;
}

查看文件

@@ -2,8 +2,10 @@
#include <sqlite3.h>
#include <algorithm>
#include <stdexcept>
#include <string>
#include <utility>
namespace csp::services {
@@ -70,4 +72,58 @@ std::vector<domain::GlobalLeaderboardEntry> UserService::GlobalLeaderboard(int l
return out;
}
UserListResult UserService::ListUsers(int page, int page_size) {
const int safe_page = std::max(1, page);
const int safe_size = std::max(1, std::min(200, page_size));
const int offset = (safe_page - 1) * safe_size;
sqlite3* db = db_.raw();
UserListResult result;
{
sqlite3_stmt* count_stmt = nullptr;
const char* count_sql = "SELECT COUNT(1) FROM users";
CheckSqlite(sqlite3_prepare_v2(db, count_sql, -1, &count_stmt, nullptr), db,
"prepare count users");
CheckSqlite(sqlite3_step(count_stmt), db, "step count users");
result.total_count = sqlite3_column_int(count_stmt, 0);
sqlite3_finalize(count_stmt);
}
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,username,rating,created_at FROM users ORDER BY id ASC LIMIT ? OFFSET ?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare list users");
CheckSqlite(sqlite3_bind_int(stmt, 1, safe_size), db, "bind limit");
CheckSqlite(sqlite3_bind_int(stmt, 2, offset), db, "bind offset");
while (sqlite3_step(stmt) == SQLITE_ROW) {
domain::GlobalLeaderboardEntry e;
e.user_id = sqlite3_column_int64(stmt, 0);
e.username = ColText(stmt, 1);
e.rating = sqlite3_column_int(stmt, 2);
e.created_at = sqlite3_column_int64(stmt, 3);
result.items.push_back(std::move(e));
}
sqlite3_finalize(stmt);
return result;
}
void UserService::SetRating(int64_t user_id, int rating) {
if (user_id <= 0) throw std::runtime_error("invalid user_id");
if (rating < 0) throw std::runtime_error("rating must be >= 0");
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql = "UPDATE users SET rating=? WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare set user rating");
CheckSqlite(sqlite3_bind_int(stmt, 1, rating), db, "bind rating");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
CheckSqlite(sqlite3_step(stmt), db, "exec set user rating");
sqlite3_finalize(stmt);
if (sqlite3_changes(db) <= 0) throw std::runtime_error("user not found");
}
} // namespace csp::services

查看文件

@@ -42,7 +42,21 @@ TEST_CASE("problem workspace service drafts and solution jobs") {
REQUIRE(latest->id == job_id);
REQUIRE(latest->status == "queued");
REQUIRE(latest->max_solutions == 3);
REQUIRE(latest->problem_title.empty());
const auto recent = svc.ListRecentSolutionJobs(10);
REQUIRE(recent.size() == 1);
REQUIRE(recent.front().id == job_id);
REQUIRE(recent.front().problem_id == pid);
REQUIRE(!recent.front().problem_title.empty());
const auto solutions = svc.ListSolutions(pid);
REQUIRE(solutions.empty());
REQUIRE(svc.CountProblemsWithoutSolutions() >= 1);
const auto missing_all = svc.ListProblemIdsWithoutSolutions(10, false);
REQUIRE(!missing_all.empty());
const auto missing_skip_busy = svc.ListProblemIdsWithoutSolutions(10, true);
REQUIRE(!missing_skip_busy.empty());
REQUIRE(missing_skip_busy.size() < missing_all.size());
}

查看文件

@@ -4,16 +4,24 @@ services:
- .env
environment:
- OI_IMPORT_AUTO_RUN=true
- OI_IMPORT_AUTO_MODE=luogu
- OI_IMPORT_WORKERS=3
- OI_IMPORT_SCRIPT_PATH=/app/scripts/import_luogu_csp.py
- OI_IMPORT_CLEAR_ALL_PROBLEMS=true
- OI_IMPORT_CLEAR_EXISTING=true
- OI_IMPORT_LOCAL_RAG_SCRIPT_PATH=/app/scripts/import_local_pdf_rag.py
- OI_LOCAL_PDF_DIR=/data/local_pdfs
- OI_IMPORT_TARGET_TOTAL=5000
- OI_IMPORT_RESUME_ON_RESTART=true
- OI_IMPORT_CLEAR_ALL_PROBLEMS=false
- OI_IMPORT_CLEAR_EXISTING=false
- "OI_IMPORT_CLEAR_SOURCE_PREFIX=luogu:"
- CSP_GEN_AUTO_RUN=true
- CSP_GEN_COUNT=1
- CSP_GEN_WAIT_FOR_IMPORT=true
- CSP_GEN_SCRIPT_PATH=/app/scripts/generate_cspj_problem_rag.py
- CSP_SOLUTION_SCRIPT_PATH=/app/scripts/generate_problem_solutions.py
- CSP_SOLUTION_AUTO_RUN_MISSING=true
- CSP_SOLUTION_AUTO_LIMIT=50000
- CSP_SOLUTION_AUTO_MAX_SOLUTIONS=3
build:
context: .
dockerfile: Dockerfile.backend
@@ -22,6 +30,7 @@ services:
# - "8080:8080"
volumes:
- csp_data:/data
- ./data/local_pdfs:/data/local_pdfs
restart: unless-stopped
frontend:

查看文件

@@ -140,3 +140,20 @@
```json
{ "clear_all_problems": true }
```
也支持本地 PDF + RAG + LLM 出题模式(异步执行,进度同样写入 `import_jobs/import_job_items`
```json
{
"mode": "local_pdf_rag",
"workers": 3,
"local_pdf_dir": "/data/local_pdfs",
"target_total": 5000
}
```
---
## 9) 后台日志(题解异步队列)
### `GET /backend/logs?limit=100`
返回最近题解生成任务日志(按任务 ID 倒序),并包含当前排队任务数 `pending_jobs`

查看文件

@@ -0,0 +1,348 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type RedeemItem = {
id: number;
name: string;
description: string;
unit_label: string;
holiday_cost: number;
studyday_cost: number;
is_active: boolean;
is_global: boolean;
created_at: number;
updated_at: number;
};
type RedeemRecord = {
id: number;
user_id: number;
username: string;
item_id: number;
item_name: string;
quantity: number;
day_type: string;
unit_cost: number;
total_cost: number;
note: string;
created_at: number;
};
type ItemForm = {
name: string;
description: string;
unit_label: string;
holiday_cost: number;
studyday_cost: number;
is_active: boolean;
is_global: boolean;
};
const DEFAULT_FORM: ItemForm = {
name: "",
description: "",
unit_label: "hour",
holiday_cost: 5,
studyday_cost: 25,
is_active: true,
is_global: true,
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function AdminRedeemPage() {
const { tx } = useI18nText();
const [token, setToken] = useState("");
const [items, setItems] = useState<RedeemItem[]>([]);
const [records, setRecords] = useState<RedeemRecord[]>([]);
const [form, setForm] = useState<ItemForm>(DEFAULT_FORM);
const [editingId, setEditingId] = useState<number | null>(null);
const [recordUserId, setRecordUserId] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [msg, setMsg] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const tk = readToken();
setToken(tk);
if (!tk) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
const recordsQs = new URLSearchParams({ limit: "200" });
if (recordUserId.trim()) recordsQs.set("user_id", recordUserId.trim());
const [itemRows, recordRows] = await Promise.all([
apiFetch<RedeemItem[]>("/api/v1/admin/redeem-items?include_inactive=1", {}, tk),
apiFetch<RedeemRecord[]>(`/api/v1/admin/redeem-records?${recordsQs.toString()}`, {}, tk),
]);
setItems(itemRows ?? []);
setRecords(recordRows ?? []);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const submit = async () => {
setSaving(true);
setError("");
setMsg("");
try {
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
if (!form.name.trim()) throw new Error(tx("物品名称不能为空", "Item name is required"));
const payload = {
...form,
name: form.name.trim(),
description: form.description.trim(),
unit_label: form.unit_label.trim() || tx("小时", "hour"),
};
if (editingId) {
await apiFetch(`/api/v1/admin/redeem-items/${editingId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}, token);
setMsg(tx(`已更新兑换物品 #${editingId}`, `Updated redeem item #${editingId}`));
} else {
await apiFetch("/api/v1/admin/redeem-items", {
method: "POST",
body: JSON.stringify(payload),
}, token);
setMsg(tx("已新增兑换物品", "Added redeem item"));
}
setForm(DEFAULT_FORM);
setEditingId(null);
await load();
} catch (e: unknown) {
setError(String(e));
} finally {
setSaving(false);
}
};
const edit = (item: RedeemItem) => {
setEditingId(item.id);
setForm({
name: item.name,
description: item.description,
unit_label: item.unit_label,
holiday_cost: item.holiday_cost,
studyday_cost: item.studyday_cost,
is_active: item.is_active,
is_global: item.is_global,
});
};
const deactivate = async (id: number) => {
setError("");
setMsg("");
try {
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
await apiFetch(`/api/v1/admin/redeem-items/${id}`, { method: "DELETE" }, token);
setMsg(tx(`已下架兑换物品 #${id}`, `Disabled redeem item #${id}`));
await load();
} catch (e: unknown) {
setError(String(e));
}
};
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">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("管理员:积分兑换管理", "Admin: Redeem Management")}
</h1>
<p className="mt-1 text-sm text-zinc-600">
{tx(
"可在此添加/修改/下架全局兑换物品,并查看全站兑换记录。",
"Add/update/disable global redeem items and view all redeem records here."
)}
</p>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-base font-semibold">{tx("兑换物品表单(增删改查)", "Redeem Item Form (CRUD)")}</h2>
<div className="mt-3 grid gap-2 md:grid-cols-2">
<input
className="rounded border px-3 py-2 text-sm"
placeholder={tx("物品名称", "Item name")}
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
<input
className="rounded border px-3 py-2 text-sm"
placeholder={tx("单位(如:小时)", "Unit (e.g. hour)")}
value={form.unit_label}
onChange={(e) => setForm((prev) => ({ ...prev, unit_label: e.target.value }))}
/>
<input
className="rounded border px-3 py-2 text-sm"
type="number"
min={0}
placeholder={tx("假期单价", "Holiday unit cost")}
value={form.holiday_cost}
onChange={(e) =>
setForm((prev) => ({ ...prev, holiday_cost: Math.max(0, Number(e.target.value) || 0) }))
}
/>
<input
className="rounded border px-3 py-2 text-sm"
type="number"
min={0}
placeholder={tx("学习日单价", "Study-day unit cost")}
value={form.studyday_cost}
onChange={(e) =>
setForm((prev) => ({ ...prev, studyday_cost: Math.max(0, Number(e.target.value) || 0) }))
}
/>
<textarea
className="rounded border px-3 py-2 text-sm md:col-span-2"
placeholder={tx("描述", "Description")}
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm((prev) => ({ ...prev, is_active: e.target.checked }))}
/>
{tx("启用", "Enabled")}
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.is_global}
onChange={(e) => setForm((prev) => ({ ...prev, is_global: e.target.checked }))}
/>
{tx("全局可兑换", "Global redeemable")}
</label>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
className="rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
onClick={() => void submit()}
disabled={saving}
>
{saving
? tx("保存中...", "Saving...")
: editingId
? tx(`保存修改 #${editingId}`, `Save changes #${editingId}`)
: tx("新增物品", "Add item")}
</button>
<button
className="rounded border px-4 py-2 text-sm"
onClick={() => {
setEditingId(null);
setForm(DEFAULT_FORM);
}}
>
{tx("清空表单", "Clear form")}
</button>
<button
className="rounded border px-4 py-2 text-sm"
onClick={() => void load()}
disabled={loading}
>
{tx("刷新数据", "Refresh data")}
</button>
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-base font-semibold">{tx("兑换物品列表", "Redeem Items")}</h2>
<div className="mt-3 divide-y">
{items.map((item) => (
<article key={item.id} className="py-2 text-sm">
<p>
#{item.id} · {item.name} · {tx("假期", "Holiday")} {item.holiday_cost}/{item.unit_label} · {tx("学习日", "Study Day")} {item.studyday_cost}/
{item.unit_label}
</p>
<p className="text-xs text-zinc-600">
{tx("状态:", "Status: ")}
{item.is_active ? tx("启用", "Enabled") : tx("下架", "Disabled")}
{" · "}
{tx("范围:", "Scope: ")}
{item.is_global ? tx("全局", "Global") : tx("非全局", "Non-global")}
{" · "}
{tx("更新:", "Updated: ")}
{fmtTs(item.updated_at)}
</p>
<p className="text-xs text-zinc-500">{item.description || "-"}</p>
<div className="mt-1 flex gap-2">
<button className="rounded border px-2 py-1 text-xs" onClick={() => edit(item)}>
{tx("编辑", "Edit")}
</button>
<button className="rounded border px-2 py-1 text-xs" onClick={() => void deactivate(item.id)}>
{tx("下架", "Disable")}
</button>
</div>
</article>
))}
{!loading && items.length === 0 && (
<p className="py-3 text-sm text-zinc-500">{tx("暂无兑换物品。", "No redeem items yet.")}</p>
)}
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold">{tx("兑换记录", "Redeem Records")}</h2>
<input
className="rounded border px-3 py-1 text-xs"
placeholder={tx("按 user_id 筛选(可选)", "Filter by user_id (optional)")}
value={recordUserId}
onChange={(e) => setRecordUserId(e.target.value)}
/>
<button className="rounded border px-3 py-1 text-xs" onClick={() => void load()}>
{tx("筛选/刷新", "Filter / Refresh")}
</button>
</div>
<div className="mt-3 divide-y">
{records.map((row) => (
<article key={row.id} className="py-2 text-sm">
<p>
#{row.id} · {tx("用户", "User")} {row.user_id}({row.username || "-"}) · {row.item_name} × {row.quantity}
</p>
<p className="text-xs text-zinc-600">
{row.day_type === "holiday" ? tx("假期", "Holiday") : tx("学习日", "Study Day")}
{" · "}
{tx("单价", "Unit cost")} {row.unit_cost}
{" · "}
{tx("扣分", "Cost")} {row.total_cost}
{" · "}
{fmtTs(row.created_at)}
</p>
{row.note && <p className="text-xs text-zinc-500">{tx("备注:", "Note: ")}{row.note}</p>}
</article>
))}
{!loading && records.length === 0 && (
<p className="py-3 text-sm text-zinc-500">{tx("暂无兑换记录。", "No redeem records yet.")}</p>
)}
</div>
</section>
</main>
);
}

查看文件

@@ -0,0 +1,192 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type AdminUser = {
id: number;
username: string;
rating: number;
created_at: number;
};
type ListResp = {
items: AdminUser[];
total_count: number;
page: number;
page_size: number;
};
function fmtTs(v: number): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function AdminUsersPage() {
const { tx } = useI18nText();
const [items, setItems] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [msg, setMsg] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const token = readToken();
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
const data = await apiFetch<ListResp>("/api/v1/admin/users?page=1&page_size=200", undefined, token);
setItems(data.items ?? []);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateRating = async (userId: number, rating: number) => {
setMsg("");
setError("");
try {
const token = readToken();
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
await apiFetch(
`/api/v1/admin/users/${userId}/rating`,
{
method: "PATCH",
body: JSON.stringify({ rating }),
},
token
);
setMsg(tx(`已更新用户 ${userId} Rating=${rating}`, `Updated user ${userId} rating=${rating}`));
await load();
} catch (e: unknown) {
setError(String(e));
}
};
return (
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("管理员用户与积分", "Admin Users & Rating")}
</h1>
<p className="mt-2 text-sm text-zinc-600">
{tx("默认管理员账号:", "Default admin account: ")}
<code>admin</code> / <code>whoami139</code>
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button
className="rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
onClick={() => void load()}
disabled={loading}
>
{loading ? tx("刷新中...", "Refreshing...") : tx("刷新用户列表", "Refresh users")}
</button>
</div>
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 rounded-xl border bg-white">
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2">ID</th>
<th className="px-3 py-2">{tx("用户名", "Username")}</th>
<th className="px-3 py-2">Rating</th>
<th className="px-3 py-2">{tx("创建时间", "Created At")}</th>
<th className="px-3 py-2">{tx("操作", "Action")}</th>
</tr>
</thead>
<tbody>
{items.map((user) => (
<tr key={user.id} className="border-t">
<td className="px-3 py-2">{user.id}</td>
<td className="px-3 py-2">{user.username}</td>
<td className="px-3 py-2">
<input
className="w-24 rounded border px-2 py-1"
type="number"
min={0}
value={user.rating}
onChange={(e) => {
const value = Number(e.target.value);
setItems((prev) =>
prev.map((row) => (row.id === user.id ? { ...row, rating: value } : row))
);
}}
/>
</td>
<td className="px-3 py-2 text-zinc-600">{fmtTs(user.created_at)}</td>
<td className="px-3 py-2">
<button
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100"
onClick={() => void updateRating(user.id, Math.max(0, Number(user.rating) || 0))}
>
{tx("保存", "Save")}
</button>
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td className="px-3 py-6 text-center text-zinc-500" colSpan={5}>
{tx("暂无用户数据", "No users found")}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="divide-y md:hidden">
{items.map((user) => (
<div key={user.id} className="space-y-2 p-3 text-sm">
<p>
#{user.id} · {user.username}
</p>
<p className="text-xs text-zinc-500">
{tx("创建时间:", "Created: ")}
{fmtTs(user.created_at)}
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-600">Rating</span>
<input
className="w-24 rounded border px-2 py-1 text-xs"
type="number"
min={0}
value={user.rating}
onChange={(e) => {
const value = Number(e.target.value);
setItems((prev) =>
prev.map((row) => (row.id === user.id ? { ...row, rating: value } : row))
);
}}
/>
<button
className="rounded border px-3 py-1 text-xs"
onClick={() => void updateRating(user.id, Math.max(0, Number(user.rating) || 0))}
>
{tx("保存", "Save")}
</button>
</div>
</div>
))}
{!loading && items.length === 0 && (
<p className="px-3 py-6 text-center text-sm text-zinc-500">{tx("暂无用户数据", "No users found")}</p>
)}
</div>
</div>
</main>
);
}

查看文件

@@ -1,28 +1,25 @@
import Link from "next/link";
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useUiPreferences } from "@/components/ui-preference-provider";
export default function AdminEntryPage() {
const { t } = useUiPreferences();
const router = useRouter();
useEffect(() => {
router.replace("/imports");
}, [router]);
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-3 text-sm text-zinc-600">
<span className="font-medium text-zinc-900">admin</span>
<span className="font-medium text-zinc-900">whoami139</span>
<h1 className="text-2xl font-semibold">{t("admin.entry.title")}</h1>
<p className="mt-3 text-sm text-zinc-600">{t("admin.entry.desc")}</p>
<p className="mt-2 text-sm text-zinc-500">
{t("admin.entry.moved_to_platform")}
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/auth">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/admin-users">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/admin-redeem">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/backend-logs">
</Link>
</div>
</main>
);
}

查看文件

@@ -1,19 +1,80 @@
"use client";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { API_BASE } from "@/lib/api";
import { API_BASE, apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
export default function ApiDocsPage() {
const { tx } = useI18nText();
const specUrl = useMemo(() => `${API_BASE}/api/openapi.json`, []);
const [checkingAdmin, setCheckingAdmin] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let canceled = false;
const checkAdmin = async () => {
const token = readToken();
if (!token) {
if (!canceled) {
setIsAdmin(false);
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
setCheckingAdmin(false);
}
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, token);
if (!canceled) {
const allowed = (me?.username ?? "") === "admin";
setIsAdmin(allowed);
setError(allowed ? "" : tx("仅管理员可查看 API 文档", "API docs are visible to admin only"));
}
} catch (e: unknown) {
if (!canceled) {
setIsAdmin(false);
setError(String(e));
}
} finally {
if (!canceled) setCheckingAdmin(false);
}
};
void checkAdmin();
return () => {
canceled = true;
};
}, [tx]);
if (checkingAdmin) {
return (
<main className="mx-auto max-w-7xl px-3 py-8 text-sm text-zinc-600">
{tx("正在校验管理员权限...", "Checking admin access...")}
</main>
);
}
if (!isAdmin) {
return (
<main className="mx-auto max-w-7xl px-3 py-8">
<h1 className="text-xl font-semibold">{tx("API 文档Swagger", "API Docs (Swagger)")}</h1>
<p className="mt-3 text-sm text-red-600">
{error || tx("仅管理员可查看此页面", "This page is available for admin only")}
</p>
</main>
);
}
return (
<main className="mx-auto max-w-7xl px-6 py-6">
<h1 className="mb-4 text-2xl font-semibold">API Swagger</h1>
<div className="rounded-xl border bg-white p-2">
<main className="mx-auto max-w-7xl px-3 py-5 max-[390px]:px-2 sm:px-4 md:px-6 md:py-6">
<h1 className="mb-4 text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("API 文档Swagger", "API Docs (Swagger)")}
</h1>
<div className="overflow-x-auto rounded-xl border bg-white p-2">
<SwaggerUI url={specUrl} docExpansion="list" defaultModelsExpandDepth={1} />
</div>
</main>

查看文件

@@ -1,25 +1,28 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { API_BASE, apiFetch } from "@/lib/api";
import { saveToken } from "@/lib/auth";
import { readToken, saveToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type AuthOk = { ok: true; user_id: number; token: string; expires_at: number };
type AuthErr = { ok: false; error: string };
type AuthResp = AuthOk | AuthErr;
function passwordScore(password: string): { label: string; color: string } {
if (password.length >= 12) return { label: "强", color: "text-emerald-600" };
if (password.length >= 8) return { label: "中", color: "text-blue-600" };
return { label: "弱", color: "text-orange-600" };
function passwordScore(password: string, isZh: boolean): { label: string; color: string } {
if (password.length >= 12) return { label: isZh ? "强" : "Strong", color: "text-emerald-600" };
if (password.length >= 8) return { label: isZh ? "中" : "Medium", color: "text-blue-600" };
return { label: isZh ? "弱" : "Weak", color: "text-orange-600" };
}
export default function AuthPage() {
const { isZh, tx } = useI18nText();
const router = useRouter();
const apiBase = useMemo(() => API_BASE, []);
const [checkingAuth, setCheckingAuth] = useState(true);
const [mode, setMode] = useState<"register" | "login">("login");
const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? "");
@@ -29,10 +32,18 @@ export default function AuthPage() {
const [loading, setLoading] = useState(false);
const [resp, setResp] = useState<AuthResp | null>(null);
const usernameErr = username.trim().length < 3 ? "用户名至少 3 位" : "";
const passwordErr = password.length < 6 ? "密码至少 6 位" : "";
useEffect(() => {
if (readToken()) {
router.replace("/problems");
return;
}
setCheckingAuth(false);
}, [router]);
const usernameErr = username.trim().length < 3 ? tx("用户名至少 3 位", "Username must be at least 3 chars") : "";
const passwordErr = password.length < 6 ? tx("密码至少 6 位", "Password must be at least 6 chars") : "";
const confirmErr =
mode === "register" && password !== confirmPassword ? "两次密码不一致" : "";
mode === "register" && password !== confirmPassword ? tx("两次密码不一致", "Passwords do not match") : "";
const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr;
@@ -49,7 +60,7 @@ export default function AuthPage() {
if (j.ok) {
saveToken(j.token);
setTimeout(() => {
router.push("/problems");
router.replace("/problems");
}, 350);
}
} catch (e: unknown) {
@@ -59,20 +70,28 @@ export default function AuthPage() {
}
}
const strength = passwordScore(password);
const strength = passwordScore(password, isZh);
if (checkingAuth) {
return (
<main className="mx-auto max-w-4xl px-3 py-12 text-sm text-zinc-500">
{tx("已登录,正在跳转...", "Already signed in, redirecting...")}
</main>
);
}
return (
<main className="mx-auto max-w-4xl px-6 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]">
<section className="rounded-2xl border bg-zinc-900 p-6 text-zinc-100">
<h1 className="text-2xl font-semibold"></h1>
<h1 className="text-2xl font-semibold">{tx("欢迎回来,开始刷题", "Welcome back, let's practice")}</h1>
<p className="mt-3 text-sm text-zinc-300">
稿
{tx("登录后可提交评测、保存草稿、查看错题本和个人进度。", "After sign-in you can submit, save drafts, review wrong-book, and track your progress.")}
</p>
<div className="mt-6 space-y-2 text-sm text-zinc-300">
<p> CSP-J / CSP-S / NOIP </p>
<p> 稿</p>
<p> </p>
<p>{tx("• 题库按 CSP-J / CSP-S / NOIP 入门组织", "• Problem sets are organized by CSP-J / CSP-S / NOIP junior")}</p>
<p>{tx("• 题目页支持本地草稿与试运行", "• Problem page supports local draft and run")}</p>
<p>{tx("• 生成式题解会异步入库,支持多解法", "• Generated solutions are queued asynchronously with multiple methods")}</p>
</div>
<p className="mt-6 text-xs text-zinc-400">
API Base: <span className="font-mono">{apiBase}</span>
@@ -92,7 +111,7 @@ export default function AuthPage() {
}}
disabled={loading}
>
{tx("登录", "Sign In")}
</button>
<button
type="button"
@@ -105,46 +124,46 @@ export default function AuthPage() {
}}
disabled={loading}
>
{tx("注册", "Register")}
</button>
</div>
<div className="mt-5 space-y-4">
<div>
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{tx("用户名", "Username")}</label>
<input
className="mt-1 w-full rounded-lg border px-3 py-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="例如csp_student"
placeholder={tx("例如csp_student", "e.g. csp_student")}
/>
{usernameErr && <p className="mt-1 text-xs text-red-600">{usernameErr}</p>}
</div>
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium"></label>
<span className={`text-xs ${strength.color}`}>{strength.label}</span>
<label className="text-sm font-medium">{tx("密码", "Password")}</label>
<span className={`text-xs ${strength.color}`}>{tx("强度", "Strength")}: {strength.label}</span>
</div>
<input
type={showPassword ? "text" : "password"}
className="mt-1 w-full rounded-lg border px-3 py-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 6 位"
placeholder={tx("至少 6 位", "At least 6 chars")}
/>
{passwordErr && <p className="mt-1 text-xs text-red-600">{passwordErr}</p>}
</div>
{mode === "register" && (
<div>
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{tx("确认密码", "Confirm Password")}</label>
<input
type={showPassword ? "text" : "password"}
className="mt-1 w-full rounded-lg border px-3 py-2"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再输入一次密码"
placeholder={tx("再输入一次密码", "Enter password again")}
/>
{confirmErr && <p className="mt-1 text-xs text-red-600">{confirmErr}</p>}
</div>
@@ -156,7 +175,7 @@ export default function AuthPage() {
checked={showPassword}
onChange={(e) => setShowPassword(e.target.checked)}
/>
{tx("显示密码", "Show password")}
</label>
<button
@@ -164,7 +183,7 @@ export default function AuthPage() {
onClick={() => void submit()}
disabled={!canSubmit}
>
{loading ? "提交中..." : mode === "register" ? "注册并登录" : "登录"}
{loading ? tx("提交中...", "Submitting...") : mode === "register" ? tx("注册并登录", "Register & Sign In") : tx("登录", "Sign In")}
</button>
</div>
@@ -175,21 +194,21 @@ export default function AuthPage() {
}`}
>
{resp.ok
? "登录成功,正在跳转到题库..."
: `操作失败:${resp.error}`}
? tx("登录成功,正在跳转到题库...", "Signed in. Redirecting to problem set...")
: `${tx("操作失败:", "Action failed: ")}${resp.error}`}
</div>
)}
<p className="mt-4 text-xs text-zinc-500">
Token localStorage
{tx("登录后 Token 自动保存在浏览器 localStorage,可直接前往", "Token is stored in browser localStorage after sign-in. You can go to")}
<Link className="mx-1 underline" href="/problems">
{tx("题库", "Problems")}
</Link>
{tx("与", "and")}
<Link className="mx-1 underline" href="/me">
{tx("我的", "My Account")}
</Link>
{tx("页面。", ".")}
</p>
</section>
</div>

查看文件

@@ -0,0 +1,383 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type BackendLogItem = {
id: number;
problem_id: number;
problem_title: string;
status: string;
progress: number;
message: string;
created_by: number;
max_solutions: number;
created_at: number;
started_at: number | null;
finished_at: number | null;
updated_at: number;
runner_pending: boolean;
};
type QueueJobItem = {
id: number;
problem_id: number;
problem_title: string;
status: string;
progress: number;
message: string;
updated_at: number;
started_at?: number | null;
};
type BackendLogsResp = {
items: BackendLogItem[];
running_jobs: QueueJobItem[];
queued_jobs: QueueJobItem[];
running_problem_ids: number[];
queued_problem_ids: number[];
running_count: number;
queued_count_preview: number;
pending_jobs: number;
missing_problems: number;
limit: number;
running_limit: number;
queued_limit: number;
};
type TriggerMissingResp = {
started: boolean;
missing_total: number;
candidate_count: number;
queued_count: number;
pending_jobs: number;
limit: number;
max_solutions: number;
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function BackendLogsPage() {
const { tx } = useI18nText();
const [token, setToken] = useState("");
const [checkingAdmin, setCheckingAdmin] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [limit, setLimit] = useState(100);
const [pendingJobs, setPendingJobs] = useState(0);
const [missingProblems, setMissingProblems] = useState(0);
const [items, setItems] = useState<BackendLogItem[]>([]);
const [runningJobs, setRunningJobs] = useState<QueueJobItem[]>([]);
const [queuedJobs, setQueuedJobs] = useState<QueueJobItem[]>([]);
const [runningIds, setRunningIds] = useState<number[]>([]);
const [queuedIds, setQueuedIds] = useState<number[]>([]);
const [triggerLoading, setTriggerLoading] = useState(false);
const [triggerMsg, setTriggerMsg] = useState("");
const refresh = async () => {
if (!isAdmin || !token) return;
setLoading(true);
setError("");
try {
const data = await apiFetch<BackendLogsResp>(
`/api/v1/backend/logs?limit=${limit}&running_limit=20&queued_limit=100`,
{},
token
);
setPendingJobs(data.pending_jobs ?? 0);
setMissingProblems(data.missing_problems ?? 0);
setItems(data.items ?? []);
setRunningJobs(data.running_jobs ?? []);
setQueuedJobs(data.queued_jobs ?? []);
setRunningIds(data.running_problem_ids ?? []);
setQueuedIds(data.queued_problem_ids ?? []);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
let canceled = false;
const checkAdmin = async () => {
setCheckingAdmin(true);
const tk = readToken();
if (!canceled) setToken(tk);
if (!tk) {
if (!canceled) {
setIsAdmin(false);
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
setCheckingAdmin(false);
}
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, tk);
const allowed = (me?.username ?? "") === "admin";
if (!canceled) {
setIsAdmin(allowed);
if (!allowed) {
setError(tx("仅管理员可查看后台日志", "Backend logs are visible to admin only"));
} else {
setError("");
}
}
} catch (e: unknown) {
if (!canceled) {
setIsAdmin(false);
setError(String(e));
}
} finally {
if (!canceled) setCheckingAdmin(false);
}
};
void checkAdmin();
return () => {
canceled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!isAdmin || !token) return;
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin, token, limit]);
const triggerMissingSolutions = async () => {
if (!isAdmin || !token) {
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
return;
}
setTriggerLoading(true);
setTriggerMsg("");
setError("");
try {
const data = await apiFetch<TriggerMissingResp>(
"/api/v1/backend/solutions/generate-missing",
{
method: "POST",
body: JSON.stringify({ limit: 50000, max_solutions: 3 }),
},
token
);
setPendingJobs(data.pending_jobs ?? 0);
setTriggerMsg(
tx(
`已触发异步任务:候选 ${data.candidate_count} 题,入队 ${data.queued_count} 题(当前待处理 ${data.pending_jobs})。`,
`Async trigger submitted: candidate ${data.candidate_count}, queued ${data.queued_count} (pending ${data.pending_jobs}).`
)
);
await refresh();
} catch (e: unknown) {
setError(String(e));
} finally {
setTriggerLoading(false);
}
};
useEffect(() => {
if (!isAdmin || !token) return;
const timer = setInterval(() => {
void refresh();
}, 5000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin, token, limit]);
if (checkingAdmin) {
return (
<main className="mx-auto max-w-7xl px-3 py-8 text-sm text-zinc-600">
{tx("正在校验管理员权限...", "Checking admin access...")}
</main>
);
}
if (!isAdmin) {
return (
<main className="mx-auto max-w-5xl px-3 py-8">
<h1 className="text-xl font-semibold">{tx("后台日志(题解异步队列)", "Backend Logs (Async Solution Queue)")}</h1>
<p className="mt-3 text-sm text-red-600">
{error || tx("仅管理员可查看此页面", "This page is available for admin only")}
</p>
</main>
);
}
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">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("后台日志(题解异步队列)", "Backend Logs (Async Solution Queue)")}
</h1>
<div className="flex w-full flex-wrap items-center gap-2 text-sm sm:w-auto sm:justify-end">
<span className={pendingJobs > 0 ? "text-emerald-700" : "text-zinc-600"}>
{tx("待处理任务", "Pending jobs")} {pendingJobs}
</span>
<span className={missingProblems > 0 ? "text-amber-700" : "text-zinc-600"}>
{tx("缺失答案题目", "Problems missing answers")} {missingProblems}
</span>
<button
className="rounded border px-3 py-1 disabled:opacity-50"
onClick={() => void triggerMissingSolutions()}
disabled={triggerLoading}
>
{triggerLoading ? tx("手动补全中...", "Triggering...") : tx("手动补全(可选)", "Manual fill (optional)")}
</button>
<select
className="rounded border px-2 py-1"
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
>
<option value={50}>{tx("最近 50 条", "Latest 50")}</option>
<option value={100}>{tx("最近 100 条", "Latest 100")}</option>
<option value={200}>{tx("最近 200 条", "Latest 200")}</option>
</select>
<button className="rounded border px-3 py-1 sm:ml-auto" onClick={() => void refresh()} disabled={loading}>
{tx("刷新", "Refresh")}
</button>
</div>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{triggerMsg && <p className="mt-3 text-sm text-emerald-700">{triggerMsg}</p>}
<p className="mt-3 text-xs text-zinc-500">
{tx(
"系统已自动单线程异步处理待队列任务,无需手工点击;上方按钮仅用于立即手动补全。",
"System auto-processes queued jobs in single-thread async mode; the button above is only for manual trigger."
)}
</p>
<section className="mt-4 grid gap-3 md:grid-cols-2">
<article className="rounded-xl border bg-white p-3">
<h2 className="text-sm font-medium">{tx("正在处理Running", "Running Jobs")}</h2>
<p className="mt-1 text-xs text-zinc-600">
{tx("当前题目 ID", "Current problem IDs:")}
{runningIds.length ? runningIds.join(", ") : tx("无", "None")}
</p>
<ul className="mt-2 space-y-2 text-xs">
{runningJobs.map((job) => (
<li key={job.id} className="rounded border border-zinc-200 p-2">
<p>
{tx("任务", "Job")} #{job.id} · {tx("题目", "Problem")} #{job.problem_id} {job.problem_title || tx("(未命名题目)", "(Untitled)")}
</p>
<p className="text-zinc-600">
{tx("状态", "Status")} {job.status} · {tx("进度", "Progress")} {job.progress}% · {tx("开始", "Start")} {fmtTs(job.started_at ?? null)}
</p>
<p className="whitespace-pre-wrap break-words text-zinc-600">{job.message || "-"}</p>
</li>
))}
{!runningJobs.length && <li className="text-zinc-500">{tx("当前无运行中的任务", "No running jobs")}</li>}
</ul>
</article>
<article className="rounded-xl border bg-white p-3">
<h2 className="text-sm font-medium">{tx("待处理队列Queued", "Queued Jobs")}</h2>
<p className="mt-1 text-xs text-zinc-600">
{tx("待处理题目 ID预览", "Queued problem IDs (preview):")}
{queuedIds.length ? queuedIds.join(", ") : tx("无", "None")}
</p>
<ul className="mt-2 max-h-56 space-y-2 overflow-auto text-xs">
{queuedJobs.map((job) => (
<li key={job.id} className="rounded border border-zinc-200 p-2">
<p>
{tx("任务", "Job")} #{job.id} · {tx("题目", "Problem")} #{job.problem_id} {job.problem_title || tx("(未命名题目)", "(Untitled)")}
</p>
<p className="text-zinc-600">
{tx("状态", "Status")} {job.status} · {tx("进度", "Progress")} {job.progress}% · {tx("更新", "Updated")} {fmtTs(job.updated_at)}
</p>
<p className="whitespace-pre-wrap break-words text-zinc-600">{job.message || "-"}</p>
</li>
))}
{!queuedJobs.length && <li className="text-zinc-500">{tx("当前无待处理任务", "No queued jobs")}</li>}
</ul>
</article>
</section>
<section className="mt-4 rounded-xl border bg-white">
<div className="divide-y md:hidden">
{items.map((item) => (
<article key={item.id} className="space-y-2 p-3 text-xs">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
{tx("任务", "Job")} #{item.id}
</p>
<span className={item.runner_pending ? "text-emerald-700" : "text-zinc-700"}>
{item.status} · {item.progress}%
</span>
</div>
<Link className="text-blue-600 hover:underline" href={`/problems/${item.problem_id}`}>
#{item.problem_id} {item.problem_title || tx("(未命名题目)", "(Untitled)")}
</Link>
<p className="whitespace-pre-wrap break-words text-zinc-600">{item.message || "-"}</p>
<p className="text-zinc-500">
{tx("创建", "Created")} {fmtTs(item.created_at)} · {tx("开始", "Start")} {fmtTs(item.started_at)} · {tx("结束", "End")} {fmtTs(item.finished_at)}
</p>
</article>
))}
{!loading && items.length === 0 && (
<p className="px-3 py-6 text-center text-sm text-zinc-500">{tx("暂无后台任务日志", "No backend logs yet")}</p>
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-xs">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-2 py-2">{tx("任务ID", "Job ID")}</th>
<th className="px-2 py-2">{tx("题目", "Problem")}</th>
<th className="px-2 py-2">{tx("状态", "Status")}</th>
<th className="px-2 py-2">{tx("进度", "Progress")}</th>
<th className="px-2 py-2">{tx("消息", "Message")}</th>
<th className="px-2 py-2">{tx("时间", "Time")}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t align-top">
<td className="px-2 py-2 font-medium">{item.id}</td>
<td className="max-w-[260px] px-2 py-2">
<Link className="text-blue-600 hover:underline" href={`/problems/${item.problem_id}`}>
#{item.problem_id} {item.problem_title || tx("(未命名题目)", "(Untitled)")}
</Link>
</td>
<td className="px-2 py-2">
<span className={item.runner_pending ? "text-emerald-700" : "text-zinc-700"}>
{item.status}
</span>
</td>
<td className="px-2 py-2">{item.progress}%</td>
<td className="max-w-[420px] px-2 py-2">
<div className="whitespace-pre-wrap break-words">{item.message || "-"}</div>
</td>
<td className="px-2 py-2 text-zinc-600">
{tx("创建", "Created")} {fmtTs(item.created_at)}
<br />
{tx("开始", "Start")} {fmtTs(item.started_at)}
<br />
{tx("结束", "End")} {fmtTs(item.finished_at)}
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td className="px-2 py-6 text-center text-zinc-500" colSpan={6}>
{tx("暂无后台任务日志", "No backend logs yet")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</main>
);
}

查看文件

@@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type Contest = {
id: number;
@@ -35,6 +36,7 @@ type DetailResp = {
};
export default function ContestDetailPage() {
const { tx } = useI18nText();
const params = useParams<{ id: string }>();
const contestId = useMemo(() => Number(params.id), [params.id]);
@@ -69,7 +71,7 @@ export default function ContestDetailPage() {
const register = async () => {
try {
const token = readToken();
if (!token) throw new Error("请先登录");
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
await apiFetch(`/api/v1/contests/${contestId}/register`, { method: "POST" }, token);
await load();
} catch (e: unknown) {
@@ -78,9 +80,11 @@ export default function ContestDetailPage() {
};
return (
<main className="mx-auto max-w-6xl px-6 py-8">
<h1 className="text-2xl font-semibold"> #{contestId}</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("比赛详情", "Contest Detail")} #{contestId}
</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{detail && (
@@ -91,24 +95,29 @@ export default function ContestDetailPage() {
{new Date(detail.contest.starts_at * 1000).toLocaleString()} - {" "}
{new Date(detail.contest.ends_at * 1000).toLocaleString()}
</p>
<pre className="mt-3 rounded bg-zinc-900 p-3 text-xs text-zinc-100">
<pre className="mt-3 overflow-x-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{detail.contest.rule_json}
</pre>
<button
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-white"
className="mt-3 w-full rounded bg-zinc-900 px-4 py-2 text-white sm:w-auto"
onClick={() => void register()}
>
{detail.registered ? "已报名(可重复点击刷新)" : "报名比赛"}
{detail.registered
? tx("已报名(可重复点击刷新)", "Registered (click again to refresh)")
: tx("报名比赛", "Register Contest")}
</button>
<h3 className="mt-4 text-sm font-medium"></h3>
<h3 className="mt-4 text-sm font-medium">{tx("比赛题目", "Contest Problems")}</h3>
<ul className="mt-2 space-y-2 text-sm">
{detail.problems.map((p) => (
<li key={p.id} className="rounded border p-2">
#{p.id} {p.title} {p.difficulty}
<Link className="ml-2 text-blue-600 underline" href={`/problems/${p.id}`}>
#{p.id} {p.title}
{tx("(难度 ", " (Difficulty ")}
{p.difficulty}
{tx("", ")")}
<Link className="mt-1 block text-blue-600 underline sm:ml-2 sm:mt-0 sm:inline" href={`/problems/${p.id}`}>
{tx("去提交", "Submit")}
</Link>
</li>
))}
@@ -116,28 +125,55 @@ export default function ContestDetailPage() {
</section>
<section className="rounded-xl border bg-white p-4">
<h3 className="text-sm font-medium"></h3>
<div className="mt-2 overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-2 py-1">#</th>
<th className="px-2 py-1"></th>
<th className="px-2 py-1">Solved</th>
<th className="px-2 py-1">Penalty(s)</th>
</tr>
</thead>
<tbody>
{board.map((r, idx) => (
<tr key={r.user_id} className="border-t">
<td className="px-2 py-1">{idx + 1}</td>
<td className="px-2 py-1">{r.username}</td>
<td className="px-2 py-1">{r.solved}</td>
<td className="px-2 py-1">{r.penalty_sec}</td>
<h3 className="text-sm font-medium">{tx("排行榜", "Leaderboard")}</h3>
<div className="mt-2 rounded-lg border">
<div className="divide-y md:hidden">
{board.map((r, idx) => (
<article key={r.user_id} className="space-y-1 p-3 text-sm">
<p className="font-medium">
#{idx + 1} · {r.username}
</p>
<p className="text-xs text-zinc-600">
Solved {r.solved} · Penalty {r.penalty_sec}s
</p>
</article>
))}
{!loading && board.length === 0 && (
<p className="px-3 py-5 text-center text-sm text-zinc-500">
{tx("暂无榜单数据", "No leaderboard data yet")}
</p>
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-2 py-1">#</th>
<th className="px-2 py-1">{tx("用户", "User")}</th>
<th className="px-2 py-1">Solved</th>
<th className="px-2 py-1">Penalty(s)</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{board.map((r, idx) => (
<tr key={r.user_id} className="border-t">
<td className="px-2 py-1">{idx + 1}</td>
<td className="px-2 py-1">{r.username}</td>
<td className="px-2 py-1">{r.solved}</td>
<td className="px-2 py-1">{r.penalty_sec}</td>
</tr>
))}
{!loading && board.length === 0 && (
<tr>
<td className="px-2 py-5 text-center text-zinc-500" colSpan={4}>
{tx("暂无榜单数据", "No leaderboard data yet")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
</div>

查看文件

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type Contest = {
id: number;
@@ -14,6 +15,7 @@ type Contest = {
};
export default function ContestsPage() {
const { tx } = useI18nText();
const [items, setItems] = useState<Contest[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -35,9 +37,11 @@ export default function ContestsPage() {
}, []);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("模拟竞赛", "Contests")}
</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 space-y-3">
@@ -48,8 +52,8 @@ export default function ContestsPage() {
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
>
<h2 className="text-lg font-medium">{c.title}</h2>
<p className="mt-1 text-xs text-zinc-500">: {new Date(c.starts_at * 1000).toLocaleString()}</p>
<p className="text-xs text-zinc-500">: {new Date(c.ends_at * 1000).toLocaleString()}</p>
<p className="mt-1 text-xs text-zinc-500">{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}</p>
<p className="text-xs text-zinc-500">{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}</p>
</Link>
))}
</div>

查看文件

@@ -1,8 +1,12 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--background: #fff;
--foreground: #171717;
--surface: #fff;
--surface-soft: #f4f4f5;
--border: #d4d4d8;
-webkit-text-size-adjust: 100%;
}
@theme inline {
@@ -13,15 +17,55 @@
"Courier New", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
.problem-markdown-compact {
font-size: 70%;
line-height: 1.55;
}
.problem-markdown-compact pre,
.problem-markdown-compact code {
font-size: 1em;
}
.print-only {
display: none;
}
@media print {
body {
background: #fff !important;
color: #000 !important;
}
.print-hidden {
display: none !important;
}
.print-only {
display: block !important;
}
.problem-detail-grid {
display: block !important;
}
.problem-print-section {
border: 0 !important;
background: transparent !important;
padding: 0 !important;
}
.problem-print-section pre {
border: 1px solid #d4d4d8 !important;
background: #f4f4f5 !important;
color: #111827 !important;
}
}

查看文件

@@ -1,8 +1,11 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type ImportJob = {
id: number;
@@ -46,19 +49,50 @@ type ItemsResp = {
page_size: number;
};
type MeProfile = {
username?: string;
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
type ImportJobOptions = {
mode?: string;
source?: string;
workers?: number;
target_total?: number;
local_pdf_dir?: string;
pdf_dir?: string;
};
function parseOptions(raw: string): ImportJobOptions | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return typeof parsed === "object" && parsed !== null ? (parsed as ImportJobOptions) : null;
} catch {
return null;
}
}
export default function ImportsPage() {
const { tx } = useI18nText();
const [token, setToken] = useState("");
const [checkingAdmin, setCheckingAdmin] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [runMode, setRunMode] = useState<"luogu" | "local_pdf_rag">("luogu");
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [job, setJob] = useState<ImportJob | null>(null);
const [items, setItems] = useState<ImportItem[]>([]);
const [statusFilter, setStatusFilter] = useState("");
const [pageSize, setPageSize] = useState(100);
const [workers, setWorkers] = useState(3);
const [localPdfDir, setLocalPdfDir] = useState("/data/local_pdfs");
const [targetTotal, setTargetTotal] = useState(5000);
const [pageSize, setPageSize] = useState(50);
const [clearAllBeforeRun, setClearAllBeforeRun] = useState(true);
const progress = useMemo(() => {
@@ -66,29 +100,30 @@ export default function ImportsPage() {
return Math.min(100, Math.floor((job.processed_count / job.total_count) * 100));
}, [job]);
const loadLatest = async () => {
const latest = await apiFetch<LatestResp>("/api/v1/import/jobs/latest");
const loadLatest = async (tk: string) => {
const latest = await apiFetch<LatestResp>("/api/v1/import/jobs/latest", {}, tk);
setJob(latest.job ?? null);
setRunning(Boolean(latest.runner_running) || latest.job?.status === "running");
return latest.job;
};
const loadItems = async (jobId: number) => {
const loadItems = async (tk: string, jobId: number) => {
const params = new URLSearchParams();
params.set("page", "1");
params.set("page_size", String(pageSize));
if (statusFilter) params.set("status", statusFilter);
const data = await apiFetch<ItemsResp>(`/api/v1/import/jobs/${jobId}/items?${params.toString()}`);
const data = await apiFetch<ItemsResp>(`/api/v1/import/jobs/${jobId}/items?${params.toString()}`, {}, tk);
setItems(data.items ?? []);
};
const refresh = async () => {
if (!isAdmin || !token) return;
setLoading(true);
setError("");
try {
const latestJob = await loadLatest();
const latestJob = await loadLatest(token);
if (latestJob) {
await loadItems(latestJob.id);
await loadItems(token, latestJob.id);
} else {
setItems([]);
}
@@ -100,159 +135,369 @@ export default function ImportsPage() {
};
const runImport = async () => {
if (!isAdmin || !token) {
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
return;
}
setError("");
try {
await apiFetch<{ started: boolean }>("/api/v1/import/jobs/run", {
method: "POST",
body: JSON.stringify({ clear_all_problems: clearAllBeforeRun }),
});
const body: Record<string, unknown> = {
mode: runMode,
workers,
};
if (runMode === "luogu") {
body.clear_all_problems = clearAllBeforeRun;
} else {
body.local_pdf_dir = localPdfDir;
body.target_total = targetTotal;
}
await apiFetch<{ started: boolean }>(
"/api/v1/import/jobs/run",
{
method: "POST",
body: JSON.stringify(body),
},
token
);
await refresh();
} catch (e: unknown) {
setError(String(e));
}
};
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, statusFilter]);
const jobOpts = useMemo(() => parseOptions(job?.options_json ?? ""), [job?.options_json]);
useEffect(() => {
let canceled = false;
const init = async () => {
setCheckingAdmin(true);
const tk = readToken();
setToken(tk);
if (!tk) {
if (!canceled) {
setIsAdmin(false);
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
setCheckingAdmin(false);
}
return;
}
try {
const me = await apiFetch<MeProfile>("/api/v1/me", {}, tk);
const allowed = (me?.username ?? "") === "admin";
if (!canceled) {
setIsAdmin(allowed);
if (!allowed) {
setError(tx("仅管理员可查看平台管理信息", "Platform management is visible to admin only"));
} else {
setError("");
}
}
} catch (e: unknown) {
if (!canceled) {
setIsAdmin(false);
setError(String(e));
}
} finally {
if (!canceled) setCheckingAdmin(false);
}
};
void init();
return () => {
canceled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!isAdmin || !token) return;
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin, token, pageSize, statusFilter]);
useEffect(() => {
if (!isAdmin || !token) return;
const timer = setInterval(() => {
void refresh();
}, running ? 3000 : 15000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [running, pageSize, statusFilter]);
}, [isAdmin, token, running, pageSize, statusFilter]);
if (checkingAdmin) {
return (
<main className="mx-auto max-w-7xl px-3 py-8 text-sm text-zinc-600">
{tx("正在校验管理员权限...", "Checking admin access...")}
</main>
);
}
if (!isAdmin) {
return (
<main className="mx-auto max-w-4xl px-3 py-8">
<h1 className="text-xl font-semibold">{tx("平台管理", "Platform Management")}</h1>
<p className="mt-3 text-sm text-red-600">
{error || tx("仅管理员可查看此页面", "This page is available for admin only")}
</p>
<div className="mt-4 flex flex-wrap gap-2 text-sm">
<Link className="rounded border bg-white px-3 py-2 hover:bg-zinc-50" href="/auth">
{tx("去登录", "Go to Sign In")}
</Link>
<Link className="rounded border bg-white px-3 py-2 hover:bg-zinc-50" href="/">
{tx("返回首页", "Back to Home")}
</Link>
</div>
</main>
);
}
return (
<main className="mx-auto max-w-7xl px-6 py-8">
<h1 className="text-2xl font-semibold">Luogu CSP J/S</h1>
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("题库导入/出题任务", "Import / Generation Jobs")}
</h1>
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-base font-medium">{tx("平台管理快捷入口(原 /admin139", "Platform Shortcuts (moved from /admin139)")}</h2>
<p className="mt-1 text-xs text-zinc-600">
{tx("默认管理员账号admin / whoami139", "Default admin account: admin / whoami139")}
</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/auth">
{tx("登录入口", "Sign In")}
</Link>
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/admin-users">
{tx("用户积分管理", "User Rating")}
</Link>
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/admin-redeem">
{tx("积分兑换管理", "Redeem Config")}
</Link>
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/backend-logs">
{tx("后台日志队列", "Backend Logs")}
</Link>
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/api-docs">
{tx("API 文档", "API Docs")}
</Link>
</div>
</section>
<div className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
<select
className="w-full rounded border px-2 py-2 text-sm sm:w-auto"
value={runMode}
onChange={(e) => setRunMode((e.target.value as "luogu" | "local_pdf_rag") ?? "luogu")}
disabled={loading || running}
>
<option value="luogu">{tx("来源Luogu CSP J/S", "Source: Luogu CSP J/S")}</option>
<option value="local_pdf_rag">{tx("来源:本地 PDF + RAG + LLM 出题", "Source: Local PDF + RAG + LLM")}</option>
</select>
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
{tx("线程", "Workers")}
<input
type="number"
min={1}
max={16}
className="w-full rounded border px-2 py-1 sm:w-20"
value={workers}
onChange={(e) => setWorkers(Math.max(1, Math.min(16, Number(e.target.value) || 1)))}
disabled={loading || running}
/>
</label>
{runMode === "local_pdf_rag" && (
<>
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
{tx("PDF目录", "PDF Dir")}
<input
className="w-full rounded border px-2 py-1 sm:w-64"
value={localPdfDir}
onChange={(e) => setLocalPdfDir(e.target.value)}
disabled={loading || running}
/>
</label>
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
{tx("目标题量", "Target Total")}
<input
type="number"
min={1}
max={50000}
className="w-full rounded border px-2 py-1 sm:w-28"
value={targetTotal}
onChange={(e) =>
setTargetTotal(Math.max(1, Math.min(50000, Number(e.target.value) || 1)))
}
disabled={loading || running}
/>
</label>
</>
)}
<button
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
className="w-full rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50 sm:ml-auto sm:w-auto"
onClick={() => void runImport()}
disabled={loading || running}
>
{running ? "导入中..." : "启动导入任务"}
{running ? tx("导入中...", "Importing...") : tx("启动导入任务", "Start Import Job")}
</button>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={clearAllBeforeRun}
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
/>
</label>
{runMode === "luogu" && (
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
<input
type="checkbox"
checked={clearAllBeforeRun}
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
/>
{tx("启动前清空历史题库", "Clear old problem set before start")}
</label>
)}
<button className="rounded border px-3 py-2 text-sm" onClick={() => void refresh()} disabled={loading}>
{tx("刷新", "Refresh")}
</button>
<span className={`text-sm ${running ? "text-emerald-700" : "text-zinc-600"}`}>
{running ? "运行中" : "空闲"}
{running ? tx("运行中", "Running") : tx("空闲", "Idle")}
</span>
</div>
<p className="mt-2 text-xs text-zinc-500">
3 线 CSP-J/CSP-S/NOIP
</p>
{runMode === "luogu" && (
<p className="mt-2 text-xs text-zinc-500">
{tx(
"抓取洛谷 CSP-J/CSP-S/NOIP 标签题;容器重启后可自动触发(可通过环境变量关闭)。",
"Fetch Luogu problems tagged CSP-J/CSP-S/NOIP. It can auto-start after container restart (configurable via env)."
)}
</p>
)}
{runMode === "local_pdf_rag" && (
<p className="mt-2 text-xs text-zinc-500">
{tx(
"从本地 PDF 提取文本做 RAG,调用 LLM 生成 CSP-J/S 题目,按现有题库难度分布补齐到目标题量并自动去重跳过。",
"Extract text from local PDFs for RAG, then call LLM to generate CSP-J/S problems with dedupe and target distribution."
)}
</p>
)}
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-lg font-medium"></h2>
{!job && <p className="mt-2 text-sm text-zinc-500"></p>}
<h2 className="text-lg font-medium">{tx("最新任务", "Latest Job")}</h2>
{!job && <p className="mt-2 text-sm text-zinc-500">{tx("暂无任务记录", "No job records")}</p>}
{job && (
<div className="mt-3 space-y-2 text-sm">
<p>
#{job.id} · <b>{job.status}</b> · {job.trigger}
{tx("任务", "Job")} #{job.id} · {tx("状态", "Status")} <b>{job.status}</b> · {tx("触发方式", "Trigger")} {job.trigger}
</p>
<p className="text-zinc-600">
{tx("模式", "Mode")} {jobOpts?.mode || jobOpts?.source || "luogu"} · {tx("线程", "Workers")} {jobOpts?.workers ?? "-"}
{typeof jobOpts?.target_total === "number" && ` · ${tx("目标题量", "Target total")} ${jobOpts.target_total}`}
{(jobOpts?.local_pdf_dir || jobOpts?.pdf_dir) &&
` · ${tx("PDF目录", "PDF dir")} ${jobOpts.local_pdf_dir || jobOpts.pdf_dir}`}
</p>
<p>
{job.total_count} {job.processed_count} {job.success_count} {job.failed_count}
{tx("总数", "Total")} {job.total_count}{tx("已处理", "Processed")} {job.processed_count}{tx("成功", "Success")} {job.success_count}{tx("失败", "Failed")} {job.failed_count}
</p>
<div className="h-2 w-full rounded bg-zinc-100">
<div className="h-2 rounded bg-emerald-500" style={{ width: `${progress}%` }} />
</div>
<p className="text-zinc-600">
{progress}% · {fmtTs(job.started_at)} · {fmtTs(job.finished_at)}
{tx("进度", "Progress")} {progress}% · {tx("开始", "Start")} {fmtTs(job.started_at)} · {tx("结束", "End")} {fmtTs(job.finished_at)}
</p>
{job.last_error && <p className="text-red-600">{job.last_error}</p>}
{job.last_error && <p className="text-red-600">{tx("最近错误:", "Latest error: ")}{job.last_error}</p>}
</div>
)}
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-lg font-medium"></h2>
<h2 className="text-lg font-medium">{tx("任务明细", "Job Items")}</h2>
<select
className="rounded border px-2 py-1 text-sm"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value=""></option>
<option value="">{tx("全部状态", "All Status")}</option>
<option value="queued">queued</option>
<option value="running">running</option>
<option value="success">success</option>
<option value="failed">failed</option>
<option value="skipped">skipped</option>
<option value="interrupted">interrupted</option>
</select>
<select
className="rounded border px-2 py-1 text-sm"
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
>
<option value={50}>50 </option>
<option value={100}>100 </option>
<option value={200}>200 </option>
<option value={50}>{tx("50 条", "50 rows")}</option>
<option value={100}>{tx("100 条", "100 rows")}</option>
<option value={200}>{tx("200 条", "200 rows")}</option>
</select>
</div>
<div className="mt-3 overflow-x-auto">
<table className="min-w-full text-xs">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-2 py-2">ID</th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2">ID</th>
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t align-top">
<td className="px-2 py-2">{item.id}</td>
<td className="max-w-[400px] px-2 py-2">
<div className="truncate" title={item.source_path}>
{item.source_path}
</div>
</td>
<td className="px-2 py-2">{item.status}</td>
<td className="max-w-[220px] px-2 py-2">
<div className="truncate" title={item.title}>
{item.title || "-"}
</div>
</td>
<td className="px-2 py-2">{item.difficulty || "-"}</td>
<td className="px-2 py-2">{item.problem_id ?? "-"}</td>
<td className="max-w-[320px] px-2 py-2 text-red-600">
<div className="truncate" title={item.error_text}>
{item.error_text || "-"}
</div>
</td>
</tr>
))}
{items.length === 0 && (
<div className="mt-3 rounded-lg border">
<div className="divide-y md:hidden">
{items.map((item) => (
<article key={item.id} className="space-y-1 p-3 text-xs">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
{tx("明细", "Detail")} #{item.id}
</p>
<span>{item.status}</span>
</div>
<p className="break-all text-zinc-600">{tx("路径:", "Path: ")}{item.source_path}</p>
<p className="text-zinc-600">{tx("标题:", "Title: ")}{item.title || "-"}</p>
<p className="text-zinc-600">
{tx("难度:", "Difficulty: ")}{item.difficulty || "-"} · {tx("题目ID", "Problem ID: ")}{item.problem_id ?? "-"}
</p>
{item.error_text && <p className="break-words text-red-600">{tx("错误:", "Error: ")}{item.error_text}</p>}
</article>
))}
{items.length === 0 && <p className="px-3 py-4 text-center text-sm text-zinc-500">{tx("暂无明细", "No details")}</p>}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-xs">
<thead className="bg-zinc-100 text-left">
<tr>
<td className="px-2 py-4 text-center text-zinc-500" colSpan={7}>
</td>
<th className="px-2 py-2">ID</th>
<th className="px-2 py-2">{tx("路径", "Path")}</th>
<th className="px-2 py-2">{tx("状态", "Status")}</th>
<th className="px-2 py-2">{tx("标题", "Title")}</th>
<th className="px-2 py-2">{tx("难度", "Difficulty")}</th>
<th className="px-2 py-2">{tx("题目ID", "Problem ID")}</th>
<th className="px-2 py-2">{tx("错误", "Error")}</th>
</tr>
)}
</tbody>
</table>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t align-top">
<td className="px-2 py-2">{item.id}</td>
<td className="max-w-[400px] px-2 py-2">
<div className="truncate" title={item.source_path}>
{item.source_path}
</div>
</td>
<td className="px-2 py-2">{item.status}</td>
<td className="max-w-[220px] px-2 py-2">
<div className="truncate" title={item.title}>
{item.title || "-"}
</div>
</td>
<td className="px-2 py-2">{item.difficulty || "-"}</td>
<td className="px-2 py-2">{item.problem_id ?? "-"}</td>
<td className="max-w-[320px] px-2 py-2 text-red-600">
<div className="truncate" title={item.error_text}>
{item.error_text || "-"}
</div>
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td className="px-2 py-4 text-center text-zinc-500" colSpan={7}>
{tx("暂无明细", "No details")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
</main>

查看文件

@@ -4,7 +4,9 @@ import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type Article = {
id: number;
@@ -20,6 +22,7 @@ type DetailResp = {
};
export default function KbDetailPage() {
const { tx } = useI18nText();
const params = useParams<{ slug: string }>();
const slug = useMemo(() => params.slug, [params.slug]);
@@ -44,29 +47,41 @@ export default function KbDetailPage() {
}, [slug]);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("知识库文章", "Knowledge Article")}
</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{data && (
<div className="mt-4 space-y-4">
<section className="rounded-xl border bg-white p-4">
<h2 className="text-xl font-medium">{data.article.title}</h2>
<pre className="mt-3 whitespace-pre-wrap text-sm">{data.article.content_md}</pre>
<p className="mt-1 text-xs text-zinc-500">
{tx("更新时间:", "Updated: ")}
{new Date(data.article.created_at * 1000).toLocaleString()}
</p>
<div className="mt-3">
<MarkdownRenderer markdown={data.article.content_md} />
</div>
</section>
<section className="rounded-xl border bg-white p-4">
<h3 className="text-sm font-medium"></h3>
<ul className="mt-2 space-y-2 text-sm">
{data.related_problems.map((p) => (
<li key={p.problem_id}>
<Link className="text-blue-600 underline" href={`/problems/${p.problem_id}`}>
#{p.problem_id} {p.title}
</Link>
</li>
))}
</ul>
<h3 className="text-sm font-medium">{tx("关联题目", "Related Problems")}</h3>
{data.related_problems.length ? (
<ul className="mt-2 space-y-2 text-sm">
{data.related_problems.map((p) => (
<li key={p.problem_id}>
<Link className="text-blue-600 underline" href={`/problems/${p.problem_id}`}>
#{p.problem_id} {p.title}
</Link>
</li>
))}
</ul>
) : (
<p className="mt-2 text-sm text-zinc-500">{tx("暂无关联题目", "No related problems")}</p>
)}
</section>
</div>
)}

查看文件

@@ -1,9 +1,11 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type Article = {
id: number;
@@ -12,47 +14,263 @@ type Article = {
created_at: number;
};
type KbRefreshStatus = {
running: boolean;
last_command: string;
last_trigger: string;
last_exit_code: number | null;
last_started_at: number;
last_finished_at: number;
};
type TriggerKbRefreshResp = KbRefreshStatus & {
started: boolean;
message: string;
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function KbListPage() {
const { tx } = useI18nText();
const [refreshToken, setRefreshToken] = useState("");
const [canManageRefresh, setCanManageRefresh] = useState(false);
const [items, setItems] = useState<Article[]>([]);
const [loading, setLoading] = useState(false);
const [statusLoading, setStatusLoading] = useState(false);
const [triggerLoading, setTriggerLoading] = useState(false);
const [error, setError] = useState("");
const [hint, setHint] = useState("");
const [refreshStatus, setRefreshStatus] = useState<KbRefreshStatus | null>(null);
const [lastSyncedFinishedAt, setLastSyncedFinishedAt] = useState(0);
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const data = await apiFetch<Article[]>("/api/v1/kb/articles");
setItems(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
const grouped = useMemo(() => {
const buckets: Record<string, Article[]> = {
roadmap: [],
cpp: [],
cspj: [],
csps: [],
other: [],
};
void load();
for (const article of items) {
const slug = article.slug.toLowerCase();
if (slug.includes("roadmap")) {
buckets.roadmap.push(article);
} else if (slug.includes("cpp")) {
buckets.cpp.push(article);
} else if (slug.includes("csp-j") || slug.includes("cspj")) {
buckets.cspj.push(article);
} else if (slug.includes("csp-s") || slug.includes("csps")) {
buckets.csps.push(article);
} else {
buckets.other.push(article);
}
}
return buckets;
}, [items]);
const loadArticles = useCallback(async () => {
setLoading(true);
setError("");
try {
const data = await apiFetch<Article[]>("/api/v1/kb/articles");
setItems(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
const loadStatus = useCallback(async () => {
if (!canManageRefresh || !refreshToken) {
setRefreshStatus(null);
return;
}
setStatusLoading(true);
try {
const data = await apiFetch<KbRefreshStatus>("/api/v1/backend/kb/refresh", {}, refreshToken);
setRefreshStatus(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setStatusLoading(false);
}
}, [canManageRefresh, refreshToken]);
<div className="mt-4 space-y-3">
{items.map((a) => (
<Link
key={a.slug}
href={`/kb/${a.slug}`}
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
useEffect(() => {
let canceled = false;
const refreshAdminState = async () => {
const tk = readToken();
if (!tk) {
if (!canceled) {
setRefreshToken("");
setCanManageRefresh(false);
}
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, tk);
if (!canceled) {
const isAdmin = (me?.username ?? "") === "admin";
setCanManageRefresh(isAdmin);
setRefreshToken(isAdmin ? tk : "");
}
} catch {
if (!canceled) {
setRefreshToken("");
setCanManageRefresh(false);
}
}
};
void refreshAdminState();
const onFocus = () => {
void refreshAdminState();
};
window.addEventListener("focus", onFocus);
return () => {
canceled = true;
window.removeEventListener("focus", onFocus);
};
}, []);
useEffect(() => {
void loadArticles();
void loadStatus();
}, [loadArticles, loadStatus]);
useEffect(() => {
const timer = setInterval(() => {
void loadStatus();
}, 5000);
return () => clearInterval(timer);
}, [loadStatus]);
useEffect(() => {
if (!refreshStatus) return;
if (
!refreshStatus.running &&
refreshStatus.last_finished_at > 0 &&
refreshStatus.last_finished_at > lastSyncedFinishedAt
) {
setLastSyncedFinishedAt(refreshStatus.last_finished_at);
void loadArticles();
}
}, [lastSyncedFinishedAt, loadArticles, refreshStatus]);
const triggerRefresh = async () => {
if (!canManageRefresh || !refreshToken) {
setError(tx("仅管理员可执行一键更新资料", "Only admin can trigger material refresh"));
return;
}
setTriggerLoading(true);
setError("");
setHint("");
try {
const data = await apiFetch<TriggerKbRefreshResp>(
"/api/v1/backend/kb/refresh",
{
method: "POST",
body: JSON.stringify({}),
},
refreshToken
);
setRefreshStatus({
running: data.running,
last_command: data.last_command,
last_trigger: data.last_trigger,
last_exit_code: data.last_exit_code,
last_started_at: data.last_started_at,
last_finished_at: data.last_finished_at,
});
setHint(
data.message || (data.started ? tx("已触发异步更新", "Async refresh started") : tx("当前已有任务在运行", "A refresh job is already running"))
);
} catch (e: unknown) {
setError(String(e));
} finally {
setTriggerLoading(false);
}
};
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">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">{tx("学习知识库", "Knowledge Base")}</h1>
{canManageRefresh ? (
<button
className="rounded border px-3 py-1 text-sm disabled:opacity-50"
disabled={triggerLoading || refreshStatus?.running}
onClick={() => void triggerRefresh()}
>
<h2 className="text-lg font-medium">{a.title}</h2>
<p className="mt-1 text-xs text-zinc-500">
slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()}
</p>
</Link>
))}
{triggerLoading
? tx("更新触发中...", "Triggering...")
: refreshStatus?.running
? tx("资料更新中...", "Refreshing...")
: tx("手动一键更新资料", "Manual Refresh")}
</button>
) : (
<span className="text-xs text-zinc-500">
{tx("资料更新由管理员维护", "Material refresh is managed by admin")}
</span>
)}
</div>
<p className="mt-2 text-sm text-zinc-600">
{tx(
"已整理 C++ 基础、CSP-J、CSP-S 学习资料,可按阶段逐步学习。",
"Curated learning materials for C++ fundamentals, CSP-J, and CSP-S."
)}
</p>
<p className="mt-1 text-xs text-zinc-500">
{canManageRefresh
? tx("更新状态:", "Refresh status:")
: tx("更新状态:仅管理员可查看与触发。", "Refresh status: admin only.")}
{canManageRefresh &&
(statusLoading && !refreshStatus
? tx("读取中...", "Loading...")
: refreshStatus?.running
? tx(`运行中(开始于 ${fmtTs(refreshStatus.last_started_at)}`, `Running (started at ${fmtTs(refreshStatus.last_started_at)})`)
: tx(`空闲(最近结束 ${fmtTs(refreshStatus?.last_finished_at ?? null)},退出码 ${
refreshStatus?.last_exit_code ?? "-"
}`, `Idle (last finished ${fmtTs(refreshStatus?.last_finished_at ?? null)}, exit code ${
refreshStatus?.last_exit_code ?? "-"
})`))}
</p>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{hint && <p className="mt-3 text-sm text-emerald-700">{hint}</p>}
<section className="mt-4 space-y-5">
{[
["roadmap", tx("学习总路线", "Learning Roadmap")],
["cpp", tx("C++ 基础", "C++ Fundamentals")],
["cspj", "CSP-J"],
["csps", "CSP-S"],
["other", tx("其他资料", "Other Resources")],
].map(([key, label]) => {
const group = grouped[key] ?? [];
if (!group.length) return null;
return (
<div key={key} className="space-y-3">
<h2 className="text-sm font-semibold text-zinc-700">{label}</h2>
{group.map((a) => (
<Link
key={a.slug}
href={`/kb/${a.slug}`}
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
>
<h3 className="text-lg font-medium">{a.title}</h3>
<p className="mt-1 text-xs text-zinc-500">
slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()}
</p>
</Link>
))}
</div>
);
})}
</section>
</main>
);
}

查看文件

@@ -1,13 +1,17 @@
import type { Metadata } from "next";
import { AppNav } from "@/components/app-nav";
import { MobileTabBar } from "@/components/mobile-tab-bar";
import { UiPreferenceProvider } from "@/components/ui-preference-provider";
import "katex/dist/katex.min.css";
import "highlight.js/styles/github-dark.css";
import "swagger-ui-react/swagger-ui.css";
import "@/themes/default/theme.css";
import "@/themes/minecraft/theme.css";
import "./globals.css";
export const metadata: Metadata = {
title: "CSP 在线学习与竞赛平台",
description: "题库、错题本、模拟竞赛、知识库与在线 C++ 运行",
title: "CSP Online Learning & Contest Platform",
description: "Problems, wrong-book review, contests, knowledge base, and C++ runner.",
};
export default function RootLayout({
@@ -16,10 +20,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<html lang="en" suppressHydrationWarning>
<body className="antialiased">
<AppNav />
{children}
<UiPreferenceProvider>
<AppNav />
<div className="pb-[calc(3.8rem+env(safe-area-inset-bottom))] md:pb-0">{children}</div>
<MobileTabBar />
</UiPreferenceProvider>
</body>
</html>
);

查看文件

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type Row = {
user_id: number;
@@ -12,6 +13,7 @@ type Row = {
};
export default function LeaderboardPage() {
const { tx } = useI18nText();
const [items, setItems] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -33,34 +35,65 @@ export default function LeaderboardPage() {
}, []);
return (
<main className="mx-auto max-w-4xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("全站排行榜", "Global Leaderboard")}
</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 overflow-x-auto rounded-xl border bg-white">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">Rating</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{items.map((row, i) => (
<tr key={row.user_id} className="border-t">
<td className="px-3 py-2">{i + 1}</td>
<td className="px-3 py-2">{row.username}</td>
<td className="px-3 py-2">{row.rating}</td>
<td className="px-3 py-2">
{new Date(row.created_at * 1000).toLocaleString()}
</td>
<div className="mt-4 rounded-xl border bg-white">
<div className="divide-y md:hidden">
{items.map((row, i) => (
<article key={row.user_id} className="space-y-1 p-3 text-sm">
<p className="font-medium">
#{i + 1} · {row.username}
</p>
<p className="text-xs text-zinc-600">Rating: {row.rating}</p>
<p className="text-xs text-zinc-500">
{tx("注册时间:", "Registered: ")}
{new Date(row.created_at * 1000).toLocaleString()}
</p>
</article>
))}
{!loading && items.length === 0 && (
<p className="px-3 py-5 text-center text-sm text-zinc-500">
{tx("暂无排行数据", "No ranking data yet")}
</p>
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2">{tx("排名", "Rank")}</th>
<th className="px-3 py-2">{tx("用户", "User")}</th>
<th className="px-3 py-2">Rating</th>
<th className="px-3 py-2">{tx("注册时间", "Registered At")}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{items.map((row, i) => (
<tr key={row.user_id} className="border-t">
<td className="px-3 py-2">{i + 1}</td>
<td className="px-3 py-2">{row.username}</td>
<td className="px-3 py-2">{row.rating}</td>
<td className="px-3 py-2">
{new Date(row.created_at * 1000).toLocaleString()}
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td className="px-3 py-5 text-center text-zinc-500" colSpan={4}>
{tx("暂无排行数据", "No ranking data yet")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</main>
);

查看文件

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type Me = {
id: number;
@@ -61,6 +62,7 @@ function fmtTs(v: number | null | undefined): string {
}
export default function MePage() {
const { isZh, tx } = useI18nText();
const [token, setToken] = useState("");
const [profile, setProfile] = useState<Me | null>(null);
const [items, setItems] = useState<RedeemItem[]>([]);
@@ -92,6 +94,38 @@ export default function MePage() {
const totalCost = useMemo(() => Math.max(0, unitCost * Math.max(1, quantity)), [quantity, unitCost]);
const taskTitle = (task: DailyTaskItem): string => {
if (isZh) return task.title;
if (task.code === "login_checkin") return "Daily Sign-in";
if (task.code === "daily_submit") return "Daily Submission";
if (task.code === "first_ac") return "Solve One Problem";
if (task.code === "code_quality") return "Code Quality";
return task.title;
};
const taskDesc = (task: DailyTaskItem): string => {
if (isZh) return task.description;
if (task.code === "login_checkin") return "Sign in once today to get 1 point.";
if (task.code === "daily_submit") return "Submit once today to get 1 point.";
if (task.code === "first_ac") return "Get AC once today to get 1 point.";
if (task.code === "code_quality") return "Submit code longer than 10 lines once today to get 1 point.";
return task.description;
};
const itemName = (name: string): string => {
if (isZh) return name;
if (name === "私人玩游戏时间") return "Private Game Time";
return name;
};
const itemDesc = (text: string): string => {
if (isZh) return text;
if (text === "全局用户可兑换:假期 1 小时 5 Rating;学习日/非节假日 1 小时 25 Rating。") {
return "Global redeem item: holiday 1 hour = 5 rating; study day/non-holiday 1 hour = 25 rating.";
}
return text;
};
const loadAll = async () => {
setLoading(true);
setError("");
@@ -99,7 +133,7 @@ export default function MePage() {
try {
const tk = readToken();
setToken(tk);
if (!tk) throw new Error("请先登录");
if (!tk) throw new Error(tx("请先登录", "Please sign in first"));
const [me, redeemItems, redeemRecords, daily] = await Promise.all([
apiFetch<Me>("/api/v1/me", {}, tk),
@@ -127,6 +161,7 @@ export default function MePage() {
useEffect(() => {
void loadAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const redeem = async () => {
@@ -134,9 +169,11 @@ export default function MePage() {
setError("");
setMsg("");
try {
if (!token) throw new Error("请先登录");
if (!selectedItemId) throw new Error("请选择兑换物品");
if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("兑换数量必须大于 0");
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
if (!selectedItemId) throw new Error(tx("请选择兑换物品", "Please select a redeem item"));
if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error(tx("兑换数量必须大于 0", "Quantity must be greater than 0"));
}
const created = await apiFetch<RedeemCreateResp>(
"/api/v1/me/redeem/records",
@@ -153,9 +190,13 @@ export default function MePage() {
);
setMsg(
`兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : ""
}`
isZh
? `兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : ""
}`
: `Redeemed successfully: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} rating${
typeof created.rating_after === "number" ? `, current rating ${created.rating_after}` : ""
}.`
);
setNote("");
await loadAll();
@@ -168,25 +209,28 @@ export default function MePage() {
return (
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("我的信息与积分兑换", "My Profile & Redeem")}
</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
{profile && (
<section className="mt-4 rounded-xl border bg-white p-4 text-sm">
<p>ID: {profile.id}</p>
<p>: {profile.username}</p>
<p>{tx("用户名", "Username")}: {profile.username}</p>
<p>Rating: {profile.rating}</p>
<p>: {fmtTs(profile.created_at)}</p>
<p>{tx("创建时间", "Created At")}: {fmtTs(profile.created_at)}</p>
</section>
)}
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold"></h2>
<h2 className="text-base font-semibold">{tx("每日任务", "Daily Tasks")}</h2>
<p className="text-xs text-zinc-600">
{dailyDayKey ? `${dailyDayKey} · ` : ""} {dailyGainedReward}/{dailyTotalReward}
{dailyDayKey ? `${dailyDayKey} · ` : ""}
{tx("已获", "Earned")} {dailyGainedReward}/{dailyTotalReward} {tx("分", "pts")}
</p>
</div>
<div className="mt-3 divide-y">
@@ -194,68 +238,82 @@ export default function MePage() {
<article key={task.code} className="py-2 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
{task.title} · +{task.reward}
{taskTitle(task)} · +{task.reward}
</p>
<span
className={`rounded px-2 py-0.5 text-xs ${
task.completed ? "bg-emerald-100 text-emerald-700" : "bg-zinc-100 text-zinc-600"
}`}
>
{task.completed ? "已完成" : "未完成"}
{task.completed ? tx("已完成", "Completed") : tx("未完成", "Incomplete")}
</span>
</div>
<p className="mt-1 text-xs text-zinc-600">{task.description}</p>
<p className="mt-1 text-xs text-zinc-600">{taskDesc(task)}</p>
{task.completed && (
<p className="mt-1 text-xs text-zinc-500">{fmtTs(task.completed_at)}</p>
<p className="mt-1 text-xs text-zinc-500">
{tx("完成时间:", "Completed At: ")}
{fmtTs(task.completed_at)}
</p>
)}
</article>
))}
{!loading && dailyTasks.length === 0 && (
<p className="py-3 text-sm text-zinc-500"></p>
<p className="py-3 text-sm text-zinc-500">
{tx("今日任务尚未初始化,请稍后刷新。", "Today's tasks are not initialized yet. Please refresh later.")}
</p>
)}
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-base font-semibold"></h2>
<h2 className="text-base font-semibold">{tx("积分兑换物品", "Redeem Items")}</h2>
<p className="mt-1 text-xs text-zinc-600">
1 =5 / 1 =25
{tx(
"示例规则:私人玩游戏时间(假期 1 小时=5 积分;学习日/非节假日 1 小时=25 积分)",
"Sample rule: Private Game Time (holiday 1h=5 points; study day/non-holiday 1h=25 points)"
)}
</p>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{items.map((item) => (
<article key={item.id} className="rounded border bg-zinc-50 p-3 text-sm">
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{item.name}</p>
<p className="font-medium">{itemName(item.name)}</p>
<button
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100"
onClick={() => setSelectedItemId(item.id)}
>
{tx("选中", "Select")}
</button>
</div>
<p className="mt-1 text-xs text-zinc-600">{item.description || "-"}</p>
<p className="mt-1 text-xs text-zinc-700">{item.holiday_cost} / {item.unit_label}</p>
<p className="text-xs text-zinc-700">{item.studyday_cost} / {item.unit_label}</p>
<p className="mt-1 text-xs text-zinc-600">{itemDesc(item.description) || "-"}</p>
<p className="mt-1 text-xs text-zinc-700">
{tx("假期", "Holiday")}: {item.holiday_cost} / {item.unit_label}
</p>
<p className="text-xs text-zinc-700">
{tx("学习日", "Study Day")}: {item.studyday_cost} / {item.unit_label}
</p>
</article>
))}
{!loading && items.length === 0 && (
<p className="text-sm text-zinc-500"></p>
<p className="text-sm text-zinc-500">
{tx("管理员尚未配置可兑换物品。", "No redeem items configured by admin yet.")}
</p>
)}
</div>
<div className="mt-4 rounded-lg border p-3">
<h3 className="text-sm font-medium"></h3>
<h3 className="text-sm font-medium">{tx("兑换表单", "Redeem Form")}</h3>
<div className="mt-2 grid gap-2 md:grid-cols-2">
<select
className="rounded border px-3 py-2 text-sm"
value={selectedItemId}
onChange={(e) => setSelectedItemId(Number(e.target.value))}
>
<option value={0}></option>
<option value={0}>{tx("请选择兑换物品", "Please select an item")}</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
{itemName(item.name)}
</option>
))}
</select>
@@ -265,8 +323,8 @@ export default function MePage() {
value={dayType}
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
>
<option value="holiday"></option>
<option value="studyday">/</option>
<option value="holiday">{tx("假期时间(按假期单价)", "Holiday time (holiday price)")}</option>
<option value="studyday">{tx("学习日/非节假日(按学习日单价)", "Study day/non-holiday (study-day price)")}</option>
</select>
<input
@@ -276,19 +334,19 @@ export default function MePage() {
max={24}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
placeholder="兑换时长(小时)"
placeholder={tx("兑换时长(小时)", "Redeem duration (hours)")}
/>
<input
className="rounded border px-3 py-2 text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="备注(可选)"
placeholder={tx("备注(可选)", "Note (optional)")}
/>
</div>
<p className="mt-2 text-xs text-zinc-600">
{unitCost} / {totalCost}
{tx("当前单价", "Current unit price")}: {unitCost} / {tx("小时", "hour")}{tx("预计扣分", "Estimated cost")}: {totalCost}
</p>
<button
@@ -296,20 +354,20 @@ export default function MePage() {
onClick={() => void redeem()}
disabled={redeemLoading || !selectedItemId}
>
{redeemLoading ? "兑换中..." : "确认兑换"}
{redeemLoading ? tx("兑换中...", "Redeeming...") : tx("确认兑换", "Confirm Redeem")}
</button>
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-semibold"></h2>
<h2 className="text-base font-semibold">{tx("兑换记录", "Redeem Records")}</h2>
<button
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100"
onClick={() => void loadAll()}
disabled={loading}
>
{tx("刷新", "Refresh")}
</button>
</div>
@@ -317,15 +375,18 @@ export default function MePage() {
{records.map((row) => (
<article key={row.id} className="py-2 text-sm">
<p>
#{row.id} · {row.item_name} · {row.quantity} · {row.day_type === "holiday" ? "假期" : "学习日"}
#{row.id} · {itemName(row.item_name)} · {row.quantity} {tx("小时", "hour")} ·{" "}
{row.day_type === "holiday" ? tx("假期", "Holiday") : tx("学习日", "Study Day")}
</p>
<p className="text-xs text-zinc-600">
{row.unit_cost} {row.total_cost} · {fmtTs(row.created_at)}
{tx("单价", "Unit cost")} {row.unit_cost}{tx("总扣分", "Total cost")} {row.total_cost} · {fmtTs(row.created_at)}
</p>
{row.note && <p className="text-xs text-zinc-500">{row.note}</p>}
{row.note && <p className="text-xs text-zinc-500">{tx("备注:", "Note: ")}{row.note}</p>}
</article>
))}
{!loading && records.length === 0 && <p className="py-3 text-sm text-zinc-500"></p>}
{!loading && records.length === 0 && (
<p className="py-3 text-sm text-zinc-500">{tx("暂无兑换记录。", "No redeem records yet.")}</p>
)}
</div>
</section>
</main>

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

查看文件

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type Problem = {
id: number;
@@ -33,7 +34,8 @@ type ProblemProfile = {
type Preset = {
key: string;
label: string;
labelZh: string;
labelEn: string;
sourcePrefix?: string;
tags?: string[];
};
@@ -41,33 +43,39 @@ type Preset = {
const PRESETS: Preset[] = [
{
key: "csp-beginner-default",
label: "CSP J/S 入门默认",
labelZh: "CSP J/S 入门默认",
labelEn: "CSP J/S Beginner Default",
tags: ["csp-j", "csp-s", "noip-junior", "noip-senior"],
},
{
key: "csp-j",
label: "仅 CSP-J / 普及",
labelZh: "仅 CSP-J / 普及",
labelEn: "CSP-J / Junior Only",
tags: ["csp-j", "noip-junior"],
},
{
key: "csp-s",
label: "仅 CSP-S / 提高",
labelZh: "仅 CSP-S / 提高",
labelEn: "CSP-S / Senior Only",
tags: ["csp-s", "noip-senior"],
},
{
key: "noip-junior",
label: "仅 NOIP 入门",
labelZh: "仅 NOIP 入门",
labelEn: "NOIP Junior Only",
tags: ["noip-junior"],
},
{
key: "luogu-all",
label: "洛谷导入全部",
labelZh: "洛谷导入全部",
labelEn: "All Luogu Imports",
sourcePrefix: "luogu:",
tags: [],
},
{
key: "all",
label: "全站全部来源",
labelZh: "全站全部来源",
labelEn: "All Sources",
tags: [],
},
];
@@ -75,33 +83,39 @@ const PRESETS: Preset[] = [
const QUICK_CARDS = [
{
presetKey: "csp-j",
title: "CSP-J 真题",
desc: "普及组入门训练",
titleZh: "CSP-J 真题",
titleEn: "CSP-J Problems",
descZh: "普及组入门训练",
descEn: "Junior training set",
},
{
presetKey: "csp-s",
title: "CSP-S 真题",
desc: "提高组进阶训练",
titleZh: "CSP-S 真题",
titleEn: "CSP-S Problems",
descZh: "提高组进阶训练",
descEn: "Senior advanced set",
},
{
presetKey: "noip-junior",
title: "NOIP 入门",
desc: "基础算法与思维",
titleZh: "NOIP 入门",
titleEn: "NOIP Junior",
descZh: "基础算法与思维",
descEn: "Basic algorithm thinking",
},
] as const;
const DIFFICULTY_OPTIONS = [
{ value: "0", label: "全部难度" },
{ value: "1", label: "1" },
{ value: "2", label: "2" },
{ value: "3", label: "3" },
{ value: "4", label: "4" },
{ value: "5", label: "5" },
{ value: "6", label: "6" },
{ value: "7", label: "7" },
{ value: "8", label: "8" },
{ value: "9", label: "9" },
{ value: "10", label: "10" },
{ value: "0", labelZh: "全部难度", labelEn: "All Levels" },
{ value: "1", labelZh: "1", labelEn: "1" },
{ value: "2", labelZh: "2", labelEn: "2" },
{ value: "3", labelZh: "3", labelEn: "3" },
{ value: "4", labelZh: "4", labelEn: "4" },
{ value: "5", labelZh: "5", labelEn: "5" },
{ value: "6", labelZh: "6", labelEn: "6" },
{ value: "7", labelZh: "7", labelEn: "7" },
{ value: "8", labelZh: "8", labelEn: "8" },
{ value: "9", labelZh: "9", labelEn: "9" },
{ value: "10", labelZh: "10", labelEn: "10" },
] as const;
function parseProfile(raw: string): ProblemProfile | null {
@@ -145,6 +159,7 @@ function resolveTags(profile: ProblemProfile | null): string[] {
}
export default function ProblemsPage() {
const { isZh, tx } = useI18nText();
const [presetKey, setPresetKey] = useState(PRESETS[0].key);
const [keywordInput, setKeywordInput] = useState("");
const [keyword, setKeyword] = useState("");
@@ -217,15 +232,25 @@ export default function ProblemsPage() {
};
return (
<main className="mx-auto max-w-7xl px-6 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">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">CSP J/S </h1>
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("题库CSP J/S 入门)", "Problem Set (CSP J/S Beginner)")}
</h1>
<p className="mt-1 text-sm text-zinc-600">
CSP-J / CSP-S / NOIP
{tx(
"参考洛谷题库列表交互,默认聚焦 CSP-J / CSP-S / NOIP 入门训练。",
"Interaction style is inspired by Luogu problem list. Default focus: CSP-J / CSP-S / NOIP junior training."
)}
</p>
</div>
<p className="text-sm text-zinc-600"> {totalCount} </p>
<div className="flex w-full flex-wrap items-center gap-3 text-sm sm:w-auto sm:justify-end">
<p className="text-zinc-600">{tx("共", "Total")} {totalCount} {tx("题", "problems")}</p>
<Link className="w-full rounded border px-3 py-1 text-center hover:bg-zinc-100 sm:w-auto" href="/backend-logs">
{tx("查看后台日志", "View Backend Logs")}
</Link>
</div>
</div>
<section className="mt-4 grid gap-3 md:grid-cols-3">
@@ -242,9 +267,9 @@ export default function ProblemsPage() {
}`}
onClick={() => selectPreset(card.presetKey)}
>
<p className="text-base font-semibold">{card.title}</p>
<p className="text-base font-semibold">{isZh ? card.titleZh : card.titleEn}</p>
<p className={`mt-1 text-xs ${active ? "text-zinc-200" : "text-zinc-500"}`}>
{card.desc}
{isZh ? card.descZh : card.descEn}
</p>
</button>
);
@@ -261,14 +286,14 @@ export default function ProblemsPage() {
>
{PRESETS.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
{isZh ? item.labelZh : item.labelEn}
</option>
))}
</select>
<input
className="rounded border px-3 py-2 text-sm lg:col-span-2"
placeholder="搜索题号/标题/题面关键词"
placeholder={tx("搜索题号/标题/题面关键词", "Search id/title/statement keywords")}
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
onKeyDown={(e) => {
@@ -286,7 +311,7 @@ export default function ProblemsPage() {
>
{DIFFICULTY_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
{tx("难度", "Difficulty")} {isZh ? item.labelZh : item.labelEn}
</option>
))}
</select>
@@ -301,12 +326,12 @@ export default function ProblemsPage() {
setPage(1);
}}
>
<option value="id:asc"></option>
<option value="id:desc"></option>
<option value="difficulty:asc"></option>
<option value="difficulty:desc"></option>
<option value="created_at:desc"></option>
<option value="title:asc"> A-Z</option>
<option value="id:asc">{tx("题号升序", "ID Asc")}</option>
<option value="id:desc">{tx("题号降序", "ID Desc")}</option>
<option value="difficulty:asc">{tx("难度升序", "Difficulty Asc")}</option>
<option value="difficulty:desc">{tx("难度降序", "Difficulty Desc")}</option>
<option value="created_at:desc">{tx("最新导入", "Newest Imported")}</option>
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
</select>
<button
@@ -314,88 +339,130 @@ export default function ProblemsPage() {
onClick={applySearch}
disabled={loading}
>
{loading ? "加载中..." : "搜索"}
{loading ? tx("加载中...", "Loading...") : tx("搜索", "Search")}
</button>
</section>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<section className="mt-4 overflow-x-auto rounded-xl border bg-white">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left text-zinc-700">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">/</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{rows.map(({ problem, profile }) => {
const pid = resolvePid(problem, profile);
const tags = resolveTags(profile);
return (
<tr key={problem.id} className="border-t hover:bg-zinc-50">
<td className="px-3 py-2 font-medium text-blue-700">{pid}</td>
<td className="px-3 py-2">
<Link className="hover:underline" href={`/problems/${problem.id}`}>
{problem.title}
</Link>
</td>
<td className="px-3 py-2 text-zinc-600">{resolvePassRate(profile)}</td>
<td className={`px-3 py-2 font-semibold ${difficultyClass(problem.difficulty)}`}>
{problem.difficulty}
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{tags.length === 0 && <span className="text-zinc-400">-</span>}
{tags.map((tag) => (
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</td>
<td className="px-3 py-2 text-zinc-500">{problem.source || "-"}</td>
</tr>
);
})}
{!loading && rows.length === 0 && (
<section className="mt-4 rounded-xl border bg-white">
<div className="divide-y md:hidden">
{rows.map(({ problem, profile }) => {
const pid = resolvePid(problem, profile);
const tags = resolveTags(profile);
return (
<article key={problem.id} className="space-y-2 p-3">
<div className="flex items-start justify-between gap-2">
<Link className="font-medium text-blue-700 hover:underline" href={`/problems/${problem.id}`}>
{pid} · {problem.title}
</Link>
<span className={`shrink-0 text-sm font-semibold ${difficultyClass(problem.difficulty)}`}>
{tx("难度", "Difficulty")} {problem.difficulty}
</span>
</div>
<p className="text-xs text-zinc-600">{tx("通过/提交:", "Accepted/Submissions: ")}{resolvePassRate(profile)}</p>
<p className="text-xs text-zinc-500 break-all">{tx("来源:", "Source: ")}{problem.source || "-"}</p>
<div className="flex flex-wrap gap-1">
{tags.length === 0 && <span className="text-xs text-zinc-400">-</span>}
{tags.map((tag) => (
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</article>
);
})}
{!loading && rows.length === 0 && (
<p className="px-3 py-6 text-center text-sm text-zinc-500">
{tx(
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。",
"No problems under current filters. Switch preset or run import first."
)}
</p>
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left text-zinc-700">
<tr>
<td className="px-3 py-6 text-center text-zinc-500" colSpan={6}>
</td>
<th className="px-3 py-2">{tx("题号", "ID")}</th>
<th className="px-3 py-2">{tx("标题", "Title")}</th>
<th className="px-3 py-2">{tx("通过/提交", "Accepted/Submissions")}</th>
<th className="px-3 py-2">{tx("难度", "Difficulty")}</th>
<th className="px-3 py-2">{tx("标签", "Tags")}</th>
<th className="px-3 py-2">{tx("来源", "Source")}</th>
</tr>
)}
</tbody>
</table>
</thead>
<tbody>
{rows.map(({ problem, profile }) => {
const pid = resolvePid(problem, profile);
const tags = resolveTags(profile);
return (
<tr key={problem.id} className="border-t hover:bg-zinc-50">
<td className="px-3 py-2 font-medium text-blue-700">{pid}</td>
<td className="px-3 py-2">
<Link className="hover:underline" href={`/problems/${problem.id}`}>
{problem.title}
</Link>
</td>
<td className="px-3 py-2 text-zinc-600">{resolvePassRate(profile)}</td>
<td className={`px-3 py-2 font-semibold ${difficultyClass(problem.difficulty)}`}>
{problem.difficulty}
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{tags.length === 0 && <span className="text-zinc-400">-</span>}
{tags.map((tag) => (
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</td>
<td className="px-3 py-2 text-zinc-500">{problem.source || "-"}</td>
</tr>
);
})}
{!loading && rows.length === 0 && (
<tr>
<td className="px-3 py-6 text-center text-zinc-500" colSpan={6}>
{tx(
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。",
"No problems under current filters. Switch preset or run import first."
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2">
<div className="mt-4 flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<button
className="rounded border px-3 py-1 disabled:opacity-50"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={loading || page <= 1}
>
{tx("上一页", "Prev")}
</button>
<span>
{page} / {totalPages}
{isZh ? `${page} / ${totalPages}` : `Page ${page} / ${totalPages}`}
</span>
<button
className="rounded border px-3 py-1 disabled:opacity-50"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={loading || page >= totalPages}
>
{tx("下一页", "Next")}
</button>
</div>
<div className="flex items-center gap-2">
<span></span>
<div className="flex items-center gap-2 sm:justify-end">
<span>{tx("每页", "Per Page")}</span>
<select
className="rounded border px-2 py-1"
value={pageSize}

查看文件

@@ -3,6 +3,7 @@
import { useState } from "react";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type RunResult = {
status: string;
@@ -23,6 +24,7 @@ int main() {
`;
export default function RunPage() {
const { tx } = useI18nText();
const [code, setCode] = useState(starterCode);
const [input, setInput] = useState("hello csp");
const [loading, setLoading] = useState(false);
@@ -47,21 +49,23 @@ export default function RunPage() {
};
return (
<main className="mx-auto max-w-6xl px-6 py-8">
<h1 className="text-2xl font-semibold">线 C++ / / </h1>
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("在线 C++ 编写 / 编译 / 运行", "Online C++ Editor / Compile / Run")}
</h1>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<h2 className="text-sm font-medium">{tx("代码", "Code")}</h2>
<textarea
className="mt-2 h-[420px] w-full rounded border p-3 font-mono text-sm"
className="mt-2 h-72 w-full rounded border p-3 font-mono text-sm sm:h-[420px]"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<h2 className="text-sm font-medium">{tx("标准输入", "Standard Input")}</h2>
<textarea
className="mt-2 h-32 w-full rounded border p-3 font-mono text-sm"
value={input}
@@ -69,11 +73,11 @@ export default function RunPage() {
/>
<button
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
className="mt-3 w-full rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50 sm:w-auto"
onClick={() => void run()}
disabled={loading}
>
{loading ? "运行中..." : "运行"}
{loading ? tx("运行中...", "Running...") : tx("运行", "Run")}
</button>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
@@ -81,7 +85,7 @@ export default function RunPage() {
{result && (
<div className="mt-4 space-y-3 text-sm">
<p>
: <b>{result.status}</b> · : {result.time_ms}ms
{tx("状态", "Status")}: <b>{result.status}</b> · {tx("耗时", "Time")}: {result.time_ms}ms
</p>
<div>
<h3 className="font-medium">stdout</h3>

查看文件

@@ -3,7 +3,19 @@
import { useParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type SubmissionAnalysis = {
feedback_md: string;
links: Array<{ title: string; url: string }>;
model_name: string;
status: string;
created_at: number;
updated_at: number;
};
type Submission = {
id: number;
@@ -14,72 +26,184 @@ type Submission = {
code: string;
status: string;
score: number;
rating_delta: number;
time_ms: number;
memory_kb: number;
compile_log: string;
runtime_log: string;
created_at: number;
has_viewed_answer: boolean;
answer_view_count: number;
answer_view_total_cost: number;
last_answer_view_at: number | null;
analysis: SubmissionAnalysis | null;
};
function fmtTs(v: number | null): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
function fmtRatingDelta(delta: number): string {
if (delta > 0) return `+${delta}`;
return `${delta}`;
}
export default function SubmissionDetailPage() {
const { tx } = useI18nText();
const params = useParams<{ id: string }>();
const id = useMemo(() => Number(params.id), [params.id]);
const [data, setData] = useState<Submission | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [analysisLoading, setAnalysisLoading] = useState(false);
const [analysisMsg, setAnalysisMsg] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const d = await apiFetch<Submission>(`/api/v1/submissions/${id}`);
setData(d);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const d = await apiFetch<Submission>(`/api/v1/submissions/${id}`);
setData(d);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
if (Number.isFinite(id) && id > 0) void load();
if (Number.isFinite(id) && id > 0) {
void load();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const generateAnalysis = async (refresh = false) => {
setAnalysisLoading(true);
setAnalysisMsg("");
setError("");
try {
const token = readToken();
if (!token) throw new Error(tx("请先登录后再生成评测建议", "Please sign in before generating analysis"));
const result = await apiFetch<SubmissionAnalysis>(
`/api/v1/submissions/${id}/analysis`,
{
method: "POST",
body: JSON.stringify({ refresh }),
},
token
);
setData((prev) => (prev ? { ...prev, analysis: result } : prev));
setAnalysisMsg(refresh ? tx("已重新生成评测建议", "Analysis regenerated") : tx("评测建议已生成", "Analysis generated"));
} catch (e: unknown) {
setAnalysisMsg(`${tx("生成失败:", "Generate failed: ")}${String(e)}`);
} finally {
setAnalysisLoading(false);
}
};
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"> #{id}</h1>
{loading && <p className="mt-4 text-sm text-zinc-500">...</p>}
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("提交详情", "Submission Detail")} #{id}
</h1>
{loading && <p className="mt-4 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
{data && (
<div className="mt-4 space-y-4">
<div className="rounded-xl border bg-white p-4 text-sm">
<p>: {data.user_id}</p>
<p>: {data.problem_id}</p>
<p>: {data.contest_id ?? "-"}</p>
<p>: {data.language}</p>
<p>: {data.status}</p>
<p>: {data.score}</p>
<p>: {data.time_ms} ms</p>
<p>: {data.memory_kb} KB</p>
</div>
<section className="rounded-xl border bg-white p-4 text-sm">
<div className="grid gap-1 sm:grid-cols-2">
<p>{tx("用户", "User")}: {data.user_id}</p>
<p>{tx("题目", "Problem")}: {data.problem_id}</p>
<p>{tx("比赛", "Contest")}: {data.contest_id ?? "-"}</p>
<p>{tx("语言", "Language")}: {data.language}</p>
<p>{tx("状态", "Status")}: {data.status}</p>
<p>{tx("分数", "Score")}: {data.score}</p>
<p>{tx("Rating 变化", "Rating Delta")}: {fmtRatingDelta(data.rating_delta)}</p>
<p>{tx("时间", "Time")}: {data.time_ms} ms</p>
<p>{tx("内存", "Memory")}: {data.memory_kb} KB</p>
<p>{tx("提交时间", "Submitted At")}: {fmtTs(data.created_at)}</p>
<p>{tx("查看答案", "Viewed Answer")}: {data.has_viewed_answer ? tx("已查看", "Yes") : tx("未查看", "No")}</p>
<p>{tx("查看次数", "View Count")}: {data.answer_view_count}</p>
<p>{tx("查看扣分", "View Cost")}: {data.answer_view_total_cost}</p>
<p>{tx("最后查看答案", "Last View Time")}: {fmtTs(data.last_answer_view_at)}</p>
</div>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-medium">{tx("LLM 评测建议(福建 CSP-J/S 规范)", "LLM Analysis (Fujian CSP-J/S style)")}</h2>
<button
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
onClick={() => void generateAnalysis(false)}
disabled={analysisLoading}
>
{analysisLoading ? tx("生成中...", "Generating...") : tx("生成评测建议", "Generate Analysis")}
</button>
<button
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
onClick={() => void generateAnalysis(true)}
disabled={analysisLoading}
>
{tx("重新生成", "Regenerate")}
</button>
</div>
{analysisMsg && <p className="mt-2 text-xs text-zinc-600">{analysisMsg}</p>}
{data.analysis ? (
<div className="mt-3 space-y-3">
<div className="rounded border bg-zinc-50 p-3 text-xs text-zinc-600">
<p>{tx("模型", "Model")}: {data.analysis.model_name || "-"}</p>
<p>{tx("状态", "Status")}: {data.analysis.status || "ready"}</p>
<p>{tx("更新时间", "Updated At")}: {fmtTs(data.analysis.updated_at)}</p>
</div>
<div className="rounded border bg-white p-3">
<MarkdownRenderer markdown={data.analysis.feedback_md} />
</div>
{(data.analysis.links ?? []).length > 0 && (
<div className="rounded border bg-zinc-50 p-3">
<p className="text-xs font-medium text-zinc-700">{tx("推荐外链资料", "Recommended Links")}</p>
<div className="mt-2 flex flex-wrap gap-2">
{data.analysis.links.map((item) => (
<a
key={`${item.title}-${item.url}`}
className="rounded border px-2 py-1 text-xs text-blue-700 hover:underline"
href={item.url}
target="_blank"
rel="noreferrer"
>
{item.title}
</a>
))}
</div>
</div>
)}
</div>
) : (
<p className="mt-2 text-xs text-zinc-500">
{tx("暂无评测建议,可点击上方按钮生成。", "No analysis yet. Click the button above to generate one.")}
</p>
)}
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium">{tx("提交代码", "Submitted Code")}</h2>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{data.code}
{data.code || "(empty)"}
</pre>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<h2 className="text-sm font-medium">{tx("编译日志", "Compile Log")}</h2>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{data.compile_log || "(empty)"}
</pre>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<h2 className="text-sm font-medium">{tx("运行日志", "Runtime Log")}</h2>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{data.runtime_log || "(empty)"}
</pre>

查看文件

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type Submission = {
id: number;
@@ -12,6 +13,7 @@ type Submission = {
contest_id: number | null;
status: string;
score: number;
rating_delta: number;
time_ms: number;
created_at: number;
};
@@ -19,6 +21,7 @@ type Submission = {
type ListResp = { items: Submission[]; page: number; page_size: number };
export default function SubmissionsPage() {
const { tx } = useI18nText();
const [userId, setUserId] = useState("");
const [problemId, setProblemId] = useState("");
const [contestId, setContestId] = useState("");
@@ -26,6 +29,17 @@ export default function SubmissionsPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const fmtRatingDelta = (delta: number) => {
if (delta > 0) return `+${delta}`;
return `${delta}`;
};
const ratingDeltaClass = (delta: number) => {
if (delta > 0) return "text-emerald-700";
if (delta < 0) return "text-red-700";
return "text-zinc-600";
};
const load = async () => {
setLoading(true);
setError("");
@@ -49,8 +63,10 @@ export default function SubmissionsPage() {
}, []);
return (
<main className="mx-auto max-w-6xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("提交记录", "Submissions")}
</h1>
<div className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-4">
<input
@@ -76,43 +92,80 @@ export default function SubmissionsPage() {
onClick={() => void load()}
disabled={loading}
>
{loading ? "加载中..." : "筛选"}
{loading ? tx("加载中...", "Loading...") : tx("筛选", "Filter")}
</button>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 overflow-x-auto rounded-xl border bg-white">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2">ID</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">(ms)</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{items.map((s) => (
<tr key={s.id} className="border-t">
<td className="px-3 py-2">{s.id}</td>
<td className="px-3 py-2">{s.user_id}</td>
<td className="px-3 py-2">{s.problem_id}</td>
<td className="px-3 py-2">{s.status}</td>
<td className="px-3 py-2">{s.score}</td>
<td className="px-3 py-2">{s.time_ms}</td>
<td className="px-3 py-2">
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
</Link>
</td>
<div className="mt-4 rounded-xl border bg-white">
<div className="divide-y md:hidden">
{items.map((s) => (
<article key={s.id} className="space-y-2 p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<p className="font-medium">{tx("提交", "Submission")} #{s.id}</p>
<span className="text-xs text-zinc-500">{s.status}</span>
</div>
<p className="text-xs text-zinc-600">
{tx("用户", "User")} {s.user_id} · {tx("题目", "Problem")} {s.problem_id} · {tx("分数", "Score")} {s.score}
</p>
<p className={`text-xs ${ratingDeltaClass(s.rating_delta)}`}>
{tx("Rating 变化", "Rating Delta")} {fmtRatingDelta(s.rating_delta)}
</p>
<p className="text-xs text-zinc-600">{tx("耗时", "Time")} {s.time_ms} ms</p>
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
{tx("查看详情", "View Detail")}
</Link>
</article>
))}
{!loading && items.length === 0 && (
<p className="px-3 py-5 text-center text-sm text-zinc-500">{tx("暂无提交记录", "No submissions yet")}</p>
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2">ID</th>
<th className="px-3 py-2">{tx("用户", "User")}</th>
<th className="px-3 py-2">{tx("题目", "Problem")}</th>
<th className="px-3 py-2">{tx("状态", "Status")}</th>
<th className="px-3 py-2">{tx("分数", "Score")}</th>
<th className="px-3 py-2">{tx("Rating 变化", "Rating Delta")}</th>
<th className="px-3 py-2">{tx("耗时(ms)", "Time(ms)")}</th>
<th className="px-3 py-2">{tx("详情", "Detail")}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{items.map((s) => (
<tr key={s.id} className="border-t">
<td className="px-3 py-2">{s.id}</td>
<td className="px-3 py-2">{s.user_id}</td>
<td className="px-3 py-2">{s.problem_id}</td>
<td className="px-3 py-2">{s.status}</td>
<td className="px-3 py-2">{s.score}</td>
<td className={`px-3 py-2 ${ratingDeltaClass(s.rating_delta)}`}>
{fmtRatingDelta(s.rating_delta)}
</td>
<td className="px-3 py-2">{s.time_ms}</td>
<td className="px-3 py-2">
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
{tx("查看", "View")}
</Link>
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td className="px-3 py-5 text-center text-zinc-500" colSpan={8}>
{tx("暂无提交记录", "No submissions yet")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</main>
);

查看文件

@@ -1,9 +1,11 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type WrongBookItem = {
user_id: number;
@@ -14,7 +16,13 @@ type WrongBookItem = {
updated_at: number;
};
function fmtTs(v: number): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function WrongBookPage() {
const { tx } = useI18nText();
const [token, setToken] = useState("");
const [items, setItems] = useState<WrongBookItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -28,7 +36,7 @@ export default function WrongBookPage() {
setLoading(true);
setError("");
try {
if (!token) throw new Error("请先登录");
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
const data = await apiFetch<WrongBookItem[]>("/api/v1/me/wrong-book", {}, token);
setItems(data);
} catch (e: unknown) {
@@ -65,9 +73,13 @@ export default function WrongBookPage() {
};
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-zinc-600"></p>
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("错题本", "Wrong Book")}
</h1>
<p className="mt-2 text-sm text-zinc-600">
{tx("未通过提交会自动进入错题本。", "Failed submissions are added to the wrong-book automatically.")}
</p>
<div className="mt-4">
<button
@@ -75,7 +87,7 @@ export default function WrongBookPage() {
onClick={() => void load()}
disabled={loading}
>
{loading ? "刷新中..." : "刷新"}
{loading ? tx("刷新中...", "Refreshing...") : tx("刷新", "Refresh")}
</button>
</div>
@@ -84,22 +96,42 @@ export default function WrongBookPage() {
<div className="mt-4 space-y-3">
{items.map((item) => (
<div key={item.problem_id} className="rounded-xl border bg-white p-4">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
<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}`}>
#{item.problem_id} {item.problem_title}
</p>
</Link>
<div className="flex flex-wrap items-center gap-2">
<Link
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
href={`/problems/${item.problem_id}`}
>
{tx("查看题目", "View Problem")}
</Link>
{item.last_submission_id && (
<Link
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
href={`/submissions/${item.last_submission_id}`}
>
{tx("查看最近提交", "View Latest Submission")}
</Link>
)}
</div>
</div>
<p className="mt-1 text-xs text-zinc-500">
{tx("最近提交:", "Latest Submission:")} {item.last_submission_id ?? "-"} ·{" "}
{tx("更新时间:", "Updated:")} {fmtTs(item.updated_at)}
</p>
<div className="mt-2 flex flex-wrap justify-end gap-2">
<button
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
onClick={() => void removeItem(item.problem_id)}
>
{tx("移除", "Remove")}
</button>
</div>
<p className="mt-1 text-xs text-zinc-500">
: {item.last_submission_id ?? "-"}
</p>
<textarea
className="mt-2 h-24 w-full rounded border p-2 text-sm"
value={item.note}
@@ -116,10 +148,18 @@ export default function WrongBookPage() {
className="mt-2 rounded border px-3 py-1 text-sm hover:bg-zinc-100"
onClick={() => void updateNote(item.problem_id, item.note)}
>
{tx("保存备注", "Save Note")}
</button>
</div>
))}
{!loading && items.length === 0 && (
<div className="rounded-xl border bg-white p-6 text-center text-sm text-zinc-500">
{tx(
"暂无错题。提交未通过后会自动加入错题本,可点击“查看题目/查看最近提交”快速复盘。",
"No wrong-book entries yet. Failed submissions will be added automatically; use “View Problem/View Latest Submission” to review quickly."
)}
</div>
)}
</div>
</main>
);

查看文件

@@ -1,64 +1,321 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useUiPreferences } from "@/components/ui-preference-provider";
import { apiFetch } from "@/lib/api";
import { clearToken, readToken } from "@/lib/auth";
import type { ThemeId } from "@/themes/types";
const links = [
["首页", "/"],
["登录", "/auth"],
["题库", "/problems"],
["提交", "/submissions"],
["错题本", "/wrong-book"],
["比赛", "/contests"],
["知识库", "/kb"],
["导入任务", "/imports"],
["在线运行", "/run"],
["我的", "/me"],
["排行榜", "/leaderboard"],
["API文档", "/api-docs"],
] as const;
type NavLink = {
label: string;
href: string;
};
type NavGroup = {
key: string;
label: string;
links: NavLink[];
};
function buildNavGroups(t: (key: string) => string, isAdmin: boolean): NavGroup[] {
const groups: NavGroup[] = [
{
key: "learn",
label: t("nav.group.learn"),
links: [
{ label: t("nav.link.home"), href: "/" },
{ label: t("nav.link.problems"), href: "/problems" },
{ label: t("nav.link.submissions"), href: "/submissions" },
{ label: t("nav.link.wrong_book"), href: "/wrong-book" },
{ label: t("nav.link.kb"), href: "/kb" },
{ label: t("nav.link.run"), href: "/run" },
],
},
{
key: "contest",
label: t("nav.group.contest"),
links: [
{ label: t("nav.link.contests"), href: "/contests" },
{ label: t("nav.link.leaderboard"), href: "/leaderboard" },
],
},
{
key: "account",
label: t("nav.group.account"),
links: [
{ label: t("nav.link.auth"), href: "/auth" },
{ label: t("nav.link.me"), href: "/me" },
],
},
];
if (isAdmin) {
groups.splice(2, 0, {
key: "system",
label: t("nav.group.system"),
links: [
{ label: t("nav.link.imports"), href: "/imports" },
{ label: t("nav.link.backend_logs"), href: "/backend-logs" },
{ label: t("nav.link.admin_users"), href: "/admin-users" },
{ label: t("nav.link.admin_redeem"), href: "/admin-redeem" },
{ label: t("nav.link.api_docs"), href: "/api-docs" },
],
});
}
return groups;
}
function isActivePath(pathname: string, href: string): boolean {
if (pathname === href) return true;
if (href === "/") return pathname === "/";
return pathname.startsWith(`${href}/`);
}
function resolveActiveGroup(pathname: string, groups: NavGroup[]): string {
for (const group of groups) {
for (const item of group.links) {
if (isActivePath(pathname, item.href)) return group.key;
}
}
return groups[0]?.key ?? "learn";
}
function resolveActiveLink(pathname: string, group: NavGroup): string {
for (const item of group.links) {
if (isActivePath(pathname, item.href)) return item.href;
}
return group.links[0]?.href ?? "/";
}
export function AppNav() {
const pathname = usePathname();
const router = useRouter();
const { theme, setTheme, language, setLanguage, themes, t } = useUiPreferences();
const [hasToken, setHasToken] = useState<boolean>(() => Boolean(readToken()));
const [isAdmin, setIsAdmin] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [desktopOpenGroup, setDesktopOpenGroup] = useState<string | null>(null);
const desktopMenuRef = useRef<HTMLDivElement | null>(null);
const navGroups = useMemo(() => buildNavGroups(t, isAdmin), [isAdmin, t]);
const activeGroup = resolveActiveGroup(pathname, navGroups);
const usePopupSecondary = theme === "default";
useEffect(() => {
const refresh = () => setHasToken(Boolean(readToken()));
window.addEventListener("storage", refresh);
window.addEventListener("focus", refresh);
return () => {
window.removeEventListener("storage", refresh);
window.removeEventListener("focus", refresh);
let canceled = false;
const refresh = async () => {
const token = readToken();
if (canceled) return;
setHasToken(Boolean(token));
if (!token) {
setIsAdmin(false);
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, token);
if (!canceled) {
setIsAdmin((me?.username ?? "") === "admin");
}
} catch {
if (!canceled) setIsAdmin(false);
}
};
}, []);
const onRefresh = () => {
void refresh();
};
void refresh();
window.addEventListener("storage", onRefresh);
window.addEventListener("focus", onRefresh);
return () => {
canceled = true;
window.removeEventListener("storage", onRefresh);
window.removeEventListener("focus", onRefresh);
};
}, [pathname]);
useEffect(() => {
if (!usePopupSecondary || !desktopOpenGroup) return;
const onMouseDown = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (desktopMenuRef.current?.contains(target)) return;
setDesktopOpenGroup(null);
};
window.addEventListener("mousedown", onMouseDown);
return () => window.removeEventListener("mousedown", onMouseDown);
}, [desktopOpenGroup, usePopupSecondary]);
const currentGroup = navGroups.find((g) => g.key === activeGroup) ?? navGroups[0];
return (
<header className="border-b bg-white">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-2 px-4 py-3">
{links.map(([label, href]) => (
<Link
key={href}
href={href}
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100"
<header className="print-hidden border-b bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85">
<div className="mx-auto max-w-6xl px-3 py-3 max-[390px]:px-2 max-[390px]:py-2 sm:px-4">
<div className="flex items-center justify-between md:hidden">
<span className="text-sm font-medium text-zinc-700">{t("nav.menu")}</span>
<button
type="button"
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100 max-[390px]:px-2 max-[390px]:text-xs"
onClick={() => setMenuOpen((v) => !v)}
aria-expanded={menuOpen}
aria-controls="main-nav-links"
>
{label}
</Link>
))}
{menuOpen ? t("nav.collapse") : t("nav.expand")}
</button>
</div>
<div className="ml-auto flex items-center gap-2 text-sm">
<div id="main-nav-links" className={`${menuOpen ? "mt-3 block" : "hidden"} md:mt-0 md:block`}>
<div className="space-y-3">
{usePopupSecondary ? (
<div ref={desktopMenuRef} className="hidden flex-wrap items-center gap-2 md:flex">
{navGroups.map((group) => {
const active = activeGroup === group.key;
const opened = desktopOpenGroup === group.key;
return (
<div key={group.key} className="relative">
<button
type="button"
className={`rounded-md border px-3 py-1 text-sm ${
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`}
aria-expanded={opened}
onClick={() =>
setDesktopOpenGroup((prev) => (prev === group.key ? null : group.key))
}
>
{group.label}
</button>
{opened && (
<div className="absolute left-0 top-full z-50 mt-2 min-w-[11rem] rounded-md border bg-[color:var(--surface)] p-1 shadow-lg">
{group.links.map((item) => {
const linkActive = isActivePath(pathname, item.href);
return (
<button
key={item.href}
type="button"
className={`block w-full rounded px-3 py-1.5 text-left text-sm ${
linkActive ? "bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`}
onClick={() => {
setDesktopOpenGroup(null);
router.push(item.href);
}}
>
{item.label}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
) : (
<>
<div className="hidden flex-wrap items-center gap-2 md:flex">
{navGroups.map((group) => {
const active = activeGroup === group.key;
return (
<button
key={group.key}
type="button"
className={`rounded-md border px-3 py-1 text-sm ${
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`}
onClick={() => router.push(group.links[0]?.href ?? "/")}
>
{group.label}
</button>
);
})}
</div>
<div className="hidden items-center gap-2 md:flex">
<span className="text-xs text-zinc-500">{t("nav.secondary_menu")}</span>
<select
className="min-w-[220px] rounded-md border px-3 py-1 text-sm"
value={resolveActiveLink(pathname, currentGroup)}
onChange={(e) => router.push(e.target.value)}
>
{currentGroup.links.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</select>
</div>
</>
)}
<div className="space-y-3 md:hidden">
{navGroups.map((group) => (
<section key={group.key} className="rounded-lg border p-2">
<h3 className="text-xs font-semibold text-zinc-600">{group.label}</h3>
<select
className="mt-2 w-full rounded-md border px-3 py-1 text-xs"
value={resolveActiveLink(pathname, group)}
onChange={(e) => {
setMenuOpen(false);
router.push(e.target.value);
}}
>
{group.links.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</select>
</section>
))}
</div>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm">
<label className="inline-flex items-center gap-1">
<span className="text-zinc-500">{t("prefs.theme")}</span>
<select
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
value={theme}
onChange={(e) => {
setDesktopOpenGroup(null);
setTheme(e.target.value as ThemeId);
}}
>
{themes.map((item) => (
<option key={item.id} value={item.id}>
{item.labels[language]}
</option>
))}
</select>
</label>
<label className="inline-flex items-center gap-1">
<span className="text-zinc-500">{t("prefs.language")}</span>
<select
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
value={language}
onChange={(e) => setLanguage(e.target.value === "zh" ? "zh" : "en")}
>
<option value="en">{t("prefs.lang.en")}</option>
<option value="zh">{t("prefs.lang.zh")}</option>
</select>
</label>
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
{hasToken ? "已登录" : "未登录"}
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
</span>
{hasToken && (
<button
onClick={() => {
clearToken();
setHasToken(false);
setIsAdmin(false);
}}
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
>
退
{t("nav.logout")}
</button>
)}
</div>

查看文件

@@ -1,25 +1,160 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef } from "react";
import type { editor as MonacoEditorNS, IDisposable, MarkerSeverity, IPosition } from "monaco-editor";
import { analyzeCpp14Policy, type Cpp14PolicyIssue } from "@/lib/cpp14-policy";
import { useI18nText } from "@/lib/i18n";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
const POLICY_MARKER_OWNER = "csp-cpp14-policy";
type Props = {
value: string;
onChange: (next: string) => void;
height?: string;
fontSize?: number;
onPolicyIssuesChange?: (issues: Cpp14PolicyIssue[]) => void;
};
export function CodeEditor({ value, onChange, height = "420px" }: Props) {
function markerSeverity(monaco: typeof import("monaco-editor"), severity: Cpp14PolicyIssue["severity"]): MarkerSeverity {
if (severity === "error") return monaco.MarkerSeverity.Error;
if (severity === "warning") return monaco.MarkerSeverity.Warning;
if (severity === "hint") return monaco.MarkerSeverity.Hint;
return monaco.MarkerSeverity.Info;
}
function safeEndPosition(
issue: Cpp14PolicyIssue,
model: MonacoEditorNS.ITextModel
): IPosition {
const lineMax = model.getLineCount();
const line = Math.min(Math.max(1, issue.endLine), lineMax);
const colMax = model.getLineMaxColumn(line);
const column = Math.min(Math.max(1, issue.endColumn), colMax);
return { lineNumber: line, column };
}
function localizePolicyIssue(
issue: Cpp14PolicyIssue,
tx: (zhText: string, enText: string) => string
): Cpp14PolicyIssue {
const byId: Record<string, { message: string; detail: string }> = {
"cpp17-header": {
message: tx("检测到 C++17+ 头文件", "Detected C++17+ header"),
detail: tx(
"福建 CSP-J/S 环境通常以 C++14 为准,建议改为 C++14 可用写法。",
"Fujian CSP-J/S judge usually targets C++14; please rewrite with C++14-compatible code."
),
},
"if-constexpr": {
message: tx("检测到 if constexprC++17", "Detected `if constexpr` (C++17)"),
detail: tx("请改用普通条件分支或模板特化方案。", "Use normal condition branches or template specialization."),
},
"structured-binding": {
message: tx("检测到结构化绑定C++17", "Detected structured binding (C++17)"),
detail: tx("可改为 pair.first/second 或自定义结构体字段。", "Use `pair.first/second` or explicit struct fields."),
},
"cpp17-stdlib": {
message: tx("检测到 C++17+ 标准库符号", "Detected C++17+ stdlib symbol"),
detail: tx("请替换为 C++14 可用实现,避免提交到老版本 GCC 报 CE。", "Replace with C++14 implementation to avoid CE on older GCC."),
},
"void-main": {
message: tx("main 函数返回类型不规范", "Invalid `main` return type"),
detail: tx("请使用 int main(),并在末尾 return 0;", "Use `int main()` and end with `return 0;`."),
},
"windows-i64d": {
message: tx("检测到 %I64dWindows 特有)", "Detected `%I64d` (Windows-only)"),
detail: tx("Linux 评测机请使用 %lld 读写 long long。", "Use `%lld` for `long long` on Linux judges."),
},
"windows-int64": {
message: tx("检测到 __int64非标准", "Detected `__int64` (non-standard)"),
detail: tx("建议改为标准类型 long long。", "Use standard type `long long` instead."),
},
"bits-header": {
message: tx("检测到 <bits/stdc++.h>", "Detected `<bits/stdc++.h>`"),
detail: tx("福建实战建议优先使用标准头文件,提升环境兼容性。", "Prefer standard headers for better judge compatibility."),
},
"main-return-zero": {
message: tx("建议在 main 末尾显式 return 0;", "Recommend explicit `return 0;` in `main`"),
detail: tx("部分考场与评测环境会严格检查主函数返回行为。", "Some exam/judge environments check main return behavior strictly."),
},
"ll-format": {
message: tx("检测到 long long + scanf/printf,建议确认格式符为 %lld", "Detected `long long` with scanf/printf; confirm `%lld` format"),
detail: tx("Linux 评测环境不支持 %I64d。", "Linux judge does not support `%I64d`."),
},
"freopen-tip": {
message: tx("未检测到 freopen福建二轮常见文件读写要求", "No `freopen` found (common requirement in Fujian round-2)"),
detail: tx("若考场题面要求 *.in/*.out,请按官方文件名补上 freopen。", "If statement requires `*.in/*.out`, add `freopen` with exact official filenames."),
},
};
const localized = byId[issue.id];
if (!localized) return issue;
return { ...issue, ...localized };
}
export function CodeEditor({
value,
onChange,
height = "420px",
fontSize = 14,
onPolicyIssuesChange,
}: Props) {
const { tx } = useI18nText();
const editorRef = useRef<MonacoEditorNS.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<typeof import("monaco-editor") | null>(null);
const completionRef = useRef<IDisposable | null>(null);
const updatePolicyIssues = useCallback(
(nextCode: string) => {
const issues = analyzeCpp14Policy(nextCode).map((issue) => localizePolicyIssue(issue, tx));
onPolicyIssuesChange?.(issues);
const monaco = monacoRef.current;
const model = editorRef.current?.getModel();
if (!monaco || !model) return;
const markers = issues.map((issue) => {
const endPos = safeEndPosition(issue, model);
return {
severity: markerSeverity(monaco, issue.severity),
message: `${issue.message}\n${issue.detail}`,
startLineNumber: Math.max(1, issue.line),
startColumn: Math.max(1, issue.column),
endLineNumber: endPos.lineNumber,
endColumn: endPos.column,
source: tx("CSP C++14 规范提醒", "CSP C++14 Policy"),
code: issue.id,
};
});
monaco.editor.setModelMarkers(model, POLICY_MARKER_OWNER, markers);
},
[onPolicyIssuesChange, tx]
);
useEffect(() => {
updatePolicyIssues(value);
}, [updatePolicyIssues, value]);
useEffect(
() => () => {
completionRef.current?.dispose();
completionRef.current = null;
},
[]
);
return (
<MonacoEditor
height={height}
language="cpp"
value={value}
options={{
fontSize: 14,
fontSize,
minimap: { enabled: false },
automaticLayout: true,
glyphMargin: true,
tabSize: 2,
wordWrap: "on",
suggestOnTriggerCharacters: true,
@@ -30,40 +165,98 @@ export function CodeEditor({ value, onChange, height = "420px" }: Props) {
},
}}
onMount={(editor, monaco) => {
monaco.languages.registerCompletionItemProvider("cpp", {
provideCompletionItems: () => ({
suggestions: [
{
label: "ios",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText:
"ios::sync_with_stdio(false);\\ncin.tie(nullptr);\\n",
documentation: "Fast IO",
},
{
label: "fori",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: "for (int i = 0; i < ${1:n}; ++i) {\\n ${2}\\n}",
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: "for loop",
},
{
label: "vector",
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: "vector<int> ${1:arr}(${2:n});",
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
},
],
}),
});
editorRef.current = editor;
monacoRef.current = monaco;
if (!completionRef.current) {
completionRef.current = monaco.languages.registerCompletionItemProvider("cpp", {
provideCompletionItems: () => ({
suggestions: [
{
label: "cspmain",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: [
"#include <iostream>",
"#include <vector>",
"#include <algorithm>",
"using namespace std;",
"",
"int main() {",
" ios::sync_with_stdio(false);",
" cin.tie(nullptr);",
"",
" ${1:// code}",
" return 0;",
"}",
].join("\\n"),
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: tx("C++14 主函数模板(含 return 0", "C++14 `main` template (with `return 0`)"),
},
{
label: "ios",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText:
"ios::sync_with_stdio(false);\\ncin.tie(nullptr);\\n",
documentation: "Fast IO",
},
{
label: "freopen",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: [
'freopen("${1:problem}.in", "r", stdin);',
'freopen("${1:problem}.out", "w", stdout);',
].join("\\n"),
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: tx("福建赛场常见文件读写模板", "Common Fujian contest file I/O template"),
},
{
label: "scanfll",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: 'scanf("%lld", &${1:x});',
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: tx("long long 输入Linux 评测格式符)", "long long input (Linux judge format)"),
},
{
label: "printfll",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: 'printf("%lld\\\\n", ${1:x});',
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: tx("long long 输出Linux 评测格式符)", "long long output (Linux judge format)"),
},
{
label: "fori",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: "for (int i = 0; i < ${1:n}; ++i) {\\n ${2}\\n}",
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: "for loop",
},
{
label: "vector",
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: "vector<int> ${1:arr}(${2:n});",
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
},
],
}),
});
}
updatePolicyIssues(editor.getValue());
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
// handled by page-level save button; reserve shortcut for UX consistency.
});
}}
onChange={(next) => onChange(next ?? "")}
onChange={(next) => {
const nextCode = next ?? "";
onChange(nextCode);
updatePolicyIssues(nextCode);
}}
/>
);
}

查看文件

@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUiPreferences } from "@/components/ui-preference-provider";
function isActivePath(pathname: string, href: string): boolean {
if (pathname === href) return true;
return pathname.startsWith(`${href}/`);
}
export function MobileTabBar() {
const pathname = usePathname();
const { t } = useUiPreferences();
const tabs = [
{ label: t("mobile.tab.problems"), href: "/problems" },
{ label: t("mobile.tab.submissions"), href: "/submissions" },
{ label: t("mobile.tab.contests"), href: "/contests" },
{ label: t("mobile.tab.kb"), href: "/kb" },
{ label: t("mobile.tab.me"), href: "/me" },
] as const;
return (
<nav className="print-hidden fixed inset-x-0 bottom-0 z-40 border-t bg-[color:var(--surface)]/95 pb-[calc(0.3rem+env(safe-area-inset-bottom))] pt-1 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85 md:hidden">
<div className="mx-auto max-w-5xl px-2 max-[390px]:px-1.5">
<div className="grid grid-cols-5 gap-1 max-[390px]:gap-0.5">
{tabs.map((tab) => {
const active = isActivePath(pathname, tab.href);
return (
<Link
key={tab.href}
href={tab.href}
className={`rounded-md px-1 py-1.5 text-center text-xs max-[390px]:text-[11px] ${
active
? "bg-zinc-900 font-semibold text-white"
: "text-zinc-600 hover:bg-zinc-100"
}`}
>
{tab.label}
</Link>
);
})}
</div>
</div>
</nav>
);
}

查看文件

@@ -0,0 +1,71 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { listThemes, resolveLanguage, resolveTheme } from "@/themes/registry";
import { DEFAULT_LANGUAGE, DEFAULT_THEME, type ThemeDefinition, type ThemeId, type UiLanguage } from "@/themes/types";
const THEME_STORAGE_KEY = "csp.ui.theme";
const LANGUAGE_STORAGE_KEY = "csp.ui.language";
type UiPreferenceContextValue = {
theme: ThemeId;
language: UiLanguage;
themes: ThemeDefinition[];
setTheme: (theme: ThemeId) => void;
setLanguage: (language: UiLanguage) => void;
t: (key: string) => string;
};
const UiPreferenceContext = createContext<UiPreferenceContextValue | null>(null);
export function UiPreferenceProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeId>(() => {
if (typeof window === "undefined") return DEFAULT_THEME;
return resolveTheme(window.localStorage.getItem(THEME_STORAGE_KEY)).id;
});
const [language, setLanguageState] = useState<UiLanguage>(() => {
if (typeof window === "undefined") return DEFAULT_LANGUAGE;
return resolveLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY));
});
const themes = useMemo(() => listThemes(), []);
useEffect(() => {
if (typeof window !== "undefined") {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}
document.documentElement.dataset.theme = theme;
}, [theme]);
useEffect(() => {
if (typeof window !== "undefined") {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
}
document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
}, [language]);
const value = useMemo<UiPreferenceContextValue>(() => {
const resolved = resolveTheme(theme);
const fallbackMessages = resolved.messages.en;
const currentMessages = resolved.messages[language] ?? fallbackMessages;
return {
theme,
language,
themes,
setTheme: (nextTheme) => setThemeState(resolveTheme(nextTheme).id),
setLanguage: (nextLanguage) => setLanguageState(resolveLanguage(nextLanguage)),
t: (key: string) => currentMessages[key] ?? fallbackMessages[key] ?? key,
};
}, [language, theme, themes]);
return <UiPreferenceContext.Provider value={value}>{children}</UiPreferenceContext.Provider>;
}
export function useUiPreferences() {
const ctx = useContext(UiPreferenceContext);
if (!ctx) {
throw new Error("useUiPreferences must be used inside UiPreferenceProvider");
}
return ctx;
}

查看文件

@@ -2,6 +2,12 @@ export const API_BASE =
process.env.NEXT_PUBLIC_API_BASE ??
(process.env.NODE_ENV === "development" ? "http://localhost:8080" : "/admin139");
function uiText(zhText: string, enText: string): string {
if (typeof window === "undefined") return enText;
const lang = window.localStorage.getItem("csp.ui.language");
return lang === "zh" ? zhText : enText;
}
type ApiEnvelope<T> =
| { ok: true; data?: T; [k: string]: unknown }
| { ok: false; error?: string; [k: string]: unknown };
@@ -17,11 +23,45 @@ export async function apiFetch<T>(
headers.set("Content-Type", "application/json");
}
const resp = await fetch(`${API_BASE}${path}`, {
...init,
headers,
cache: "no-store",
});
const method = (init?.method ?? "GET").toUpperCase();
const retryable = method === "GET" || method === "HEAD";
let resp: Response;
try {
resp = await fetch(`${API_BASE}${path}`, {
...init,
headers,
cache: "no-store",
});
} catch (err) {
if (!retryable) {
throw new Error(
uiText(
`网络请求失败,请检查后端服务或代理连接(${err instanceof Error ? err.message : String(err)}`,
`Network request failed. Please check backend/proxy connectivity (${err instanceof Error ? err.message : String(err)}).`
)
);
}
await new Promise((resolve) => setTimeout(resolve, 400));
try {
resp = await fetch(`${API_BASE}${path}`, {
...init,
headers,
cache: "no-store",
});
} catch (retryErr) {
throw new Error(
uiText(
`网络请求失败,请检查后端服务或代理连接(${
retryErr instanceof Error ? retryErr.message : String(retryErr)
}`,
`Network request failed. Please check backend/proxy connectivity (${
retryErr instanceof Error ? retryErr.message : String(retryErr)
}).`
)
);
}
}
const text = await resp.text();
let payload: unknown = null;

查看文件

@@ -0,0 +1,206 @@
export type Cpp14PolicySeverity = "error" | "warning" | "hint";
export type Cpp14PolicyIssue = {
id: string;
severity: Cpp14PolicySeverity;
message: string;
detail: string;
line: number;
column: number;
endLine: number;
endColumn: number;
};
type RegexRule = {
id: string;
severity: Cpp14PolicySeverity;
pattern: RegExp;
message: string;
detail: string;
};
const REGEX_RULES: RegexRule[] = [
{
id: "cpp17-header",
severity: "error",
pattern: /#\s*include\s*<\s*(optional|variant|any|string_view|filesystem|charconv|execution)\s*>/g,
message: "检测到 C++17+ 头文件",
detail: "福建 CSP-J/S 环境通常以 C++14 为准,建议改为 C++14 可用写法。",
},
{
id: "if-constexpr",
severity: "error",
pattern: /\bif\s+constexpr\b/g,
message: "检测到 if constexprC++17",
detail: "请改用普通条件分支或模板特化方案。",
},
{
id: "structured-binding",
severity: "error",
pattern: /\b(?:const\s+)?auto(?:\s*&|\s*&&)?\s*\[[^\]\n]+\]\s*=/g,
message: "检测到结构化绑定C++17",
detail: "可改为 pair.first/second 或自定义结构体字段。",
},
{
id: "cpp17-stdlib",
severity: "error",
pattern: /\bstd::(optional|variant|any|string_view|filesystem|byte|clamp|gcd|lcm)\b/g,
message: "检测到 C++17+ 标准库符号",
detail: "请替换为 C++14 可用实现,避免提交到老版本 GCC 报 CE。",
},
{
id: "void-main",
severity: "error",
pattern: /\bvoid\s+main\s*\(/g,
message: "main 函数返回类型不规范",
detail: "请使用 int main(),并在末尾 return 0;",
},
{
id: "windows-i64d",
severity: "warning",
pattern: /%I64d/g,
message: "检测到 %I64dWindows 特有)",
detail: "Linux 评测机请使用 %lld 读写 long long。",
},
{
id: "windows-int64",
severity: "warning",
pattern: /\b__int64\b/g,
message: "检测到 __int64非标准",
detail: "建议改为标准类型 long long。",
},
{
id: "bits-header",
severity: "warning",
pattern: /#\s*include\s*<\s*bits\/stdc\+\+\.h\s*>/g,
message: "检测到 <bits/stdc++.h>",
detail: "福建实战建议优先使用标准头文件,提升环境兼容性。",
},
];
export const CSP_CPP14_TIPS: string[] = [
"编译标准固定为 C++14建议按 -std=gnu++14 习惯编码),不要使用 C++17+ 特性。",
"main 必须是 int main(),结尾写 return 0;。",
"long long 的 scanf/printf 请使用 %lld,不要使用 %I64d。",
"命名/提交包按考场须知执行:题目目录与源码名使用英文小写。",
"福建二轮常见要求是文件读写freopen,赛前请按官方样例再核对一次。",
];
function buildLineStarts(text: string): number[] {
const starts = [0];
for (let i = 0; i < text.length; i += 1) {
if (text[i] === "\n") starts.push(i + 1);
}
return starts;
}
function offsetToPosition(lineStarts: number[], offset: number): { line: number; column: number } {
let lo = 0;
let hi = lineStarts.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (lineStarts[mid] <= offset) lo = mid + 1;
else hi = mid - 1;
}
const lineIdx = Math.max(0, hi);
return {
line: lineIdx + 1,
column: offset - lineStarts[lineIdx] + 1,
};
}
function pushIssue(
issues: Cpp14PolicyIssue[],
id: string,
severity: Cpp14PolicySeverity,
message: string,
detail: string,
line: number,
column: number,
endLine: number,
endColumn: number
) {
issues.push({ id, severity, message, detail, line, column, endLine, endColumn });
}
export function analyzeCpp14Policy(code: string): Cpp14PolicyIssue[] {
const text = (code ?? "").replace(/\r\n?/g, "\n");
if (!text.trim()) return [];
const issues: Cpp14PolicyIssue[] = [];
const lineStarts = buildLineStarts(text);
for (const rule of REGEX_RULES) {
const matcher = new RegExp(rule.pattern.source, rule.pattern.flags);
let match = matcher.exec(text);
while (match) {
const start = match.index;
const end = start + Math.max(1, match[0].length);
const p1 = offsetToPosition(lineStarts, start);
const p2 = offsetToPosition(lineStarts, end);
pushIssue(
issues,
rule.id,
rule.severity,
rule.message,
rule.detail,
p1.line,
p1.column,
p2.line,
p2.column
);
if (matcher.lastIndex === match.index) matcher.lastIndex += 1;
match = matcher.exec(text);
}
}
if (/\bint\s+main\s*\(/.test(text) && !/\breturn\s+0\s*;/.test(text)) {
const idx = text.search(/\bint\s+main\s*\(/);
const pos = offsetToPosition(lineStarts, Math.max(0, idx));
pushIssue(
issues,
"main-return-zero",
"warning",
"建议在 main 末尾显式 return 0;",
"部分考场与评测环境会严格检查主函数返回行为。",
pos.line,
pos.column,
pos.line,
pos.column + 3
);
}
if (/\blong\s+long\b/.test(text) && /\b(?:scanf|printf)\s*\(/.test(text) && !/%lld/.test(text)) {
const idx = text.search(/\b(?:scanf|printf)\s*\(/);
const pos = offsetToPosition(lineStarts, Math.max(0, idx));
pushIssue(
issues,
"ll-format",
"warning",
"检测到 long long + scanf/printf,建议确认格式符为 %lld",
"Linux 评测环境不支持 %I64d。",
pos.line,
pos.column,
pos.line,
pos.column + 6
);
}
if (!/\bfreopen\s*\(/.test(text) && /\bint\s+main\s*\(/.test(text)) {
const idx = text.search(/\bint\s+main\s*\(/);
const pos = offsetToPosition(lineStarts, Math.max(0, idx));
pushIssue(
issues,
"freopen-tip",
"hint",
"未检测到 freopen福建二轮常见文件读写要求",
"若考场题面要求 *.in/*.out,请按官方文件名补上 freopen。",
pos.line,
pos.column,
pos.line,
pos.column + 3
);
}
return issues;
}

15
frontend/src/lib/i18n.ts 普通文件
查看文件

@@ -0,0 +1,15 @@
"use client";
import { useUiPreferences } from "@/components/ui-preference-provider";
export function readUiLanguage(): "en" | "zh" {
if (typeof window === "undefined") return "en";
return window.localStorage.getItem("csp.ui.language") === "zh" ? "zh" : "en";
}
export function useI18nText() {
const { language } = useUiPreferences();
const isZh = language === "zh";
const tx = (zhText: string, enText: string) => (isZh ? zhText : enText);
return { language, isZh, tx };
}

查看文件

@@ -0,0 +1,294 @@
# 前端主题开发规范Theme Development Guide
本规范用于统一 `frontend/src/themes` 的主题实现方式,确保:
- 主题切换稳定(不闪烁、不丢样式、可回退)。
- 中英文文案与主题样式解耦。
- 新主题可快速复制、低风险上线。
- 代码评审和验收有统一标准。
---
## 1. 当前主题系统结构
```text
src/themes/
README.md
types.ts
registry.ts
default/
index.ts
theme.css
messages/
en.ts
zh.ts
minecraft/
index.ts
theme.css
```
相关运行时入口:
- `frontend/src/components/ui-preference-provider.tsx`
- 读取/写入本地偏好:
- `csp.ui.theme`
- `csp.ui.language`
- 将主题写入:`document.documentElement.dataset.theme`
- 将语言写入:`document.documentElement.lang`
- `frontend/src/app/layout.tsx`
- 必须导入所有主题 CSS 文件(否则构建后不会产出样式)。
---
## 2. 主题模型与命名规范
### 2.1 ThemeId
`src/themes/types.ts``ThemeId` 使用联合类型,新增主题必须显式加入:
```ts
export type ThemeId = "default" | "minecraft";
```
### 2.2 目录命名
- 目录名使用小写短横线或小写单词(建议与 `ThemeId` 一致)。
- 推荐:
- `default`
- `minecraft`
- 不推荐:
- `MinecraftTheme`
- `theme_minecraft_v2`
### 2.3 主题显示名
`<theme>/index.ts``labels` 中定义:
- `en`: 英文名称
- `zh`: 中文名称
示例:
```ts
labels: {
en: "Minecraft Pixel",
zh: "Minecraft 像素风",
}
```
---
## 3. 主题文件职责划分
### 3.1 `types.ts`
职责:
- 主题类型与默认主题定义。
禁止:
- 放置主题样式、业务逻辑。
### 3.2 `registry.ts`
职责:
- 注册所有主题。
- 提供 `listThemes` / `resolveTheme` / `resolveLanguage`
规范:
- 新主题必须加入 `themes` 数组。
- `resolveTheme` 必须有回退逻辑(回退到 `DEFAULT_THEME`)。
### 3.3 `<theme>/index.ts`
职责:
- 只描述主题元数据(`id` / `labels` / `messages`)。
规范:
- 不写样式代码。
- 可复用默认消息包,也可提供主题专属消息包。
### 3.4 `<theme>/theme.css`
职责:
- 主题 token 与组件样式覆盖。
规范:
- 必须以 `:root[data-theme="<id>"]` 作为作用域入口。
- 优先 token 化,减少硬编码。
- 避免直接污染无作用域的全局选择器。
---
## 4. CSS 实现规范(强制)
### 4.1 作用域规则
必须:
- 使用 `:root[data-theme="<id>"]` 作为根作用域。
- 仅在该作用域下覆盖样式。
示例:
```css
:root[data-theme="minecraft"] {
--background: #1a1a1a;
--foreground: #f5f5f5;
}
```
### 4.2 Token 优先级
实现顺序:
1. 先定义 CSS 变量(颜色、边框、背景、文本)。
2. 业务组件尽量走变量消费。
3. 最后才做类级别覆盖(如 `.rounded-xl.border`)。
### 4.3 覆盖策略
推荐:
- 小范围、可预测覆盖(导航、按钮、卡片、输入等)。
谨慎:
- `!important` 仅用于必要冲突点。
- 覆盖 Tailwind 工具类时,需评估全站影响。
### 4.4 字体规范
推荐:
- `font-display: swap`
- 提供回退字体链。
注意:
- 外链字体失败时应可读、不崩布局。
---
## 5. 国际化i18n规范
### 5.1 文案层级
- 主题消息通过 `ThemeDefinition.messages` 提供。
- 页面业务文案使用 `tx(zh, en)``t(key)`
### 5.2 主题与文案关系
- 主题可以复用默认 `en/zh` 消息包(如 `minecraft`)。
- 如主题有独立文案(例如术语、品牌),再创建主题专属 `messages`
### 5.3 必查项
- 主题切换不影响语言切换。
- EN 模式不出现未预期中文 UI 文案(数据内容除外)。
---
## 6. 新增主题标准流程SOP
1. 新建目录 `src/themes/<id>/`
2. 新建 `<id>/theme.css`(先 token,后覆盖
3. 新建 `<id>/index.ts`(主题元数据)。
4.`types.ts` 扩展 `ThemeId` 联合类型。
5.`registry.ts` 注册主题。
6.`app/layout.tsx` 导入主题 CSS。
7. 运行校验:
- `npm run lint`
- `npm run build`
8. 手工验收:
- 主题切换
- 移动端导航
- 深色页面可读性
- 表单/按钮交互
---
## 7. 验收清单(上线前必须过)
- 功能:
- 主题可切换、可持久化(刷新后保留)。
- 语言切换与主题切换互不影响。
- 样式:
- 首页、题库、题目详情、提交详情、个人中心、后台页无明显错位。
- 按钮 hover/active/disabled 状态清晰。
- 输入框、下拉框、表格、代码区可读。
- 技术:
- `npm run lint` 无报错。
- `npm run build` 成功。
- 无新增严重控制台报错。
---
## 8. Code Review 清单
- 是否遵循 `:root[data-theme="<id>"]` 作用域?
- 是否优先使用 token,而不是散落硬编码颜色?
- 是否避免了无必要的大面积 `!important`
- `ThemeId``registry``layout` 三处是否同步更新?
- 主题名称 `labels` 是否提供中英文?
- 是否验证主要页面可读性与对比度?
---
## 9. Minecraft 主题落地说明(当前版本)
来源参考:
- `ref/CSP-Minecraft-UI-Kit/docs/Design-Delivery-Document.html`
已落地核心规范:
- 色板 tokengrass/stone/wood/gold/diamond/redstone
- 像素风容器与边框(黑边 + 阴影)。
- 按钮 3D 状态normal/hover/active/disabled
- 输入控件像素化风格。
- 背景纹理与图片 `image-rendering: pixelated`
- 标题字体与文本阴影风格。
---
## 10. 常见问题FAQ
### Q1: 新主题写了 CSS,但页面无变化?
优先检查:
- `layout.tsx` 是否 `import` 了该主题 CSS。
- `ThemeId` 是否包含该主题。
- `registry.ts` 是否注册主题。
- `data-theme` 是否正确写入到 `<html>`
### Q2: 主题切换后某些组件颜色不对?
原因通常是:
- 页面用了硬编码颜色类且未被主题覆盖。
建议:
- 先抽成 token,再局部覆盖组件类。
### Q3: 能否让主题拥有独立文案?
可以。把 `messages` 指向主题自己的 `en.ts / zh.ts` 即可。
---
## 11. 后续建议(可选)
- 将“按钮、卡片、输入框、徽章”收敛为主题化组件(而非纯 class 覆盖)。
- 增加主题视觉回归截图(关键页面自动快照)。
- 建立对比度检测脚本,避免低对比度文本上线。

查看文件

@@ -0,0 +1,15 @@
import { enMessages } from "@/themes/default/messages/en";
import { zhMessages } from "@/themes/default/messages/zh";
import type { ThemeDefinition } from "@/themes/types";
export const defaultTheme: ThemeDefinition = {
id: "default",
labels: {
en: "Default",
zh: "默认主题",
},
messages: {
en: enMessages,
zh: zhMessages,
},
};

查看文件

@@ -0,0 +1,55 @@
import type { ThemeMessages } from "@/themes/types";
export const enMessages: ThemeMessages = {
"app.title": "CSP Online Learning & Contest Platform",
"nav.menu": "Navigation",
"nav.expand": "Expand",
"nav.collapse": "Collapse",
"nav.secondary_menu": "Submenu",
"nav.logged_in": "Signed in",
"nav.logged_out": "Signed out",
"nav.logout": "Sign out",
"nav.group.learn": "Learning",
"nav.group.contest": "Contests",
"nav.group.system": "Platform",
"nav.group.account": "Account",
"nav.link.home": "Home",
"nav.link.problems": "Problems",
"nav.link.submissions": "Submissions",
"nav.link.wrong_book": "Wrong Book",
"nav.link.kb": "Knowledge Base",
"nav.link.run": "Run Code",
"nav.link.contests": "Contests",
"nav.link.leaderboard": "Leaderboard",
"nav.link.imports": "Imports",
"nav.link.backend_logs": "Backend Logs",
"nav.link.admin_users": "User Rating",
"nav.link.admin_redeem": "Redeem Admin",
"nav.link.api_docs": "API Docs",
"nav.link.auth": "Sign In",
"nav.link.me": "My Account",
"mobile.tab.problems": "Problems",
"mobile.tab.submissions": "Submits",
"mobile.tab.contests": "Contests",
"mobile.tab.kb": "KB",
"mobile.tab.me": "Me",
"prefs.theme": "Theme",
"prefs.language": "Language",
"prefs.lang.en": "English",
"prefs.lang.zh": "Chinese",
"admin.entry.title": "Admin Entry",
"admin.entry.desc":
"Default admin account: admin / whoami139",
"admin.entry.login": "Go to Sign In",
"admin.entry.user_rating": "Manage User Rating",
"admin.entry.redeem": "Manage Redeem Items",
"admin.entry.logs": "Backend Task Logs",
"admin.entry.moved_to_platform":
"This entry has been merged into Platform Management (/imports).",
};

查看文件

@@ -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": "用户积分",
"nav.link.admin_redeem": "积分兑换",
"nav.link.api_docs": "API文档",
"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": "English",
"prefs.lang.zh": "中文",
"admin.entry.title": "后台管理入口",
"admin.entry.desc": "默认管理员账号admin / whoami139",
"admin.entry.login": "去登录",
"admin.entry.user_rating": "用户积分管理",
"admin.entry.redeem": "积分兑换管理",
"admin.entry.logs": "后台任务日志",
"admin.entry.moved_to_platform": "该入口已并入“平台管理”页(/imports。",
};

查看文件

@@ -0,0 +1,19 @@
:root,
:root[data-theme="default"] {
--background: #ffffff;
--foreground: #171717;
--surface: #ffffff;
--surface-soft: #f4f4f5;
--border: #d4d4d8;
}
@media (prefers-color-scheme: dark) {
:root,
:root[data-theme="default"] {
--background: #0a0a0a;
--foreground: #ededed;
--surface: #111111;
--surface-soft: #1f1f1f;
--border: #3f3f46;
}
}

查看文件

@@ -0,0 +1,15 @@
import { enMessages } from "@/themes/default/messages/en";
import { zhMessages } from "@/themes/default/messages/zh";
import type { ThemeDefinition } from "@/themes/types";
export const minecraftTheme: ThemeDefinition = {
id: "minecraft",
labels: {
en: "Minecraft Pixel",
zh: "Minecraft 像素风",
},
messages: {
en: enMessages,
zh: zhMessages,
},
};

查看文件

@@ -0,0 +1,201 @@
@font-face {
font-family: "DelaGothicOne";
src: url("https://assets-persist.lovart.ai/agent-static-assets/DelaGothicOne-Regular.ttf");
font-display: swap;
}
@font-face {
font-family: "MiSans";
src: url("https://assets-persist.lovart.ai/agent-static-assets/MiSans-Regular.ttf");
font-display: swap;
}
@font-face {
font-family: "MiSansBold";
src: url("https://assets-persist.lovart.ai/agent-static-assets/MiSans-Bold.ttf");
font-display: swap;
}
:root[data-theme="minecraft"] {
--mc-grass-top: #5cb85c;
--mc-grass-side: #4cae4c;
--mc-dirt: #795548;
--mc-stone: #9e9e9e;
--mc-stone-dark: #616161;
--mc-obsidian: #212121;
--mc-wood: #8d6e63;
--mc-wood-dark: #5d4037;
--mc-gold: #ffd700;
--mc-diamond: #40e0d0;
--mc-redstone: #f44336;
--background: #1a1a1a;
--foreground: #f5f5f5;
--surface: #2d2d2d;
--surface-soft: #242424;
--border: #000000;
}
:root[data-theme="minecraft"] body {
background-color: var(--background);
background-image:
linear-gradient(rgba(26, 26, 26, 0.9), rgba(26, 26, 26, 0.9)),
url("https://a.lovart.ai/artifacts/agent/W1iXxVdg3xIm5fP9.png");
background-size: 320px 320px;
color: var(--foreground);
font-family: "MiSans", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
:root[data-theme="minecraft"] h1,
:root[data-theme="minecraft"] h2,
:root[data-theme="minecraft"] h3,
:root[data-theme="minecraft"] h4,
:root[data-theme="minecraft"] h5,
:root[data-theme="minecraft"] h6 {
color: #ffffff;
font-family: "DelaGothicOne", "MiSansBold", "MiSans", sans-serif;
letter-spacing: 0.04em;
text-shadow: 2px 2px 0 #000000;
}
:root[data-theme="minecraft"] ::-webkit-scrollbar {
width: 12px;
height: 12px;
background: var(--mc-obsidian);
}
:root[data-theme="minecraft"] ::-webkit-scrollbar-thumb {
background: var(--mc-stone);
border: 2px solid var(--mc-obsidian);
}
:root[data-theme="minecraft"] header.print-hidden {
background: rgba(33, 33, 33, 0.96) !important;
border-bottom: 4px solid var(--mc-stone-dark);
box-shadow: 0 4px 0 rgba(0, 0, 0, 0.35);
}
:root[data-theme="minecraft"] nav.print-hidden.fixed {
background: rgba(33, 33, 33, 0.96) !important;
border-top: 4px solid var(--mc-stone-dark);
}
:root[data-theme="minecraft"] .rounded-xl.border,
:root[data-theme="minecraft"] .rounded-lg.border,
:root[data-theme="minecraft"] .rounded-md.border,
:root[data-theme="minecraft"] .rounded.border {
border-radius: 0 !important;
border-color: #000000 !important;
border-width: 3px !important;
box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.45);
}
:root[data-theme="minecraft"] .bg-white {
background-color: #2d2d2d !important;
}
:root[data-theme="minecraft"] .bg-zinc-50 {
background-color: #252525 !important;
}
:root[data-theme="minecraft"] .bg-zinc-100 {
background-color: #343434 !important;
}
:root[data-theme="minecraft"] .text-zinc-400,
:root[data-theme="minecraft"] .text-zinc-500,
:root[data-theme="minecraft"] .text-zinc-600,
:root[data-theme="minecraft"] .text-zinc-700 {
color: #d0d0d0 !important;
}
:root[data-theme="minecraft"] .text-blue-600,
:root[data-theme="minecraft"] .text-blue-700 {
color: var(--mc-diamond) !important;
}
:root[data-theme="minecraft"] .text-emerald-700 {
color: var(--mc-grass-top) !important;
}
:root[data-theme="minecraft"] .text-amber-700,
:root[data-theme="minecraft"] .text-amber-800 {
color: var(--mc-gold) !important;
}
:root[data-theme="minecraft"] .text-red-600,
:root[data-theme="minecraft"] .text-red-700 {
color: var(--mc-redstone) !important;
}
:root[data-theme="minecraft"] button {
background: linear-gradient(180deg, var(--mc-stone) 0%, var(--mc-stone-dark) 100%) !important;
border: 3px solid #000000 !important;
border-bottom-width: 7px !important;
border-radius: 0 !important;
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.45);
color: #ffffff !important;
font-family: "DelaGothicOne", "MiSansBold", "MiSans", sans-serif;
letter-spacing: 0.03em;
text-shadow: 1px 1px 0 #000000;
transition: transform 0.08s ease, filter 0.08s ease;
}
:root[data-theme="minecraft"] button:hover:not(:disabled) {
filter: brightness(1.08);
transform: translateY(-1px);
}
:root[data-theme="minecraft"] button:active:not(:disabled) {
border-bottom-width: 3px !important;
transform: translateY(3px);
}
:root[data-theme="minecraft"] button:disabled {
filter: saturate(0.25);
opacity: 0.7;
}
:root[data-theme="minecraft"] input,
:root[data-theme="minecraft"] textarea,
:root[data-theme="minecraft"] select {
background: #1f1f1f !important;
border: 3px solid #000000 !important;
border-radius: 0 !important;
box-shadow: inset 2px 2px 0 rgba(255, 255, 255, 0.1);
color: #f2f2f2 !important;
}
:root[data-theme="minecraft"] input::placeholder,
:root[data-theme="minecraft"] textarea::placeholder {
color: #acacac;
}
:root[data-theme="minecraft"] a {
color: var(--mc-diamond);
}
:root[data-theme="minecraft"] a:hover {
color: var(--mc-gold);
}
:root[data-theme="minecraft"] table thead {
background: #333333 !important;
}
:root[data-theme="minecraft"] table tr {
border-color: #000000 !important;
}
:root[data-theme="minecraft"] pre {
border: 2px solid #000000;
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.08);
}
:root[data-theme="minecraft"] img {
image-rendering: pixelated;
}
:root[data-theme="minecraft"] .problem-markdown-compact {
font-size: 66%;
}

查看文件

@@ -0,0 +1,21 @@
import { defaultTheme } from "@/themes/default";
import { minecraftTheme } from "@/themes/minecraft";
import { DEFAULT_LANGUAGE, DEFAULT_THEME, type ThemeDefinition, type ThemeId, type UiLanguage } from "@/themes/types";
const themes: ThemeDefinition[] = [defaultTheme, minecraftTheme];
const themeMap = new Map<ThemeId, ThemeDefinition>(themes.map((theme) => [theme.id, theme]));
export function listThemes(): ThemeDefinition[] {
return themes;
}
export function resolveTheme(themeId: string | null | undefined): ThemeDefinition {
if (!themeId) return themeMap.get(DEFAULT_THEME) ?? defaultTheme;
return themeMap.get(themeId as ThemeId) ?? (themeMap.get(DEFAULT_THEME) ?? defaultTheme);
}
export function resolveLanguage(language: string | null | undefined): UiLanguage {
if (language === "zh") return "zh";
return DEFAULT_LANGUAGE;
}

查看文件

@@ -0,0 +1,13 @@
export type ThemeId = "default" | "minecraft";
export type UiLanguage = "en" | "zh";
export const DEFAULT_THEME: ThemeId = "default";
export const DEFAULT_LANGUAGE: UiLanguage = "en";
export type ThemeMessages = Record<string, string>;
export type ThemeDefinition = {
id: ThemeId;
labels: Record<UiLanguage, string>;
messages: Record<UiLanguage, ThemeMessages>;
};

查看文件

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Generate CSP-J/S style feedback for one submission via LLM (with fallback)."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import requests
DEFAULT_LINKS: List[Dict[str, str]] = [
{"title": "NOI 官网(规则与环境)", "url": "https://www.noi.cn/"},
{"title": "OI Wiki算法知识库", "url": "https://oi-wiki.org/"},
{"title": "cppreference C++14", "url": "https://en.cppreference.com/w/cpp/14"},
{
"title": "GCC Warning Options",
"url": "https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html",
},
{"title": "洛谷(题解与训练)", "url": "https://www.luogu.com.cn/"},
]
@dataclass
class LlmResult:
ok: bool
feedback_md: str
links: List[Dict[str, str]]
model_name: str
status: str
def env(name: str, default: str = "") -> str:
value = os.getenv(name, "").strip()
return value if value else default
def load_input(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("input json must be object")
return data
def detect_cpp14_risk(code: str, compile_log: str) -> List[str]:
hints: List[str] = []
joined = f"{code}\n{compile_log}"
checks = [
(r"\bif\s+constexpr\b", "检测到 `if constexpr`C++17,C++14 环境会 CE。"),
(r"\bstd::optional\b", "检测到 `std::optional`C++17,建议改为普通变量+标记位。"),
(r"\bstd::variant\b", "检测到 `std::variant`C++17,建议改为 struct/enum 分支。"),
(r"\bstd::string_view\b", "检测到 `std::string_view`C++17,建议改为 `const string&`。"),
(r"\[[^\]]+\]\s*=" , "检测到结构化绑定迹象,C++14 不支持,建议改 pair/struct 访问。"),
(r"%I64d", "检测到 `%I64d`,Linux 评测机应统一使用 `%lld`。"),
(r"\bvoid\s+main\s*\(", "检测到 `void main()`,需改为 `int main()` 并 `return 0;`。"),
]
for pattern, tip in checks:
if re.search(pattern, joined):
hints.append(tip)
if "-Wsign-compare" in compile_log:
hints.append("存在 `-Wsign-compare`,建议统一使用 `size_t` 或显式类型转换。")
return hints
def build_fallback_feedback(payload: Dict[str, Any], llm_error: str = "") -> LlmResult:
status = str(payload.get("status", "Unknown"))
score = payload.get("score", 0)
compile_log = str(payload.get("compile_log", ""))
runtime_log = str(payload.get("runtime_log", ""))
code = str(payload.get("code", ""))
risk_tips = detect_cpp14_risk(code, compile_log)
if not risk_tips:
risk_tips = [
"请确认只使用 C++14 语法,避免 C++17 特性导致 CE。",
"若题目要求文件输入输出,使用 `freopen(\"xxx.in\",\"r\",stdin)` / `freopen(\"xxx.out\",\"w\",stdout)`。",
]
thought = (
"代码通过了当前评测,核心思路基本正确,建议继续做规范化和鲁棒性收敛。"
if status.upper() == "AC"
else "当前提交未稳定通过,建议先按日志定位错误,再拆分为思路问题与实现问题逐步修复。"
)
lines: List[str] = []
lines.append("### 评测结论")
lines.append(f"- 本次状态:**{status}**,分数:**{score}**。")
lines.append(f"- 思路评价:{thought}")
lines.append("")
lines.append("### 福建 CSP-J/S 规范检查C++14")
for tip in risk_tips:
lines.append(f"- {tip}")
if compile_log.strip():
lines.append("- 编译日志有信息,建议逐条清理 warning,减少考场不确定性。")
if runtime_log.strip():
lines.append("- 运行日志有输出,建议重点检查边界输入与数组越界风险。")
lines.append("")
lines.append("### 修改建议(可执行)")
lines.append("- 按“先编译通过→再保证正确→最后做优化”的顺序迭代。")
lines.append("- `long long` 读写统一 `%lld`;不要使用 `%I64d`。")
lines.append("- 清理 signed/unsigned 警告,降低不同编译器行为差异。")
lines.append("- 确保 `int main()` 且 `return 0;`。")
lines.append("")
lines.append("### 知识点评测")
lines.append("- 强项:基础实现与调试流程。")
lines.append("- 待加强:边界构造、类型一致性、赛场环境兼容性。")
lines.append("")
lines.append("### 推荐外链资料")
for item in DEFAULT_LINKS:
lines.append(f"- [{item['title']}]({item['url']})")
if llm_error:
lines.append("")
lines.append(f"> 说明LLM 调用失败,已返回规则兜底建议。错误:{llm_error}")
return LlmResult(
ok=True,
feedback_md="\n".join(lines).strip(),
links=DEFAULT_LINKS,
model_name="fallback-rules",
status="fallback",
)
def normalize_links(raw: Any) -> List[Dict[str, str]]:
links: List[Dict[str, str]] = []
if isinstance(raw, list):
for item in raw:
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()
url = str(item.get("url", "")).strip()
if title and url:
links.append({"title": title, "url": url})
return links if links else DEFAULT_LINKS
def dict_to_markdown(data: Dict[str, Any]) -> str:
parts: List[str] = []
for key, value in data.items():
title = str(key).strip() or "分析项"
if isinstance(value, str):
body = value.strip()
else:
body = json.dumps(value, ensure_ascii=False, indent=2)
if not body:
continue
parts.append(f"### {title}\n{body}")
return "\n\n".join(parts)
def call_llm(payload: Dict[str, Any]) -> LlmResult:
api_url = env("OI_LLM_API_URL") or env("CSP_LLM_API_URL")
api_key = env("OI_LLM_API_KEY") or env("CSP_LLM_API_KEY")
model = env("OI_LLM_MODEL", "qwen3-max")
if not api_url:
raise RuntimeError("missing OI_LLM_API_URL")
system_prompt = (
"你是福建省 CSP-J/S 代码规范与评测老师。"
"请严格按 C++14 旧 GCC 环境给建议,重点指出会导致 CE/RE/爆零的风险。"
"输出 JSON,不要输出其他文字。"
)
user_prompt = {
"task": "分析这份提交并给出改进建议",
"required_sections": [
"评测结论",
"福建 CSP-J/S 规范检查C++14",
"修改建议",
"知识点评测",
"推荐外链资料",
],
"submission": payload,
"output_json_schema": {
"feedback_md": "markdown string",
"links": [{"title": "string", "url": "string"}],
"status": "ready",
},
}
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
body = {
"model": model,
"stream": False,
"temperature": 0.1,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(user_prompt, ensure_ascii=False)},
],
}
resp = requests.post(api_url, headers=headers, json=body, timeout=50)
resp.raise_for_status()
data = resp.json()
choices = data.get("choices") if isinstance(data, dict) else None
if not choices:
raise RuntimeError("LLM response missing choices")
first = choices[0] if isinstance(choices, list) and choices else {}
message = first.get("message") if isinstance(first, dict) else {}
content = message.get("content", "") if isinstance(message, dict) else ""
if not isinstance(content, str) or not content.strip():
raise RuntimeError("LLM content is empty")
model_name = str(data.get("model", model)) if isinstance(data, dict) else model
parsed: Optional[Dict[str, Any]] = None
try:
candidate = json.loads(content)
if isinstance(candidate, dict):
parsed = candidate
except Exception:
parsed = None
if parsed and parsed.get("feedback_md"):
return LlmResult(
ok=True,
feedback_md=str(parsed.get("feedback_md", "")).strip(),
links=normalize_links(parsed.get("links")),
model_name=model_name,
status=str(parsed.get("status", "ready")) or "ready",
)
if parsed:
return LlmResult(
ok=True,
feedback_md=dict_to_markdown(parsed),
links=DEFAULT_LINKS,
model_name=model_name,
status="ready",
)
return LlmResult(
ok=True,
feedback_md=content.strip(),
links=DEFAULT_LINKS,
model_name=model_name,
status="ready",
)
def main() -> int:
parser = argparse.ArgumentParser(description="Analyze one submission with LLM + fallback")
parser.add_argument("--input-file", required=True, help="JSON file from backend")
args = parser.parse_args()
payload = load_input(args.input_file)
try:
result = call_llm(payload)
except Exception as exc:
result = build_fallback_feedback(payload, str(exc))
output = {
"feedback_md": result.feedback_md,
"links": result.links,
"model_name": result.model_name,
"status": result.status,
}
sys.stdout.write(json.dumps(output, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

查看文件

@@ -7,7 +7,10 @@ import argparse
import json
import os
import re
import shutil
import sqlite3
import subprocess
import tempfile
import time
from dataclasses import dataclass
from typing import Any
@@ -15,6 +18,37 @@ from typing import Any
import requests
RETRYABLE_HTTP_CODES = {500, 502, 503, 504}
CLANG_FORMAT_BIN = shutil.which("clang-format")
GXX_BIN = shutil.which("g++")
PLACEHOLDER_CODE_MARKERS = (
"todo",
"to do",
"请根据题意补全",
"待补全",
"自行补全",
"省略",
"your code here",
)
CPP17_BANNED_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"\bif\s+constexpr\b"), "if constexpr"),
(re.compile(r"\bstd::optional\b"), "std::optional"),
(re.compile(r"\bstd::variant\b"), "std::variant"),
(re.compile(r"\bstd::any\b"), "std::any"),
(re.compile(r"\bstd::string_view\b"), "std::string_view"),
(re.compile(r"\bstd::filesystem\b"), "std::filesystem"),
(re.compile(r"\bstd::byte\b"), "std::byte"),
(re.compile(r"\bstd::clamp\s*\("), "std::clamp"),
(re.compile(r"\bstd::gcd\s*\("), "std::gcd"),
(re.compile(r"\bstd::lcm\s*\("), "std::lcm"),
(re.compile(r"#\s*include\s*<\s*(optional|variant|any|string_view|filesystem|charconv|execution)\s*>"), "C++17 header"),
(
re.compile(
r"\b(?:const\s+)?auto(?:\s*&|\s*&&)?\s*\[[^\]\n]+\]\s*=",
flags=re.MULTILINE,
),
"structured bindings",
),
)
@dataclass
@@ -32,6 +66,17 @@ def now_sec() -> int:
return int(time.time())
def env_bool(key: str, default: bool) -> bool:
raw = os.getenv(key, "").strip().lower()
if not raw:
return default
if raw in {"1", "true", "yes", "on"}:
return True
if raw in {"0", "false", "no", "off"}:
return False
return default
def extract_json_object(text: str) -> dict[str, Any] | None:
raw = text.strip()
if raw.startswith("```"):
@@ -54,6 +99,253 @@ def extract_json_object(text: str) -> dict[str, Any] | None:
return None
def extract_message_text(content: Any) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str) and text.strip():
parts.append(text.strip())
continue
nested = item.get("content")
if isinstance(nested, str) and nested.strip():
parts.append(nested.strip())
return "\n".join(parts).strip()
if isinstance(content, dict):
text = content.get("text")
if isinstance(text, str):
return text.strip()
nested = content.get("content")
if isinstance(nested, str):
return nested.strip()
return ""
def iter_json_candidates(text: str) -> list[str]:
if not text:
return []
raw = text.strip()
candidates: list[str] = [raw] if raw else []
for match in re.finditer(r"```(?:json)?\s*([\s\S]*?)```", text, flags=re.IGNORECASE):
block = match.group(1).strip()
if block:
candidates.append(block)
decoder = json.JSONDecoder()
limit = min(len(text), 200000)
sample = text[:limit]
for idx, ch in enumerate(sample):
if ch not in "{[":
continue
try:
_, end = decoder.raw_decode(sample[idx:])
except json.JSONDecodeError:
continue
snippet = sample[idx : idx + end].strip()
if snippet:
candidates.append(snippet)
seen: set[str] = set()
deduped: list[str] = []
for cand in candidates:
if cand in seen:
continue
seen.add(cand)
deduped.append(cand)
return deduped
def extract_solution_rows(content: str) -> list[dict[str, Any]]:
for candidate in iter_json_candidates(content):
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
continue
rows: Any = None
if isinstance(parsed, dict):
rows = parsed.get("solutions")
if rows is None and isinstance(parsed.get("data"), dict):
rows = parsed["data"].get("solutions")
elif isinstance(parsed, list):
rows = parsed
if isinstance(rows, list):
filtered = [x for x in rows if isinstance(x, dict)]
if filtered:
return filtered
return []
def is_placeholder_code(code: str) -> bool:
lower = (code or "").lower()
if any(marker in lower for marker in PLACEHOLDER_CODE_MARKERS):
return True
if "..." in code:
return True
return False
def cpp14_violations(code: str) -> list[str]:
hits: list[str] = []
for pattern, label in CPP17_BANNED_PATTERNS:
if pattern.search(code):
hits.append(label)
return hits
def compiles_under_cpp14(code: str) -> tuple[bool, str]:
if not GXX_BIN:
return True, ""
with tempfile.TemporaryDirectory(prefix="csp_sol_cpp14_") as tmp:
src_path = os.path.join(tmp, "main.cpp")
with open(src_path, "w", encoding="utf-8") as f:
f.write(code if code.endswith("\n") else f"{code}\n")
proc = subprocess.run(
[GXX_BIN, "-std=gnu++14", "-O2", "-Wall", "-Wextra", "-Wpedantic", "-fsyntax-only", src_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=12,
)
if proc.returncode == 0:
return True, ""
err = proc.stderr.decode("utf-8", errors="ignore").strip()
return False, err[:400]
def normalize_solutions(rows: list[dict[str, Any]], max_solutions: int) -> tuple[list[dict[str, Any]], list[str]]:
normalized: list[dict[str, Any]] = []
rejected: list[str] = []
for row in rows:
title = str(row.get("title") or "").strip()
idea_md = str(row.get("idea_md") or "").strip()
explanation_md = str(row.get("explanation_md") or "").strip()
complexity = str(row.get("complexity") or "").strip()
code_cpp = str(row.get("code_cpp") or "")
tags = row.get("tags") if isinstance(row.get("tags"), list) else []
if not code_cpp.strip():
rejected.append("empty code_cpp")
continue
if "main(" not in code_cpp:
rejected.append("missing main()")
continue
if is_placeholder_code(code_cpp):
rejected.append("placeholder code")
continue
violations = cpp14_violations(code_cpp)
if violations:
rejected.append(f"C++17+ feature: {', '.join(violations[:3])}")
continue
ok_cpp14, compile_msg = compiles_under_cpp14(code_cpp)
if not ok_cpp14:
rejected.append(f"cannot compile with -std=gnu++14: {compile_msg}")
continue
normalized.append(
{
"title": title,
"idea_md": idea_md,
"explanation_md": explanation_md,
"complexity": complexity,
"code_cpp": code_cpp,
"tags": tags,
}
)
if len(normalized) >= max_solutions:
break
return normalized, rejected
def build_prompt(problem: Problem, max_solutions: int) -> str:
return f"""
请为下面这道 CSP 题生成 {max_solutions} 种不同思路的题解(可从模拟/贪心/DP/图论/数据结构等不同角度切入),并给出可直接提交的 C++14 参考代码。
硬性要求:
1. 必须只输出一个 JSON 对象,不能有任何 JSON 外文本。
2. JSON 必须符合下面格式,且 solutions 数组长度应为 {max_solutions}
3. 每个 code_cpp 必须是完整、可编译、可运行的 C++14 程序(包含 main 函数),不能出现 TODO、伪代码、占位注释、省略号。
4. 必须兼容 GCC 4.9/5.4 + -std=gnu++14严禁使用 C++17 及以上特性(如 structured bindings、if constexpr、std::optional、std::variant、std::any、std::string_view、<filesystem>)。
5. 建议使用标准头文件(如 <iostream>/<vector>/<algorithm> 等),不要使用 <bits/stdc++.h>。
6. main 必须是 int main(),并且 return 0;。若使用 scanf/printf 处理 long long,格式符必须用 %lld,不要用 %I64d。
7. 代码风格清晰,变量命名可读,注释简洁。
输出 JSON,格式固定
{{
"solutions": [
{{
"title": "解法标题",
"idea_md": "思路要点Markdown",
"explanation_md": "详细讲解Markdown",
"complexity": "时间/空间复杂度",
"code_cpp": "完整 C++14 代码",
"tags": ["标签1","标签2"]
}}
]
}}
题目信息:
- 题目:{problem.title}
- 难度:{problem.difficulty}
- 来源:{problem.source}
完整题面(原文,不做截断):
{problem.statement_md}
样例输入(原文):
{problem.sample_input}
样例输出(原文):
{problem.sample_output}
""".strip()
def parse_solutions_or_raise(content: str, max_solutions: int) -> list[dict[str, Any]]:
rows = extract_solution_rows(content)
if not rows:
raise RuntimeError("llm response missing valid solutions array")
normalized, rejected = normalize_solutions(rows, max_solutions=max_solutions)
if not normalized:
reason = f"; rejected sample: {rejected[0][:180]}" if rejected else ""
raise RuntimeError(f"llm response contains no runnable full code{reason}")
return normalized
def generate_solutions_with_llm(
prompt: str,
max_solutions: int,
timeout: int,
retries: int,
sleep_sec: float,
) -> list[dict[str, Any]]:
first_content = llm_request(prompt, timeout=timeout, retries=retries, sleep_sec=sleep_sec)
try:
return parse_solutions_or_raise(first_content, max_solutions=max_solutions)
except Exception as first_exc:
repair_prompt = (
"你上一条回复不符合要求,原因是:"
f"{str(first_exc)[:240]}。请只输出合法 JSON,并确保 code_cpp 是完整可运行 C++14 代码(兼容 -std=gnu++14\n\n"
+ prompt
)
second_content = llm_request(
repair_prompt,
timeout=timeout,
retries=retries,
sleep_sec=sleep_sec,
)
try:
return parse_solutions_or_raise(second_content, max_solutions=max_solutions)
except Exception as second_exc:
raise RuntimeError(
f"parse failed after retry: first={str(first_exc)[:200]}; second={str(second_exc)[:200]}"
) from second_exc
def llm_request(prompt: str, timeout: int, retries: int, sleep_sec: float) -> str:
url = os.getenv("OI_LLM_API_URL", "").strip()
api_key = os.getenv("OI_LLM_API_KEY", "").strip()
@@ -102,10 +394,14 @@ def llm_request(prompt: str, timeout: int, retries: int, sleep_sec: float) -> st
choices = payload.get("choices") or []
if not choices:
raise RuntimeError("llm response missing choices")
content = ((choices[0] or {}).get("message") or {}).get("content")
choice0 = choices[0] or {}
message = choice0.get("message") or {}
content = extract_message_text(message.get("content"))
if not content:
content = extract_message_text(choice0.get("text"))
if not content:
raise RuntimeError("llm response missing content")
return str(content)
return content
if last_error:
raise RuntimeError(f"llm request failed: {last_error}") from last_error
@@ -119,7 +415,20 @@ def fallback_solutions(max_solutions: int) -> list[dict[str, Any]]:
"idea_md": "按题意拆分步骤,先写可过样例的直观解法,再补边界处理。",
"explanation_md": "适用于数据范围较小或规则清晰的题。",
"complexity": "时间复杂度依题而定,通常 O(n)~O(n^2)",
"code_cpp": "// TODO: 请根据题意补全\n#include <bits/stdc++.h>\nusing namespace std;\nint main(){ios::sync_with_stdio(false);cin.tie(nullptr);return 0;}\n",
"code_cpp": """
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
// TODO: 请根据题意补全读入、核心逻辑与输出。
return 0;
}
""".strip(),
"tags": ["simulation", "implementation"],
},
{
@@ -127,13 +436,55 @@ def fallback_solutions(max_solutions: int) -> list[dict[str, Any]]:
"idea_md": "分析状态与重复计算,尝试用前缀和、贪心或动态规划优化。",
"explanation_md": "比直接模拟更稳定,通常能覆盖更大数据规模。",
"complexity": "通常优于朴素解法",
"code_cpp": "// TODO: 请根据题意补全\n#include <bits/stdc++.h>\nusing namespace std;\nint main(){ios::sync_with_stdio(false);cin.tie(nullptr);return 0;}\n",
"code_cpp": """
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
// TODO: 请根据题意补全优化版思路实现。
return 0;
}
""".strip(),
"tags": ["optimization", "dp"],
},
]
return base[: max(1, max_solutions)]
def format_cpp_code(raw: str) -> str:
code = (raw or "").replace("\r\n", "\n").replace("\r", "\n")
if not code.strip():
return ""
if not code.endswith("\n"):
code += "\n"
if not CLANG_FORMAT_BIN:
return code
try:
proc = subprocess.run(
[CLANG_FORMAT_BIN, "--style={BasedOnStyle: Google, IndentWidth: 2, ColumnLimit: 0}"],
input=code.encode("utf-8"),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=6,
)
if proc.returncode == 0 and proc.stdout:
out = proc.stdout.decode("utf-8", errors="ignore")
if out.strip():
return out if out.endswith("\n") else f"{out}\n"
except Exception:
pass
return code
def load_problem(conn: sqlite3.Connection, problem_id: int) -> Problem:
cur = conn.execute(
"SELECT id,title,statement_md,difficulty,source,sample_input,sample_output FROM problems WHERE id=?",
@@ -182,7 +533,7 @@ def store_solutions(conn: sqlite3.Connection, problem_id: int, rows: list[dict[s
idea_md = str(row.get("idea_md") or "").strip()
explanation_md = str(row.get("explanation_md") or "").strip()
code_cpp = str(row.get("code_cpp") or "").strip()
code_cpp = format_cpp_code(str(row.get("code_cpp") or ""))
complexity = str(row.get("complexity") or "").strip()
tags = row.get("tags") if isinstance(row.get("tags"), list) else []
@@ -239,68 +590,41 @@ def main() -> int:
try:
problem = load_problem(conn, args.problem_id)
requested_solutions = max(1, min(5, args.max_solutions))
allow_fallback = env_bool("CSP_SOLUTION_ALLOW_FALLBACK", False)
prompt = f"""
请为下面这道 CSP 题生成 {max(1, min(5, args.max_solutions))} 种不同思路的题解(可从不同角度切入,例如模拟/贪心/DP/数据结构),并给出 C++ 参考代码。
输出 JSON,格式固定
{{
"solutions": [
{{
"title": "解法标题",
"idea_md": "思路要点Markdown",
"explanation_md": "详细讲解Markdown",
"complexity": "时间/空间复杂度",
"code_cpp": "完整 C++17 代码",
"tags": ["标签1","标签2"]
}}
]
}}
题目:{problem.title}
难度:{problem.difficulty}
来源:{problem.source}
题面:
{problem.statement_md[:12000]}
样例输入:
{problem.sample_input[:1200]}
样例输出:
{problem.sample_output[:1200]}
""".strip()
prompt = build_prompt(problem, max_solutions=requested_solutions)
update_job(conn, args.job_id, progress=25, message="requesting llm", updated_at=now_sec())
source = "fallback"
source = "llm"
solutions: list[dict[str, Any]]
try:
content = llm_request(
prompt,
solutions = generate_solutions_with_llm(
prompt=prompt,
max_solutions=requested_solutions,
timeout=args.timeout,
retries=args.retries,
sleep_sec=args.retry_sleep_sec,
)
obj = extract_json_object(content)
raw = obj.get("solutions") if isinstance(obj, dict) else None
if not isinstance(raw, list) or len(raw) == 0:
raise RuntimeError("llm response missing solutions array")
solutions = [x for x in raw if isinstance(x, dict)]
if not solutions:
raise RuntimeError("llm response has empty valid solutions")
source = "llm"
except Exception:
except Exception as exc:
if not allow_fallback:
raise RuntimeError(f"llm generation failed: {str(exc)[:280]}") from exc
source = "fallback"
solutions = fallback_solutions(args.max_solutions)
solutions = solutions[: max(1, min(5, args.max_solutions))]
solutions = solutions[:requested_solutions]
update_job(conn, args.job_id, progress=70, message="writing solutions", updated_at=now_sec())
saved = store_solutions(conn, args.problem_id, solutions, source)
done_msg = f"completed: {saved} solutions ({source})"
update_job(
conn,
args.job_id,
status="completed",
progress=100,
message=f"completed: {saved} solutions ({source})",
message=done_msg,
finished_at=now_sec(),
updated_at=now_sec(),
)

查看文件

@@ -0,0 +1,487 @@
#!/usr/bin/env python3
"""Collect C++/CSP learning resources from the web, summarize with LLM, and upsert KB articles."""
from __future__ import annotations
import argparse
import html
import json
import os
import re
import sqlite3
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import requests
RETRYABLE_HTTP_CODES = {429, 500, 502, 503, 504}
DEFAULT_TIMEOUT = 30
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/123.0.0.0 Safari/537.36"
)
NO_PROXY = {"http": "", "https": ""}
@dataclass(frozen=True)
class ResourceSource:
label: str
url: str
@dataclass(frozen=True)
class TrackSpec:
slug: str
title: str
audience: str
objective: str
sources: tuple[ResourceSource, ...]
TRACKS: tuple[TrackSpec, ...] = (
TrackSpec(
slug="learning-roadmap-csp",
title="CSP 学习总路线C++ 基础 → CSP-J → CSP-S",
audience="准备长期学习 CSP 的初中/高中选手与家长",
objective="给出分阶段目标、周训练节奏、升阶检查清单和环境规范提醒。",
sources=(
ResourceSource("NOI 技术规则", "https://www.noi.cn/gynoi/jsgz/"),
ResourceSource(
"NOI Linux 与说明文档下载",
"https://www.noi.cn/gynoi/jsgz/2018-08-21/710467.shtml",
),
ResourceSource(
"NOI 标准竞赛环境说明2012",
"https://www.noi.cn/gynoi/jsgz/2018-08-13/710466.shtml",
),
ResourceSource("OI Wiki 竞赛路线图", "https://oi-wiki.org/contest/roadmap/"),
ResourceSource("OI Wiki 竞赛资源", "https://oi-wiki.org/contest/resources/"),
ResourceSource(
"cp-algorithms 首页",
"https://cp-algorithms.com/",
),
),
),
TrackSpec(
slug="learning-cpp-basic",
title="C++ 基础学习资料(面向 CSP",
audience="C++ 零基础或语法不稳,准备进入 CSP-J 的同学",
objective="梳理语法基础、STL 入门、输入输出与 C++14 兼容写法。",
sources=(
ResourceSource("cppreference C++ language", "https://en.cppreference.com/w/cpp/language.html"),
ResourceSource("OI Wiki 语言基础", "https://oi-wiki.org/lang/basic/"),
ResourceSource("OI Wiki 数组", "https://oi-wiki.org/lang/array/"),
ResourceSource("OI Wiki 循环", "https://oi-wiki.org/lang/loop/"),
ResourceSource("OI Wiki 运算符", "https://oi-wiki.org/lang/op/"),
ResourceSource("OI Wiki C++ 标准库", "https://oi-wiki.org/lang/csl/"),
ResourceSource("OI Wiki 文件操作", "https://oi-wiki.org/lang/file-op/"),
),
),
TrackSpec(
slug="learning-csp-j",
title="CSP-J 学习资料与训练路径",
audience="目标 CSP-J 提高组入门,正在建立算法基础的同学",
objective="覆盖模拟、枚举、前缀和、基础搜索与基础 DP,给出循序刷题方案。",
sources=(
ResourceSource("NOI 技术规则", "https://www.noi.cn/gynoi/jsgz/"),
ResourceSource("OI Wiki 模拟", "https://oi-wiki.org/basic/simulate/"),
ResourceSource("OI Wiki 枚举", "https://oi-wiki.org/basic/enumerate/"),
ResourceSource("OI Wiki 前缀和与差分", "https://oi-wiki.org/basic/prefix-sum/"),
ResourceSource("OI Wiki 动态规划基础", "https://oi-wiki.org/dp/basic/"),
ResourceSource("OI Wiki BFS", "https://oi-wiki.org/search/bfs/"),
ResourceSource("OI Wiki DFS", "https://oi-wiki.org/search/dfs/"),
ResourceSource("OI Wiki 常见错误", "https://oi-wiki.org/contest/common-mistakes/"),
),
),
TrackSpec(
slug="learning-csp-s",
title="CSP-S 学习资料与进阶路径",
audience="目标 CSP-S,已具备 CSP-J 基础并准备系统进阶的同学",
objective="覆盖数据结构、图论、字符串与 DP 进阶,强调复杂度与工程规范。",
sources=(
ResourceSource(
"NOI 标准竞赛环境说明2016",
"https://www.noi.cn/gynoi/jsgz/2018-08-13/710465.shtml",
),
ResourceSource(
"NOI 标准竞赛环境说明2012",
"https://www.noi.cn/gynoi/jsgz/2018-08-13/710466.shtml",
),
ResourceSource("OI Wiki 树状数组", "https://oi-wiki.org/ds/fenwick/"),
ResourceSource("OI Wiki 线段树", "https://oi-wiki.org/ds/seg/"),
ResourceSource("OI Wiki 最短路", "https://oi-wiki.org/graph/shortest-path/"),
ResourceSource("OI Wiki 强连通分量", "https://oi-wiki.org/graph/scc/"),
ResourceSource("OI Wiki 最大流", "https://oi-wiki.org/graph/flow/max-flow/"),
ResourceSource("OI Wiki 树上 DP", "https://oi-wiki.org/dp/tree/"),
ResourceSource("OI Wiki KMP", "https://oi-wiki.org/string/kmp/"),
ResourceSource(
"cp-algorithms Segment Tree",
"https://cp-algorithms.com/data_structures/segment_tree.html",
),
),
),
)
def now_sec() -> int:
return int(time.time())
def load_dotenv(path: Path) -> None:
if not path.exists():
return
for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if not key or key in os.environ:
continue
os.environ[key] = value.strip().strip("\"").strip("'")
def fetch_url(url: str, timeout: int) -> tuple[str, str]:
headers = {"User-Agent": USER_AGENT}
resp = requests.get(url, headers=headers, timeout=timeout, proxies=NO_PROXY)
resp.encoding = resp.apparent_encoding or resp.encoding
if resp.status_code >= 400:
raise RuntimeError(f"HTTP {resp.status_code}")
html_text = resp.text
return html_text, resp.url
def strip_html(html_text: str, max_chars: int) -> str:
text = re.sub(r"(?is)<(script|style|noscript|svg|canvas)[^>]*>.*?</\\1>", " ", html_text)
text = re.sub(r"(?is)<br\\s*/?>", "\n", text)
text = re.sub(r"(?is)</(p|div|section|article|h1|h2|h3|h4|h5|h6|li|tr|table|ul|ol)>", "\n", text)
text = re.sub(r"(?is)<[^>]+>", " ", text)
text = html.unescape(text)
text = text.replace("\r", "\n").replace("\xa0", " ")
text = re.sub(r"[ \t\f\v]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
text = "\n".join(line.strip() for line in text.splitlines() if line.strip())
if len(text) > max_chars:
text = text[:max_chars].rstrip() + "\n..."
return text
def extract_title(html_text: str, fallback: str) -> str:
match = re.search(r"(?is)<title>(.*?)</title>", html_text)
if not match:
return fallback
title = html.unescape(match.group(1))
title = re.sub(r"\s+", " ", title).strip()
if title:
return title
return fallback
def extract_message_text(content: Any) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
out: list[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str) and text.strip():
out.append(text.strip())
return "\n".join(out).strip()
if isinstance(content, dict):
text = content.get("text")
if isinstance(text, str):
return text.strip()
return ""
def llm_request(prompt: str, timeout: int, retries: int, retry_sleep_sec: float) -> str:
url = os.getenv("OI_LLM_API_URL", "").strip()
api_key = os.getenv("OI_LLM_API_KEY", "").strip()
model = os.getenv("OI_LLM_MODEL", "qwen3-max").strip()
if not url:
raise RuntimeError("missing OI_LLM_API_URL")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": model,
"stream": False,
"temperature": 0.2,
"messages": [
{
"role": "system",
"content": "你是资深信息学竞赛教练,请严格基于来源内容整理中文 Markdown 学习资料。",
},
{"role": "user", "content": prompt},
],
}
for idx in range(1, retries + 1):
try:
resp = requests.post(url, headers=headers, json=payload, timeout=timeout, proxies=NO_PROXY)
except requests.RequestException as exc:
if idx >= retries:
raise RuntimeError(f"llm request failed: {exc}") from exc
time.sleep(retry_sleep_sec * idx)
continue
if resp.status_code in RETRYABLE_HTTP_CODES:
if idx >= retries:
raise RuntimeError(f"llm retry exhausted: HTTP {resp.status_code}")
time.sleep(retry_sleep_sec * idx)
continue
if resp.status_code >= 400:
raise RuntimeError(f"llm request failed: HTTP {resp.status_code}: {resp.text[:300]}")
body = resp.json()
choices = body.get("choices") or []
if not choices:
raise RuntimeError("llm response missing choices")
message = (choices[0] or {}).get("message") or {}
text = extract_message_text(message.get("content"))
if not text:
text = extract_message_text((choices[0] or {}).get("text"))
if not text:
raise RuntimeError("llm response missing content")
return text
raise RuntimeError("llm request failed")
def remove_outer_markdown_fence(text: str) -> str:
raw = text.strip()
match = re.match(r"^```(?:markdown|md)?\\s*([\\s\\S]*?)\\s*```$", raw, flags=re.IGNORECASE)
if match:
return match.group(1).strip()
return raw
def build_prompt(spec: TrackSpec, source_materials: list[dict[str, str]]) -> str:
source_lines = "\n".join([f"- {it['label']}: {it['url']}" for it in source_materials])
snippets: list[str] = []
for idx, item in enumerate(source_materials, start=1):
snippets.append(
"\n".join(
[
f"[来源 {idx}] {item['label']}",
f"URL: {item['url']}",
f"页面标题: {item['title']}",
"摘录:",
item["snippet"],
]
)
)
all_snippets = "\n\n".join(snippets)
return f"""
请整理一篇中文 Markdown 学习资料文章,主题:{spec.title}
目标读者:{spec.audience}
目标:{spec.objective}
硬性要求:
1. 只输出 Markdown 正文,不要输出解释、前言、JSON 或代码块外的多余说明。
2. 正文不少于 900 字,内容要具体可执行,不能只给提纲。
3. 内容结构至少包含:
- 学习目标
- 知识图谱(按优先级)
- 分阶段训练计划(建议按周)
- 常见失分点与避坑清单
- C++14 / 评测环境规范提醒(明确写出:优先 C++14;避免 C++17 特性;long long 用 %lld;如赛方要求则使用 freopen;main 返回 int 且 return 0
4. 结尾必须包含“## 参考来源”章节,并且只列出本次给定来源,使用 Markdown 链接。
5. 不要编造具体年份政策细节;对于地方性要求,写“以当年官方通知为准”。
6. 风格要可执行,尽量给出检查清单与训练顺序。
可用来源列表:
{source_lines}
来源摘录:
{all_snippets}
""".strip()
def fallback_markdown(spec: TrackSpec, source_materials: list[dict[str, str]], error_text: str) -> str:
lines = [
f"# {spec.title}",
"",
"## 学习目标",
f"- 读者:{spec.audience}",
f"- 目标:{spec.objective}",
"",
"## 训练建议",
"- 每周固定做 3~5 道题:先基础题,再专项题,再限时套题。",
"- 每次训练后补齐错因:读题失误、边界遗漏、复杂度超限、代码规范错误。",
"- 建议建立个人模板并反复演练输入输出与边界处理。",
"",
"## C++14 / 评测环境规范提醒",
"- 统一按 C++14 编译,避免 C++17 及以上语法。",
"- `long long` 输入输出优先使用 `%lld`。",
"- 若赛方要求文件读写,使用 `freopen(\"xxx.in\", \"r\", stdin)` / `freopen(\"xxx.out\", \"w\", stdout)`。",
"- `main` 必须是 `int main()` 且 `return 0;`。",
"- 地方考区细则每年会更新,务必以当年官方通知为准。",
"",
"## 参考来源",
]
for item in source_materials:
lines.append(f"- [{item['label']}]({item['url']})")
lines.extend(
[
"",
"> 说明:本条目由自动整理流程生成。",
f"> LLM 调用失败原因:{error_text[:180]}",
]
)
return "\n".join(lines).strip() + "\n"
def upsert_article(conn: sqlite3.Connection, slug: str, title: str, content_md: str, ts: int) -> None:
conn.execute(
"""
INSERT INTO kb_articles(slug, title, content_md, created_at)
VALUES(?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET
title=excluded.title,
content_md=excluded.content_md,
created_at=excluded.created_at
""",
(slug, title, content_md, ts),
)
def guess_db_path(cli_path: str | None) -> str:
if cli_path:
return cli_path
candidates = [
os.getenv("CSP_DB_PATH", "").strip(),
"/data/csp.db",
"/var/lib/docker/volumes/csp_csp_data/_data/csp.db",
str(Path(__file__).resolve().parents[1] / "data" / "csp.db"),
]
for path in candidates:
if path and Path(path).exists():
return path
return "/data/csp.db"
def collect_source_materials(
spec: TrackSpec,
timeout: int,
max_chars_per_source: int,
max_sources_per_track: int,
) -> list[dict[str, str]]:
materials: list[dict[str, str]] = []
for src in spec.sources[:max_sources_per_track]:
try:
html_text, final_url = fetch_url(src.url, timeout=timeout)
title = extract_title(html_text, src.label)
if "404" in title.lower() and "not found" in title.lower():
raise RuntimeError(f"unexpected 404 title: {title}")
snippet = strip_html(html_text, max_chars=max_chars_per_source)
if not snippet:
raise RuntimeError("empty extracted text")
materials.append(
{
"label": src.label,
"url": final_url,
"title": title,
"snippet": snippet,
}
)
print(f"[fetch] ok: {spec.slug} <- {src.url}")
except Exception as exc:
print(f"[fetch] skip: {spec.slug} <- {src.url} ({exc})", file=sys.stderr)
if not materials:
raise RuntimeError(f"no source material collected for {spec.slug}")
return materials
def main() -> int:
parser = argparse.ArgumentParser(description="Import web learning resources into kb_articles")
parser.add_argument("--db-path", default="", help="SQLite path (default: auto-detect)")
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
parser.add_argument("--retries", type=int, default=5)
parser.add_argument("--retry-sleep-sec", type=float, default=1.5)
parser.add_argument("--max-chars-per-source", type=int, default=900)
parser.add_argument("--max-sources-per-track", type=int, default=3)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument(
"--only",
default="",
help="Only process one track slug, e.g. learning-csp-j",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
load_dotenv(repo_root / ".env")
db_path = guess_db_path(args.db_path or None)
print(f"[db] using: {db_path}")
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=10000")
processed = 0
for spec in TRACKS:
if args.only and spec.slug != args.only.strip():
continue
print(f"[track] start: {spec.slug}")
materials = collect_source_materials(
spec,
timeout=max(5, args.timeout),
max_chars_per_source=max(600, args.max_chars_per_source),
max_sources_per_track=max(1, args.max_sources_per_track),
)
prompt = build_prompt(spec, materials)
markdown: str
try:
markdown = llm_request(
prompt=prompt,
timeout=max(15, args.timeout * 2),
retries=max(1, args.retries),
retry_sleep_sec=max(0.2, args.retry_sleep_sec),
)
markdown = remove_outer_markdown_fence(markdown)
if os.getenv("KB_IMPORT_DEBUG", "").strip():
preview = markdown.strip().replace("\n", "\\n")
print(
f"[llm] raw: {spec.slug} len={len(markdown.strip())} preview={preview[:220]}",
file=sys.stderr,
)
if len(markdown.strip()) < 120:
raise RuntimeError("llm output too short")
print(f"[llm] ok: {spec.slug}")
except Exception as exc:
print(f"[llm] fallback: {spec.slug} ({exc})", file=sys.stderr)
markdown = fallback_markdown(spec, materials, str(exc))
content = markdown.strip() + "\n"
if args.dry_run:
print(f"[dry-run] {spec.slug}: {len(content)} chars")
processed += 1
continue
upsert_article(conn, spec.slug, spec.title, content, now_sec())
conn.commit()
processed += 1
print(f"[db] upserted: {spec.slug}")
conn.close()
print(f"[done] processed tracks: {processed}")
return 0 if processed > 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

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