feat: expand platform management, admin controls, and learning workflows
这个提交包含在:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -21,3 +21,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.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
|
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`。
|
如果你还要使用旧的 PDF + LLM 导入流程,可手动运行 `scripts/import_winterant_oi.py`。
|
||||||
|
|
||||||
## 7. CSP-J 题目自动生成(RAG + 去重)
|
## 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:
|
public:
|
||||||
METHOD_LIST_BEGIN
|
METHOD_LIST_BEGIN
|
||||||
ADD_METHOD_TO(MetaController::openapi, "/api/openapi.json", drogon::Get);
|
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);
|
ADD_METHOD_TO(MetaController::mcp, "/api/v1/mcp", drogon::Post);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
void openapi(const drogon::HttpRequestPtr& req,
|
void openapi(const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
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,
|
void mcp(const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
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::submitProblem, "/api/v1/problems/{1}/submit", drogon::Post);
|
||||||
ADD_METHOD_TO(SubmissionController::listSubmissions, "/api/v1/submissions", drogon::Get);
|
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::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);
|
ADD_METHOD_TO(SubmissionController::runCpp, "/api/v1/run/cpp", drogon::Post);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ class SubmissionController : public drogon::HttpController<SubmissionController>
|
|||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||||
int64_t submission_id);
|
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,
|
void runCpp(const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ struct Submission {
|
|||||||
std::string code;
|
std::string code;
|
||||||
SubmissionStatus status = SubmissionStatus::Pending;
|
SubmissionStatus status = SubmissionStatus::Pending;
|
||||||
int32_t score = 0;
|
int32_t score = 0;
|
||||||
|
int32_t rating_delta = 0;
|
||||||
int32_t time_ms = 0;
|
int32_t time_ms = 0;
|
||||||
int32_t memory_kb = 0;
|
int32_t memory_kb = 0;
|
||||||
std::string compile_log;
|
std::string compile_log;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class AuthService {
|
|||||||
// Throws on error; for controller we will catch and convert to JSON.
|
// Throws on error; for controller we will catch and convert to JSON.
|
||||||
AuthResult Register(const std::string& username, const std::string& password);
|
AuthResult Register(const std::string& username, const std::string& password);
|
||||||
AuthResult Login(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);
|
std::optional<int> VerifyToken(const std::string& token);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
namespace csp::services {
|
namespace csp::services {
|
||||||
|
|
||||||
struct ImportRunOptions {
|
struct ImportRunOptions {
|
||||||
|
std::string mode = "luogu";
|
||||||
bool clear_all_problems = false;
|
bool clear_all_problems = false;
|
||||||
|
std::string local_pdf_dir;
|
||||||
|
int target_total = 5000;
|
||||||
|
int workers = 3;
|
||||||
};
|
};
|
||||||
|
|
||||||
class ImportRunner {
|
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
|
#pragma once
|
||||||
|
|
||||||
|
#include "csp/db/sqlite_db.h"
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <deque>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <set>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
namespace csp::services {
|
namespace csp::services {
|
||||||
|
|
||||||
class ProblemSolutionRunner {
|
class ProblemSolutionRunner {
|
||||||
public:
|
public:
|
||||||
|
struct TriggerMissingSummary {
|
||||||
|
int missing_total = 0;
|
||||||
|
int candidate_count = 0;
|
||||||
|
int queued_count = 0;
|
||||||
|
};
|
||||||
|
|
||||||
static ProblemSolutionRunner& Instance();
|
static ProblemSolutionRunner& Instance();
|
||||||
|
|
||||||
void Configure(std::string db_path);
|
void Configure(std::string db_path);
|
||||||
|
|
||||||
bool TriggerAsync(int64_t problem_id, int64_t job_id, int max_solutions);
|
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;
|
bool IsRunning(int64_t problem_id) const;
|
||||||
|
size_t PendingCount() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct Task {
|
||||||
|
int64_t problem_id = 0;
|
||||||
|
int64_t job_id = 0;
|
||||||
|
int max_solutions = 3;
|
||||||
|
};
|
||||||
|
|
||||||
ProblemSolutionRunner() = default;
|
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_;
|
std::string db_path_;
|
||||||
mutable std::mutex mu_;
|
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
|
} // namespace csp::services
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct ProblemSolution {
|
|||||||
struct ProblemSolutionJob {
|
struct ProblemSolutionJob {
|
||||||
int64_t id = 0;
|
int64_t id = 0;
|
||||||
int64_t problem_id = 0;
|
int64_t problem_id = 0;
|
||||||
|
std::string problem_title;
|
||||||
std::string status;
|
std::string status;
|
||||||
int progress = 0;
|
int progress = 0;
|
||||||
std::string message;
|
std::string message;
|
||||||
@@ -60,7 +61,13 @@ class ProblemWorkspaceService {
|
|||||||
|
|
||||||
int64_t CreateSolutionJob(int64_t problem_id, int64_t created_by, int max_solutions);
|
int64_t CreateSolutionJob(int64_t problem_id, int64_t created_by, int max_solutions);
|
||||||
std::optional<ProblemSolutionJob> GetLatestSolutionJob(int64_t problem_id);
|
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);
|
std::vector<ProblemSolution> ListSolutions(int64_t problem_id);
|
||||||
|
int CountProblemsWithoutSolutions();
|
||||||
|
std::vector<int64_t> ListProblemIdsWithoutSolutions(int limit,
|
||||||
|
bool exclude_queued_or_running_jobs);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
db::SqliteDb& db_;
|
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 {
|
namespace csp::services {
|
||||||
|
|
||||||
|
struct UserListResult {
|
||||||
|
std::vector<domain::GlobalLeaderboardEntry> items;
|
||||||
|
int total_count = 0;
|
||||||
|
};
|
||||||
|
|
||||||
class UserService {
|
class UserService {
|
||||||
public:
|
public:
|
||||||
explicit UserService(db::SqliteDb& db) : db_(db) {}
|
explicit UserService(db::SqliteDb& db) : db_(db) {}
|
||||||
|
|
||||||
std::optional<domain::User> GetById(int64_t id);
|
std::optional<domain::User> GetById(int64_t id);
|
||||||
std::vector<domain::GlobalLeaderboardEntry> GlobalLeaderboard(int limit = 100);
|
std::vector<domain::GlobalLeaderboardEntry> GlobalLeaderboard(int limit = 100);
|
||||||
|
UserListResult ListUsers(int page, int page_size);
|
||||||
|
void SetRating(int64_t user_id, int rating);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
db::SqliteDb& db_;
|
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/app_state.h"
|
||||||
#include "csp/services/import_runner.h"
|
#include "csp/services/import_runner.h"
|
||||||
#include "csp/services/import_service.h"
|
#include "csp/services/import_service.h"
|
||||||
|
#include "csp/services/user_service.h"
|
||||||
|
#include "http_auth.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace csp::controllers {
|
namespace csp::controllers {
|
||||||
@@ -40,6 +43,25 @@ int ParsePositiveInt(const std::string& s,
|
|||||||
return std::max(min_value, std::min(max_value, v));
|
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 ToJson(const services::ImportJob& job) {
|
||||||
Json::Value j;
|
Json::Value j;
|
||||||
j["id"] = Json::Int64(job.id);
|
j["id"] = Json::Int64(job.id);
|
||||||
@@ -94,9 +116,11 @@ Json::Value ToJson(const services::ImportJobItem& item) {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void ImportController::latestJob(
|
void ImportController::latestJob(
|
||||||
const drogon::HttpRequestPtr& /*req*/,
|
const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||||
try {
|
try {
|
||||||
|
if (!RequireAdminUserId(req, cb).has_value()) return;
|
||||||
|
|
||||||
services::ImportService svc(csp::AppState::Instance().db());
|
services::ImportService svc(csp::AppState::Instance().db());
|
||||||
Json::Value payload;
|
Json::Value payload;
|
||||||
const auto job = svc.GetLatestJob();
|
const auto job = svc.GetLatestJob();
|
||||||
@@ -113,10 +137,12 @@ void ImportController::latestJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ImportController::jobById(
|
void ImportController::jobById(
|
||||||
const drogon::HttpRequestPtr& /*req*/,
|
const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||||
int64_t job_id) {
|
int64_t job_id) {
|
||||||
try {
|
try {
|
||||||
|
if (!RequireAdminUserId(req, cb).has_value()) return;
|
||||||
|
|
||||||
services::ImportService svc(csp::AppState::Instance().db());
|
services::ImportService svc(csp::AppState::Instance().db());
|
||||||
const auto job = svc.GetById(job_id);
|
const auto job = svc.GetById(job_id);
|
||||||
if (!job.has_value()) {
|
if (!job.has_value()) {
|
||||||
@@ -136,6 +162,8 @@ void ImportController::jobItems(
|
|||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||||
int64_t job_id) {
|
int64_t job_id) {
|
||||||
try {
|
try {
|
||||||
|
if (!RequireAdminUserId(req, cb).has_value()) return;
|
||||||
|
|
||||||
services::ImportJobItemQuery query;
|
services::ImportJobItemQuery query;
|
||||||
query.status = req->getParameter("status");
|
query.status = req->getParameter("status");
|
||||||
query.page = ParsePositiveInt(req->getParameter("page"), 1, 1, 100000);
|
query.page = ParsePositiveInt(req->getParameter("page"), 1, 1, 100000);
|
||||||
@@ -164,12 +192,26 @@ void ImportController::runJob(
|
|||||||
const drogon::HttpRequestPtr& req,
|
const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||||
try {
|
try {
|
||||||
|
if (!RequireAdminUserId(req, cb).has_value()) return;
|
||||||
|
|
||||||
services::ImportRunOptions opts;
|
services::ImportRunOptions opts;
|
||||||
const auto json = req->getJsonObject();
|
const auto json = req->getJsonObject();
|
||||||
if (json) {
|
if (json) {
|
||||||
|
if ((*json).isMember("mode")) {
|
||||||
|
opts.mode = (*json)["mode"].asString();
|
||||||
|
}
|
||||||
opts.clear_all_problems =
|
opts.clear_all_problems =
|
||||||
(*json).isMember("clear_all_problems") &&
|
(*json).isMember("clear_all_problems") &&
|
||||||
(*json)["clear_all_problems"].asBool();
|
(*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 =
|
const bool started =
|
||||||
services::ImportRunner::Instance().TriggerAsync("manual", opts);
|
services::ImportRunner::Instance().TriggerAsync("manual", opts);
|
||||||
@@ -181,6 +223,7 @@ void ImportController::runJob(
|
|||||||
Json::Value payload;
|
Json::Value payload;
|
||||||
payload["started"] = true;
|
payload["started"] = true;
|
||||||
payload["running"] = true;
|
payload["running"] = true;
|
||||||
|
payload["mode"] = opts.mode;
|
||||||
cb(JsonOk(payload));
|
cb(JsonOk(payload));
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
#include "csp/services/problem_solution_runner.h"
|
#include "csp/services/problem_solution_runner.h"
|
||||||
#include "csp/services/problem_workspace_service.h"
|
#include "csp/services/problem_workspace_service.h"
|
||||||
#include "csp/services/submission_service.h"
|
#include "csp/services/submission_service.h"
|
||||||
|
#include "csp/services/user_service.h"
|
||||||
#include "http_auth.h"
|
#include "http_auth.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace csp::controllers {
|
namespace csp::controllers {
|
||||||
@@ -47,6 +49,25 @@ int ParsePositiveInt(const std::string& s,
|
|||||||
return std::max(min_value, std::min(max_value, v));
|
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 BuildOpenApiSpec() {
|
||||||
Json::Value root;
|
Json::Value root;
|
||||||
root["openapi"] = "3.1.0";
|
root["openapi"] = "3.1.0";
|
||||||
@@ -173,6 +194,8 @@ void MetaController::backendLogs(
|
|||||||
const drogon::HttpRequestPtr& req,
|
const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||||
try {
|
try {
|
||||||
|
if (!RequireAdminUserId(req, cb).has_value()) return;
|
||||||
|
|
||||||
const int limit =
|
const int limit =
|
||||||
ParsePositiveInt(req->getParameter("limit"), 100, 1, 500);
|
ParsePositiveInt(req->getParameter("limit"), 100, 1, 500);
|
||||||
const int running_limit =
|
const int running_limit =
|
||||||
@@ -274,9 +297,11 @@ void MetaController::backendLogs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MetaController::kbRefreshStatus(
|
void MetaController::kbRefreshStatus(
|
||||||
const drogon::HttpRequestPtr& /*req*/,
|
const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||||
try {
|
try {
|
||||||
|
if (!RequireAdminUserId(req, cb).has_value()) return;
|
||||||
|
|
||||||
const auto& runner = services::KbImportRunner::Instance();
|
const auto& runner = services::KbImportRunner::Instance();
|
||||||
Json::Value payload;
|
Json::Value payload;
|
||||||
payload["running"] = runner.IsRunning();
|
payload["running"] = runner.IsRunning();
|
||||||
@@ -299,12 +324,7 @@ void MetaController::triggerKbRefresh(
|
|||||||
const drogon::HttpRequestPtr& req,
|
const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||||
try {
|
try {
|
||||||
std::string auth_error;
|
if (!RequireAdminUserId(req, cb).has_value()) return;
|
||||||
const auto user_id = GetAuthedUserId(req, auth_error);
|
|
||||||
if (!user_id.has_value()) {
|
|
||||||
cb(JsonError(drogon::k401Unauthorized, auth_error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto& runner = services::KbImportRunner::Instance();
|
auto& runner = services::KbImportRunner::Instance();
|
||||||
const bool started = runner.TriggerAsync("manual");
|
const bool started = runner.TriggerAsync("manual");
|
||||||
@@ -332,12 +352,8 @@ void MetaController::triggerMissingSolutions(
|
|||||||
const drogon::HttpRequestPtr& req,
|
const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||||
try {
|
try {
|
||||||
std::string auth_error;
|
const auto user_id = RequireAdminUserId(req, cb);
|
||||||
const auto user_id = GetAuthedUserId(req, auth_error);
|
if (!user_id.has_value()) return;
|
||||||
if (!user_id.has_value()) {
|
|
||||||
cb(JsonError(drogon::k401Unauthorized, auth_error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int limit = 50000;
|
int limit = 50000;
|
||||||
int max_solutions = 3;
|
int max_solutions = 3;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "csp/services/problem_service.h"
|
#include "csp/services/problem_service.h"
|
||||||
#include "csp/services/problem_solution_runner.h"
|
#include "csp/services/problem_solution_runner.h"
|
||||||
#include "csp/services/problem_workspace_service.h"
|
#include "csp/services/problem_workspace_service.h"
|
||||||
|
#include "csp/services/solution_access_service.h"
|
||||||
#include "http_auth.h"
|
#include "http_auth.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -192,7 +193,7 @@ void ProblemController::saveDraft(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ProblemController::listSolutions(
|
void ProblemController::listSolutions(
|
||||||
const drogon::HttpRequestPtr& /*req*/,
|
const drogon::HttpRequestPtr& req,
|
||||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||||
int64_t problem_id) {
|
int64_t problem_id) {
|
||||||
try {
|
try {
|
||||||
@@ -203,9 +204,43 @@ void ProblemController::listSolutions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auto rows = svc.ListSolutions(problem_id);
|
const auto rows = svc.ListSolutions(problem_id);
|
||||||
|
const bool has_solutions = !rows.empty();
|
||||||
const auto latest_job = svc.GetLatestSolutionJob(problem_id);
|
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);
|
Json::Value arr(Json::arrayValue);
|
||||||
|
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) {
|
for (const auto& item : rows) {
|
||||||
Json::Value j;
|
Json::Value j;
|
||||||
j["id"] = Json::Int64(item.id);
|
j["id"] = Json::Int64(item.id);
|
||||||
@@ -222,9 +257,19 @@ void ProblemController::listSolutions(
|
|||||||
j["updated_at"] = Json::Int64(item.updated_at);
|
j["updated_at"] = Json::Int64(item.updated_at);
|
||||||
arr.append(j);
|
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;
|
Json::Value payload;
|
||||||
payload["items"] = arr;
|
payload["items"] = arr;
|
||||||
|
payload["has_solutions"] = has_solutions;
|
||||||
|
payload["answer_status"] = has_solutions ? "已有" : "待生成";
|
||||||
|
payload["access"] = access;
|
||||||
payload["runner_running"] =
|
payload["runner_running"] =
|
||||||
services::ProblemSolutionRunner::Instance().IsRunning(problem_id);
|
services::ProblemSolutionRunner::Instance().IsRunning(problem_id);
|
||||||
if (latest_job.has_value()) {
|
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 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);
|
problem_id, job_id, max_solutions);
|
||||||
if (!started) {
|
if (!queued) {
|
||||||
cb(JsonError(drogon::k409Conflict, "solution generation is already running"));
|
cb(JsonError(drogon::k500InternalServerError,
|
||||||
|
"solution generation queue is unavailable"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Json::Value payload;
|
Json::Value payload;
|
||||||
|
payload["queued"] = true;
|
||||||
payload["started"] = true;
|
payload["started"] = true;
|
||||||
payload["job_id"] = Json::Int64(job_id);
|
payload["job_id"] = Json::Int64(job_id);
|
||||||
|
payload["pending_jobs"] = Json::UInt64(runner.PendingCount());
|
||||||
cb(JsonOk(payload));
|
cb(JsonOk(payload));
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||||
|
|||||||
@@ -4,11 +4,16 @@
|
|||||||
#include "csp/domain/enum_strings.h"
|
#include "csp/domain/enum_strings.h"
|
||||||
#include "csp/domain/json.h"
|
#include "csp/domain/json.h"
|
||||||
#include "csp/services/contest_service.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 "csp/services/submission_service.h"
|
||||||
#include "http_auth.h"
|
#include "http_auth.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -49,6 +54,32 @@ std::optional<int64_t> ParseOptionalInt64(const std::string& s) {
|
|||||||
return std::stoll(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
|
} // namespace
|
||||||
|
|
||||||
void SubmissionController::submitProblem(
|
void SubmissionController::submitProblem(
|
||||||
@@ -153,7 +184,91 @@ void SubmissionController::getSubmission(
|
|||||||
cb(JsonError(drogon::k404NotFound, "submission not found"));
|
cb(JsonError(drogon::k404NotFound, "submission not found"));
|
||||||
return;
|
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) {
|
} catch (const std::exception& e) {
|
||||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ Json::Value ToJson(const Submission& s) {
|
|||||||
j["language"] = ToString(s.language);
|
j["language"] = ToString(s.language);
|
||||||
j["status"] = ToString(s.status);
|
j["status"] = ToString(s.status);
|
||||||
j["score"] = s.score;
|
j["score"] = s.score;
|
||||||
|
j["rating_delta"] = s.rating_delta;
|
||||||
j["time_ms"] = s.time_ms;
|
j["time_ms"] = s.time_ms;
|
||||||
j["memory_kb"] = s.memory_kb;
|
j["memory_kb"] = s.memory_kb;
|
||||||
j["compile_log"] = s.compile_log;
|
j["compile_log"] = s.compile_log;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "csp/app_state.h"
|
#include "csp/app_state.h"
|
||||||
#include "csp/services/auth_service.h"
|
#include "csp/services/auth_service.h"
|
||||||
#include "csp/services/import_runner.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_gen_runner.h"
|
||||||
#include "csp/services/problem_solution_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::AppState::Instance().Init(db_path);
|
||||||
csp::services::ImportRunner::Instance().Configure(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::ProblemSolutionRunner::Instance().Configure(db_path);
|
||||||
csp::services::ProblemGenRunner::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) {
|
if (u && p && std::string(u).size() > 0 && std::string(p).size() > 0) {
|
||||||
try {
|
try {
|
||||||
csp::services::AuthService auth(csp::AppState::Instance().db());
|
csp::services::AuthService auth(csp::AppState::Instance().db());
|
||||||
|
try {
|
||||||
auth.Register(u, p);
|
auth.Register(u, p);
|
||||||
LOG_INFO << "seed admin user created: " << u;
|
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) {
|
} catch (const std::exception& e) {
|
||||||
// Most likely UNIQUE constraint (already exists)
|
|
||||||
LOG_INFO << "seed admin user skipped: " << e.what();
|
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.
|
// Auto-run PDF -> LLM import workflow on startup unless explicitly disabled.
|
||||||
csp::services::ImportRunner::Instance().AutoStartIfEnabled();
|
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.
|
// Auto-generate one CSP-J new problem (RAG + dedupe) on startup by default.
|
||||||
csp::services::ProblemGenRunner::Instance().AutoStartIfEnabled();
|
csp::services::ProblemGenRunner::Instance().AutoStartIfEnabled();
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
#include "csp/services/import_runner.h"
|
#include "csp/services/import_runner.h"
|
||||||
|
|
||||||
#include <drogon/drogon.h>
|
#include <drogon/drogon.h>
|
||||||
|
#include <json/json.h>
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#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 ShellQuote(const std::string& text) {
|
||||||
std::string out = "'";
|
std::string out = "'";
|
||||||
for (char c : text) {
|
for (char c : text) {
|
||||||
@@ -52,7 +64,16 @@ std::string ShellQuote(const std::string& text) {
|
|||||||
return out;
|
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");
|
const char* env_path = std::getenv("OI_IMPORT_SCRIPT_PATH");
|
||||||
if (env_path && std::filesystem::exists(env_path)) return env_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";
|
return "/app/scripts/import_luogu_csp.py";
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string BuildCommand(const std::string& db_path,
|
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 std::string& trigger,
|
||||||
const ImportRunOptions& options) {
|
const ImportRunOptions& options) {
|
||||||
const std::string script_path = ResolveScriptPath();
|
const std::string script_path = ResolveLuoguScriptPath();
|
||||||
const int workers = std::max(1, EnvInt("OI_IMPORT_WORKERS", 3));
|
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 llm_limit = EnvInt("OI_IMPORT_LLM_LIMIT", 0);
|
||||||
const int max_problems = EnvInt("OI_IMPORT_MAX_PROBLEMS", 0);
|
const int max_problems = EnvInt("OI_IMPORT_MAX_PROBLEMS", 0);
|
||||||
const bool skip_llm = EnvBool("OI_IMPORT_SKIP_LLM", false);
|
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("winterant/oi");
|
||||||
|
|
||||||
std::string cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
|
std::string cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
|
||||||
ShellQuote(db_path) + " --workers " +
|
ShellQuote(db_path) + " --workers " + std::to_string(workers) +
|
||||||
std::to_string(workers) + " --job-trigger " +
|
" --job-trigger " + ShellQuote(trigger);
|
||||||
ShellQuote(trigger);
|
|
||||||
|
|
||||||
if (max_problems > 0) cmd += " --max-problems " + std::to_string(max_problems);
|
if (max_problems > 0) cmd += " --max-problems " + std::to_string(max_problems);
|
||||||
if (skip_llm) cmd += " --skip-llm";
|
if (skip_llm) cmd += " --skip-llm";
|
||||||
@@ -107,6 +144,125 @@ std::string BuildCommand(const std::string& db_path,
|
|||||||
return cmd;
|
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
|
} // namespace
|
||||||
|
|
||||||
ImportRunner& ImportRunner::Instance() {
|
ImportRunner& ImportRunner::Instance() {
|
||||||
@@ -115,8 +271,21 @@ ImportRunner& ImportRunner::Instance() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ImportRunner::Configure(std::string db_path) {
|
void ImportRunner::Configure(std::string db_path) {
|
||||||
|
std::optional<ImportRunOptions> resume_opts;
|
||||||
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
db_path_ = std::move(db_path);
|
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,
|
bool ImportRunner::TriggerAsync(const std::string& trigger,
|
||||||
@@ -147,7 +316,14 @@ bool ImportRunner::TriggerAsync(const std::string& trigger,
|
|||||||
|
|
||||||
void ImportRunner::AutoStartIfEnabled() {
|
void ImportRunner::AutoStartIfEnabled() {
|
||||||
if (!EnvBool("OI_IMPORT_AUTO_RUN", true)) return;
|
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) {
|
if (started) {
|
||||||
LOG_INFO << "import runner auto-started";
|
LOG_INFO << "import runner auto-started";
|
||||||
} else {
|
} 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_solution_runner.h"
|
||||||
|
|
||||||
|
#include "csp/services/problem_workspace_service.h"
|
||||||
|
|
||||||
|
#include <drogon/drogon.h>
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <chrono>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
@@ -39,6 +46,43 @@ std::string ResolveScriptPath() {
|
|||||||
return "/app/scripts/generate_problem_solutions.py";
|
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
|
} // namespace
|
||||||
|
|
||||||
ProblemSolutionRunner& ProblemSolutionRunner::Instance() {
|
ProblemSolutionRunner& ProblemSolutionRunner::Instance() {
|
||||||
@@ -49,38 +93,225 @@ ProblemSolutionRunner& ProblemSolutionRunner::Instance() {
|
|||||||
void ProblemSolutionRunner::Configure(std::string db_path) {
|
void ProblemSolutionRunner::Configure(std::string db_path) {
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
db_path_ = std::move(db_path);
|
db_path_ = std::move(db_path);
|
||||||
|
RecoverQueuedJobsLocked();
|
||||||
|
StartWorkerIfNeededLocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ProblemSolutionRunner::TriggerAsync(int64_t problem_id,
|
bool ProblemSolutionRunner::TriggerAsync(int64_t problem_id,
|
||||||
int64_t job_id,
|
int64_t job_id,
|
||||||
int max_solutions) {
|
int max_solutions) {
|
||||||
std::string cmd;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
if (db_path_.empty()) return false;
|
if (db_path_.empty()) return false;
|
||||||
if (running_problem_ids_.count(problem_id) > 0) return false;
|
Task task;
|
||||||
running_problem_ids_.insert(problem_id);
|
task.problem_id = problem_id;
|
||||||
|
task.job_id = job_id;
|
||||||
const std::string script_path = ResolveScriptPath();
|
task.max_solutions = std::max(1, std::min(5, max_solutions));
|
||||||
const int clamped = std::max(1, std::min(5, max_solutions));
|
queue_.push_back(task);
|
||||||
cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
|
++pending_problem_counts_[problem_id];
|
||||||
ShellQuote(db_path_) + " --problem-id " + std::to_string(problem_id) +
|
++pending_jobs_;
|
||||||
" --job-id " + std::to_string(job_id) + " --max-solutions " +
|
StartWorkerIfNeededLocked();
|
||||||
std::to_string(clamped);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::thread([this, problem_id, command = std::move(cmd)]() {
|
ProblemSolutionRunner::TriggerMissingSummary
|
||||||
std::system(command.c_str());
|
ProblemSolutionRunner::TriggerMissingAsync(db::SqliteDb& db,
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
int64_t created_by,
|
||||||
running_problem_ids_.erase(problem_id);
|
int max_solutions,
|
||||||
}).detach();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
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 {
|
bool ProblemSolutionRunner::IsRunning(int64_t problem_id) const {
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
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
|
} // namespace csp::services
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
namespace csp::services {
|
namespace csp::services {
|
||||||
|
|
||||||
@@ -191,6 +192,116 @@ std::optional<ProblemSolutionJob> ProblemWorkspaceService::GetLatestSolutionJob(
|
|||||||
return row;
|
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) {
|
std::vector<ProblemSolution> ProblemWorkspaceService::ListSolutions(int64_t problem_id) {
|
||||||
sqlite3* db = db_.raw();
|
sqlite3* db = db_.raw();
|
||||||
sqlite3_stmt* stmt = nullptr;
|
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;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
|
||||||
std::string sql =
|
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 "
|
"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,"
|
||||||
"FROM submissions WHERE 1=1 ";
|
"CASE WHEN s.status='AC' AND NOT EXISTS ("
|
||||||
if (user_id.has_value()) sql += "AND user_id=? ";
|
" SELECT 1 FROM submissions s2 "
|
||||||
if (problem_id.has_value()) sql += "AND problem_id=? ";
|
" WHERE s2.user_id=s.user_id AND s2.problem_id=s.problem_id AND s2.status='AC' AND s2.id<s.id"
|
||||||
if (contest_id.has_value()) sql += "AND contest_id=? ";
|
") THEN 2 ELSE 0 END AS rating_delta "
|
||||||
sql += "ORDER BY id DESC LIMIT ? OFFSET ?";
|
"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,
|
CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db,
|
||||||
"prepare list submissions");
|
"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.compile_log = ColText(stmt, 10);
|
||||||
s.runtime_log = ColText(stmt, 11);
|
s.runtime_log = ColText(stmt, 11);
|
||||||
s.created_at = sqlite3_column_int64(stmt, 12);
|
s.created_at = sqlite3_column_int64(stmt, 12);
|
||||||
|
s.rating_delta = sqlite3_column_int(stmt, 13);
|
||||||
out.push_back(std::move(s));
|
out.push_back(std::move(s));
|
||||||
}
|
}
|
||||||
sqlite3_finalize(stmt);
|
sqlite3_finalize(stmt);
|
||||||
@@ -395,8 +400,12 @@ std::optional<domain::Submission> SubmissionService::GetById(int64_t id) {
|
|||||||
sqlite3* db = db_.raw();
|
sqlite3* db = db_.raw();
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
const char* sql =
|
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 "
|
"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,"
|
||||||
"FROM submissions WHERE id=?";
|
"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,
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
"prepare get submission");
|
"prepare get submission");
|
||||||
CheckSqlite(sqlite3_bind_int64(stmt, 1, id), db, "bind submission_id");
|
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.compile_log = ColText(stmt, 10);
|
||||||
s.runtime_log = ColText(stmt, 11);
|
s.runtime_log = ColText(stmt, 11);
|
||||||
s.created_at = sqlite3_column_int64(stmt, 12);
|
s.created_at = sqlite3_column_int64(stmt, 12);
|
||||||
|
s.rating_delta = sqlite3_column_int(stmt, 13);
|
||||||
sqlite3_finalize(stmt);
|
sqlite3_finalize(stmt);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
#include <sqlite3.h>
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
namespace csp::services {
|
namespace csp::services {
|
||||||
|
|
||||||
@@ -70,4 +72,58 @@ std::vector<domain::GlobalLeaderboardEntry> UserService::GlobalLeaderboard(int l
|
|||||||
return out;
|
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
|
} // namespace csp::services
|
||||||
|
|||||||
@@ -42,7 +42,21 @@ TEST_CASE("problem workspace service drafts and solution jobs") {
|
|||||||
REQUIRE(latest->id == job_id);
|
REQUIRE(latest->id == job_id);
|
||||||
REQUIRE(latest->status == "queued");
|
REQUIRE(latest->status == "queued");
|
||||||
REQUIRE(latest->max_solutions == 3);
|
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);
|
const auto solutions = svc.ListSolutions(pid);
|
||||||
REQUIRE(solutions.empty());
|
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
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- OI_IMPORT_AUTO_RUN=true
|
- OI_IMPORT_AUTO_RUN=true
|
||||||
|
- OI_IMPORT_AUTO_MODE=luogu
|
||||||
- OI_IMPORT_WORKERS=3
|
- OI_IMPORT_WORKERS=3
|
||||||
- OI_IMPORT_SCRIPT_PATH=/app/scripts/import_luogu_csp.py
|
- OI_IMPORT_SCRIPT_PATH=/app/scripts/import_luogu_csp.py
|
||||||
- OI_IMPORT_CLEAR_ALL_PROBLEMS=true
|
- OI_IMPORT_LOCAL_RAG_SCRIPT_PATH=/app/scripts/import_local_pdf_rag.py
|
||||||
- OI_IMPORT_CLEAR_EXISTING=true
|
- 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:"
|
- "OI_IMPORT_CLEAR_SOURCE_PREFIX=luogu:"
|
||||||
- CSP_GEN_AUTO_RUN=true
|
- CSP_GEN_AUTO_RUN=true
|
||||||
- CSP_GEN_COUNT=1
|
- CSP_GEN_COUNT=1
|
||||||
- CSP_GEN_WAIT_FOR_IMPORT=true
|
- CSP_GEN_WAIT_FOR_IMPORT=true
|
||||||
- CSP_GEN_SCRIPT_PATH=/app/scripts/generate_cspj_problem_rag.py
|
- CSP_GEN_SCRIPT_PATH=/app/scripts/generate_cspj_problem_rag.py
|
||||||
- CSP_SOLUTION_SCRIPT_PATH=/app/scripts/generate_problem_solutions.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:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.backend
|
dockerfile: Dockerfile.backend
|
||||||
@@ -22,6 +30,7 @@ services:
|
|||||||
# - "8080:8080"
|
# - "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- csp_data:/data
|
- csp_data:/data
|
||||||
|
- ./data/local_pdfs:/data/local_pdfs
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|||||||
@@ -140,3 +140,20 @@
|
|||||||
```json
|
```json
|
||||||
{ "clear_all_problems": true }
|
{ "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() {
|
export default function AdminEntryPage() {
|
||||||
|
const { t } = useUiPreferences();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace("/imports");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||||
<h1 className="text-2xl font-semibold">后台管理入口</h1>
|
<h1 className="text-2xl font-semibold">{t("admin.entry.title")}</h1>
|
||||||
<p className="mt-3 text-sm text-zinc-600">
|
<p className="mt-3 text-sm text-zinc-600">{t("admin.entry.desc")}</p>
|
||||||
默认管理员账号:<span className="font-medium text-zinc-900">admin</span>,密码:
|
<p className="mt-2 text-sm text-zinc-500">
|
||||||
<span className="font-medium text-zinc-900">whoami139</span>
|
{t("admin.entry.moved_to_platform")}
|
||||||
</p>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,80 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
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 });
|
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
|
||||||
|
|
||||||
export default function ApiDocsPage() {
|
export default function ApiDocsPage() {
|
||||||
|
const { tx } = useI18nText();
|
||||||
const specUrl = useMemo(() => `${API_BASE}/api/openapi.json`, []);
|
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 (
|
return (
|
||||||
<main className="mx-auto max-w-7xl px-6 py-6">
|
<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-2xl font-semibold">API 文档(Swagger)</h1>
|
<h1 className="mb-4 text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||||
<div className="rounded-xl border bg-white p-2">
|
{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} />
|
<SwaggerUI url={specUrl} docExpansion="list" defaultModelsExpandDepth={1} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { API_BASE, apiFetch } from "@/lib/api";
|
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 AuthOk = { ok: true; user_id: number; token: string; expires_at: number };
|
||||||
type AuthErr = { ok: false; error: string };
|
type AuthErr = { ok: false; error: string };
|
||||||
type AuthResp = AuthOk | AuthErr;
|
type AuthResp = AuthOk | AuthErr;
|
||||||
|
|
||||||
function passwordScore(password: string): { label: string; color: string } {
|
function passwordScore(password: string, isZh: boolean): { label: string; color: string } {
|
||||||
if (password.length >= 12) return { label: "强", color: "text-emerald-600" };
|
if (password.length >= 12) return { label: isZh ? "强" : "Strong", color: "text-emerald-600" };
|
||||||
if (password.length >= 8) return { label: "中", color: "text-blue-600" };
|
if (password.length >= 8) return { label: isZh ? "中" : "Medium", color: "text-blue-600" };
|
||||||
return { label: "弱", color: "text-orange-600" };
|
return { label: isZh ? "弱" : "Weak", color: "text-orange-600" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthPage() {
|
export default function AuthPage() {
|
||||||
|
const { isZh, tx } = useI18nText();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const apiBase = useMemo(() => API_BASE, []);
|
const apiBase = useMemo(() => API_BASE, []);
|
||||||
|
const [checkingAuth, setCheckingAuth] = useState(true);
|
||||||
|
|
||||||
const [mode, setMode] = useState<"register" | "login">("login");
|
const [mode, setMode] = useState<"register" | "login">("login");
|
||||||
const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? "");
|
const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? "");
|
||||||
@@ -29,10 +32,18 @@ export default function AuthPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [resp, setResp] = useState<AuthResp | null>(null);
|
const [resp, setResp] = useState<AuthResp | null>(null);
|
||||||
|
|
||||||
const usernameErr = username.trim().length < 3 ? "用户名至少 3 位" : "";
|
useEffect(() => {
|
||||||
const passwordErr = password.length < 6 ? "密码至少 6 位" : "";
|
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 =
|
const confirmErr =
|
||||||
mode === "register" && password !== confirmPassword ? "两次密码不一致" : "";
|
mode === "register" && password !== confirmPassword ? tx("两次密码不一致", "Passwords do not match") : "";
|
||||||
|
|
||||||
const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr;
|
const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr;
|
||||||
|
|
||||||
@@ -49,7 +60,7 @@ export default function AuthPage() {
|
|||||||
if (j.ok) {
|
if (j.ok) {
|
||||||
saveToken(j.token);
|
saveToken(j.token);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push("/problems");
|
router.replace("/problems");
|
||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} 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 (
|
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]">
|
<div className="grid gap-6 md:grid-cols-[1.1fr,1fr]">
|
||||||
<section className="rounded-2xl border bg-zinc-900 p-6 text-zinc-100">
|
<section className="rounded-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">
|
<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>
|
</p>
|
||||||
<div className="mt-6 space-y-2 text-sm text-zinc-300">
|
<div className="mt-6 space-y-2 text-sm text-zinc-300">
|
||||||
<p>• 题库按 CSP-J / CSP-S / NOIP 入门组织</p>
|
<p>{tx("• 题库按 CSP-J / CSP-S / NOIP 入门组织", "• Problem sets are organized by CSP-J / CSP-S / NOIP junior")}</p>
|
||||||
<p>• 题目页支持本地草稿与试运行</p>
|
<p>{tx("• 题目页支持本地草稿与试运行", "• Problem page supports local draft and run")}</p>
|
||||||
<p>• 生成式题解会异步入库,支持多解法</p>
|
<p>{tx("• 生成式题解会异步入库,支持多解法", "• Generated solutions are queued asynchronously with multiple methods")}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-6 text-xs text-zinc-400">
|
<p className="mt-6 text-xs text-zinc-400">
|
||||||
API Base: <span className="font-mono">{apiBase}</span>
|
API Base: <span className="font-mono">{apiBase}</span>
|
||||||
@@ -92,7 +111,7 @@ export default function AuthPage() {
|
|||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
登录
|
{tx("登录", "Sign In")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -105,46 +124,46 @@ export default function AuthPage() {
|
|||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
注册
|
{tx("注册", "Register")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 space-y-4">
|
<div className="mt-5 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">用户名</label>
|
<label className="text-sm font-medium">{tx("用户名", "Username")}</label>
|
||||||
<input
|
<input
|
||||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
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>}
|
{usernameErr && <p className="mt-1 text-xs text-red-600">{usernameErr}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium">密码</label>
|
<label className="text-sm font-medium">{tx("密码", "Password")}</label>
|
||||||
<span className={`text-xs ${strength.color}`}>强度:{strength.label}</span>
|
<span className={`text-xs ${strength.color}`}>{tx("强度", "Strength")}: {strength.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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>}
|
{passwordErr && <p className="mt-1 text-xs text-red-600">{passwordErr}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === "register" && (
|
{mode === "register" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">确认密码</label>
|
<label className="text-sm font-medium">{tx("确认密码", "Confirm Password")}</label>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder="再输入一次密码"
|
placeholder={tx("再输入一次密码", "Enter password again")}
|
||||||
/>
|
/>
|
||||||
{confirmErr && <p className="mt-1 text-xs text-red-600">{confirmErr}</p>}
|
{confirmErr && <p className="mt-1 text-xs text-red-600">{confirmErr}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +175,7 @@ export default function AuthPage() {
|
|||||||
checked={showPassword}
|
checked={showPassword}
|
||||||
onChange={(e) => setShowPassword(e.target.checked)}
|
onChange={(e) => setShowPassword(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
显示密码
|
{tx("显示密码", "Show password")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -164,7 +183,7 @@ export default function AuthPage() {
|
|||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
>
|
>
|
||||||
{loading ? "提交中..." : mode === "register" ? "注册并登录" : "登录"}
|
{loading ? tx("提交中...", "Submitting...") : mode === "register" ? tx("注册并登录", "Register & Sign In") : tx("登录", "Sign In")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,21 +194,21 @@ export default function AuthPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{resp.ok
|
{resp.ok
|
||||||
? "登录成功,正在跳转到题库..."
|
? tx("登录成功,正在跳转到题库...", "Signed in. Redirecting to problem set...")
|
||||||
: `操作失败:${resp.error}`}
|
: `${tx("操作失败:", "Action failed: ")}${resp.error}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-zinc-500">
|
<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">
|
<Link className="mx-1 underline" href="/problems">
|
||||||
题库
|
{tx("题库", "Problems")}
|
||||||
</Link>
|
</Link>
|
||||||
与
|
{tx("与", "and")}
|
||||||
<Link className="mx-1 underline" href="/me">
|
<Link className="mx-1 underline" href="/me">
|
||||||
我的
|
{tx("我的", "My Account")}
|
||||||
</Link>
|
</Link>
|
||||||
页面。
|
{tx("页面。", ".")}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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 { apiFetch } from "@/lib/api";
|
||||||
import { readToken } from "@/lib/auth";
|
import { readToken } from "@/lib/auth";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type Contest = {
|
type Contest = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -35,6 +36,7 @@ type DetailResp = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ContestDetailPage() {
|
export default function ContestDetailPage() {
|
||||||
|
const { tx } = useI18nText();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const contestId = useMemo(() => Number(params.id), [params.id]);
|
const contestId = useMemo(() => Number(params.id), [params.id]);
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ export default function ContestDetailPage() {
|
|||||||
const register = async () => {
|
const register = async () => {
|
||||||
try {
|
try {
|
||||||
const token = readToken();
|
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 apiFetch(`/api/v1/contests/${contestId}/register`, { method: "POST" }, token);
|
||||||
await load();
|
await load();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -78,9 +80,11 @@ export default function ContestDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||||
<h1 className="text-2xl font-semibold">比赛详情 #{contestId}</h1>
|
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
{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>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
{detail && (
|
{detail && (
|
||||||
@@ -91,24 +95,29 @@ export default function ContestDetailPage() {
|
|||||||
{new Date(detail.contest.starts_at * 1000).toLocaleString()} - {" "}
|
{new Date(detail.contest.starts_at * 1000).toLocaleString()} - {" "}
|
||||||
{new Date(detail.contest.ends_at * 1000).toLocaleString()}
|
{new Date(detail.contest.ends_at * 1000).toLocaleString()}
|
||||||
</p>
|
</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}
|
{detail.contest.rule_json}
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
<button
|
<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()}
|
onClick={() => void register()}
|
||||||
>
|
>
|
||||||
{detail.registered ? "已报名(可重复点击刷新)" : "报名比赛"}
|
{detail.registered
|
||||||
|
? tx("已报名(可重复点击刷新)", "Registered (click again to refresh)")
|
||||||
|
: tx("报名比赛", "Register Contest")}
|
||||||
</button>
|
</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">
|
<ul className="mt-2 space-y-2 text-sm">
|
||||||
{detail.problems.map((p) => (
|
{detail.problems.map((p) => (
|
||||||
<li key={p.id} className="rounded border p-2">
|
<li key={p.id} className="rounded border p-2">
|
||||||
#{p.id} {p.title}(难度 {p.difficulty})
|
#{p.id} {p.title}
|
||||||
<Link className="ml-2 text-blue-600 underline" href={`/problems/${p.id}`}>
|
{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>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -116,13 +125,32 @@ export default function ContestDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border bg-white p-4">
|
<section className="rounded-xl border bg-white p-4">
|
||||||
<h3 className="text-sm font-medium">排行榜</h3>
|
<h3 className="text-sm font-medium">{tx("排行榜", "Leaderboard")}</h3>
|
||||||
<div className="mt-2 overflow-x-auto">
|
<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">
|
<table className="min-w-full text-sm">
|
||||||
<thead className="bg-zinc-100 text-left">
|
<thead className="bg-zinc-100 text-left">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-2 py-1">#</th>
|
<th className="px-2 py-1">#</th>
|
||||||
<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">Solved</th>
|
||||||
<th className="px-2 py-1">Penalty(s)</th>
|
<th className="px-2 py-1">Penalty(s)</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -136,9 +164,17 @@ export default function ContestDetailPage() {
|
|||||||
<td className="px-2 py-1">{r.penalty_sec}</td>
|
<td className="px-2 py-1">{r.penalty_sec}</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type Contest = {
|
type Contest = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,6 +15,7 @@ type Contest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ContestsPage() {
|
export default function ContestsPage() {
|
||||||
|
const { tx } = useI18nText();
|
||||||
const [items, setItems] = useState<Contest[]>([]);
|
const [items, setItems] = useState<Contest[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -35,9 +37,11 @@ export default function ContestsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||||
<h1 className="text-2xl font-semibold">模拟竞赛</h1>
|
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
{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>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
@@ -48,8 +52,8 @@ export default function ContestsPage() {
|
|||||||
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
|
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-medium">{c.title}</h2>
|
<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="mt-1 text-xs text-zinc-500">{tx("开始", "Start")}: {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="text-xs text-zinc-500">{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}</p>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #fff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
|
--surface: #fff;
|
||||||
|
--surface-soft: #f4f4f5;
|
||||||
|
--border: #d4d4d8;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -13,15 +17,55 @@
|
|||||||
"Courier New", monospace;
|
"Courier New", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { readToken } from "@/lib/auth";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type ImportJob = {
|
type ImportJob = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -46,19 +49,50 @@ type ItemsResp = {
|
|||||||
page_size: number;
|
page_size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MeProfile = {
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function fmtTs(v: number | null | undefined): string {
|
function fmtTs(v: number | null | undefined): string {
|
||||||
if (!v) return "-";
|
if (!v) return "-";
|
||||||
return new Date(v * 1000).toLocaleString();
|
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() {
|
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 [loading, setLoading] = useState(false);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [job, setJob] = useState<ImportJob | null>(null);
|
const [job, setJob] = useState<ImportJob | null>(null);
|
||||||
const [items, setItems] = useState<ImportItem[]>([]);
|
const [items, setItems] = useState<ImportItem[]>([]);
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
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 [clearAllBeforeRun, setClearAllBeforeRun] = useState(true);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
@@ -66,29 +100,30 @@ export default function ImportsPage() {
|
|||||||
return Math.min(100, Math.floor((job.processed_count / job.total_count) * 100));
|
return Math.min(100, Math.floor((job.processed_count / job.total_count) * 100));
|
||||||
}, [job]);
|
}, [job]);
|
||||||
|
|
||||||
const loadLatest = async () => {
|
const loadLatest = async (tk: string) => {
|
||||||
const latest = await apiFetch<LatestResp>("/api/v1/import/jobs/latest");
|
const latest = await apiFetch<LatestResp>("/api/v1/import/jobs/latest", {}, tk);
|
||||||
setJob(latest.job ?? null);
|
setJob(latest.job ?? null);
|
||||||
setRunning(Boolean(latest.runner_running) || latest.job?.status === "running");
|
setRunning(Boolean(latest.runner_running) || latest.job?.status === "running");
|
||||||
return latest.job;
|
return latest.job;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadItems = async (jobId: number) => {
|
const loadItems = async (tk: string, jobId: number) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
params.set("page_size", String(pageSize));
|
params.set("page_size", String(pageSize));
|
||||||
if (statusFilter) params.set("status", statusFilter);
|
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 ?? []);
|
setItems(data.items ?? []);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
|
if (!isAdmin || !token) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const latestJob = await loadLatest();
|
const latestJob = await loadLatest(token);
|
||||||
if (latestJob) {
|
if (latestJob) {
|
||||||
await loadItems(latestJob.id);
|
await loadItems(token, latestJob.id);
|
||||||
} else {
|
} else {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
}
|
}
|
||||||
@@ -100,124 +135,333 @@ export default function ImportsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const runImport = async () => {
|
const runImport = async () => {
|
||||||
|
if (!isAdmin || !token) {
|
||||||
|
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await apiFetch<{ started: boolean }>("/api/v1/import/jobs/run", {
|
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",
|
method: "POST",
|
||||||
body: JSON.stringify({ clear_all_problems: clearAllBeforeRun }),
|
body: JSON.stringify(body),
|
||||||
});
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const jobOpts = useMemo(() => parseOptions(job?.options_json ?? ""), [job?.options_json]);
|
||||||
void refresh();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [pageSize, statusFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
const timer = setInterval(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, running ? 3000 : 15000);
|
}, running ? 3000 : 15000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 (
|
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">
|
||||||
<h1 className="text-2xl font-semibold">题库导入任务(Luogu CSP J/S)</h1>
|
<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="mt-4 rounded-xl border bg-white p-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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
|
<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()}
|
onClick={() => void runImport()}
|
||||||
disabled={loading || running}
|
disabled={loading || running}
|
||||||
>
|
>
|
||||||
{running ? "导入中..." : "启动导入任务"}
|
{running ? tx("导入中...", "Importing...") : tx("启动导入任务", "Start Import Job")}
|
||||||
</button>
|
</button>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
{runMode === "luogu" && (
|
||||||
|
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={clearAllBeforeRun}
|
checked={clearAllBeforeRun}
|
||||||
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
|
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
启动前清空历史题库
|
{tx("启动前清空历史题库", "Clear old problem set before start")}
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
<button className="rounded border px-3 py-2 text-sm" onClick={() => void refresh()} disabled={loading}>
|
<button className="rounded border px-3 py-2 text-sm" onClick={() => void refresh()} disabled={loading}>
|
||||||
刷新
|
{tx("刷新", "Refresh")}
|
||||||
</button>
|
</button>
|
||||||
<span className={`text-sm ${running ? "text-emerald-700" : "text-zinc-600"}`}>
|
<span className={`text-sm ${running ? "text-emerald-700" : "text-zinc-600"}`}>
|
||||||
{running ? "运行中" : "空闲"}
|
{running ? tx("运行中", "Running") : tx("空闲", "Idle")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{runMode === "luogu" && (
|
||||||
<p className="mt-2 text-xs text-zinc-500">
|
<p className="mt-2 text-xs text-zinc-500">
|
||||||
默认按后端配置以 3 线程执行,抓取洛谷 CSP-J/CSP-S/NOIP 标签题;容器重启后会自动触发导入(可通过环境变量关闭)。
|
{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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||||
<h2 className="text-lg font-medium">最新任务</h2>
|
<h2 className="text-lg font-medium">{tx("最新任务", "Latest Job")}</h2>
|
||||||
{!job && <p className="mt-2 text-sm text-zinc-500">暂无任务记录</p>}
|
{!job && <p className="mt-2 text-sm text-zinc-500">{tx("暂无任务记录", "No job records")}</p>}
|
||||||
{job && (
|
{job && (
|
||||||
<div className="mt-3 space-y-2 text-sm">
|
<div className="mt-3 space-y-2 text-sm">
|
||||||
<p>
|
<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>
|
||||||
<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>
|
</p>
|
||||||
<div className="h-2 w-full rounded bg-zinc-100">
|
<div className="h-2 w-full rounded bg-zinc-100">
|
||||||
<div className="h-2 rounded bg-emerald-500" style={{ width: `${progress}%` }} />
|
<div className="h-2 rounded bg-emerald-500" style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-zinc-600">
|
<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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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
|
<select
|
||||||
className="rounded border px-2 py-1 text-sm"
|
className="rounded border px-2 py-1 text-sm"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">全部状态</option>
|
<option value="">{tx("全部状态", "All Status")}</option>
|
||||||
<option value="queued">queued</option>
|
<option value="queued">queued</option>
|
||||||
<option value="running">running</option>
|
<option value="running">running</option>
|
||||||
<option value="success">success</option>
|
<option value="success">success</option>
|
||||||
<option value="failed">failed</option>
|
<option value="failed">failed</option>
|
||||||
|
<option value="skipped">skipped</option>
|
||||||
|
<option value="interrupted">interrupted</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
className="rounded border px-2 py-1 text-sm"
|
className="rounded border px-2 py-1 text-sm"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||||
>
|
>
|
||||||
<option value={50}>50 条</option>
|
<option value={50}>{tx("50 条", "50 rows")}</option>
|
||||||
<option value={100}>100 条</option>
|
<option value={100}>{tx("100 条", "100 rows")}</option>
|
||||||
<option value={200}>200 条</option>
|
<option value={200}>{tx("200 条", "200 rows")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 overflow-x-auto">
|
<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">
|
<table className="min-w-full text-xs">
|
||||||
<thead className="bg-zinc-100 text-left">
|
<thead className="bg-zinc-100 text-left">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-2 py-2">ID</th>
|
<th className="px-2 py-2">ID</th>
|
||||||
<th className="px-2 py-2">路径</th>
|
<th className="px-2 py-2">{tx("路径", "Path")}</th>
|
||||||
<th className="px-2 py-2">状态</th>
|
<th className="px-2 py-2">{tx("状态", "Status")}</th>
|
||||||
<th className="px-2 py-2">标题</th>
|
<th className="px-2 py-2">{tx("标题", "Title")}</th>
|
||||||
<th className="px-2 py-2">难度</th>
|
<th className="px-2 py-2">{tx("难度", "Difficulty")}</th>
|
||||||
<th className="px-2 py-2">题目ID</th>
|
<th className="px-2 py-2">{tx("题目ID", "Problem ID")}</th>
|
||||||
<th className="px-2 py-2">错误</th>
|
<th className="px-2 py-2">{tx("错误", "Error")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -247,13 +491,14 @@ export default function ImportsPage() {
|
|||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-2 py-4 text-center text-zinc-500" colSpan={7}>
|
<td className="px-2 py-4 text-center text-zinc-500" colSpan={7}>
|
||||||
暂无明细
|
{tx("暂无明细", "No details")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import Link from "next/link";
|
|||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type Article = {
|
type Article = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -20,6 +22,7 @@ type DetailResp = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function KbDetailPage() {
|
export default function KbDetailPage() {
|
||||||
|
const { tx } = useI18nText();
|
||||||
const params = useParams<{ slug: string }>();
|
const params = useParams<{ slug: string }>();
|
||||||
const slug = useMemo(() => params.slug, [params.slug]);
|
const slug = useMemo(() => params.slug, [params.slug]);
|
||||||
|
|
||||||
@@ -44,20 +47,29 @@ export default function KbDetailPage() {
|
|||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||||
<h1 className="text-2xl font-semibold">知识库文章</h1>
|
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
{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>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<section className="rounded-xl border bg-white p-4">
|
<section className="rounded-xl border bg-white p-4">
|
||||||
<h2 className="text-xl font-medium">{data.article.title}</h2>
|
<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>
|
||||||
|
|
||||||
<section className="rounded-xl border bg-white p-4">
|
<section className="rounded-xl border bg-white p-4">
|
||||||
<h3 className="text-sm font-medium">关联题目</h3>
|
<h3 className="text-sm font-medium">{tx("关联题目", "Related Problems")}</h3>
|
||||||
|
{data.related_problems.length ? (
|
||||||
<ul className="mt-2 space-y-2 text-sm">
|
<ul className="mt-2 space-y-2 text-sm">
|
||||||
{data.related_problems.map((p) => (
|
{data.related_problems.map((p) => (
|
||||||
<li key={p.problem_id}>
|
<li key={p.problem_id}>
|
||||||
@@ -67,6 +79,9 @@ export default function KbDetailPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-sm text-zinc-500">{tx("暂无关联题目", "No related problems")}</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { readToken } from "@/lib/auth";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type Article = {
|
type Article = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -12,13 +14,64 @@ type Article = {
|
|||||||
created_at: number;
|
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() {
|
export default function KbListPage() {
|
||||||
|
const { tx } = useI18nText();
|
||||||
|
const [refreshToken, setRefreshToken] = useState("");
|
||||||
|
const [canManageRefresh, setCanManageRefresh] = useState(false);
|
||||||
const [items, setItems] = useState<Article[]>([]);
|
const [items, setItems] = useState<Article[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [statusLoading, setStatusLoading] = useState(false);
|
||||||
|
const [triggerLoading, setTriggerLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [hint, setHint] = useState("");
|
||||||
|
const [refreshStatus, setRefreshStatus] = useState<KbRefreshStatus | null>(null);
|
||||||
|
const [lastSyncedFinishedAt, setLastSyncedFinishedAt] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
const grouped = useMemo(() => {
|
||||||
const load = async () => {
|
const buckets: Record<string, Article[]> = {
|
||||||
|
roadmap: [],
|
||||||
|
cpp: [],
|
||||||
|
cspj: [],
|
||||||
|
csps: [],
|
||||||
|
other: [],
|
||||||
|
};
|
||||||
|
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);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -29,30 +82,195 @@ export default function KbListPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
void load();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
const loadStatus = useCallback(async () => {
|
||||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
if (!canManageRefresh || !refreshToken) {
|
||||||
<h1 className="text-2xl font-semibold">学习知识库</h1>
|
setRefreshStatus(null);
|
||||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
return;
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
}
|
||||||
|
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">
|
useEffect(() => {
|
||||||
{items.map((a) => (
|
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()}
|
||||||
|
>
|
||||||
|
{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
|
<Link
|
||||||
key={a.slug}
|
key={a.slug}
|
||||||
href={`/kb/${a.slug}`}
|
href={`/kb/${a.slug}`}
|
||||||
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
|
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-medium">{a.title}</h2>
|
<h3 className="text-lg font-medium">{a.title}</h3>
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()}
|
slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { AppNav } from "@/components/app-nav";
|
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 "katex/dist/katex.min.css";
|
||||||
import "highlight.js/styles/github-dark.css";
|
import "highlight.js/styles/github-dark.css";
|
||||||
import "swagger-ui-react/swagger-ui.css";
|
import "swagger-ui-react/swagger-ui.css";
|
||||||
|
import "@/themes/default/theme.css";
|
||||||
|
import "@/themes/minecraft/theme.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "CSP 在线学习与竞赛平台",
|
title: "CSP Online Learning & Contest Platform",
|
||||||
description: "题库、错题本、模拟竞赛、知识库与在线 C++ 运行",
|
description: "Problems, wrong-book review, contests, knowledge base, and C++ runner.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -16,10 +20,13 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
|
<UiPreferenceProvider>
|
||||||
<AppNav />
|
<AppNav />
|
||||||
{children}
|
<div className="pb-[calc(3.8rem+env(safe-area-inset-bottom))] md:pb-0">{children}</div>
|
||||||
|
<MobileTabBar />
|
||||||
|
</UiPreferenceProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -12,6 +13,7 @@ type Row = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function LeaderboardPage() {
|
export default function LeaderboardPage() {
|
||||||
|
const { tx } = useI18nText();
|
||||||
const [items, setItems] = useState<Row[]>([]);
|
const [items, setItems] = useState<Row[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -33,19 +35,42 @@ export default function LeaderboardPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-4xl px-6 py-8">
|
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||||
<h1 className="text-2xl font-semibold">全站排行榜</h1>
|
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
{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>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
<div className="mt-4 overflow-x-auto rounded-xl border bg-white">
|
<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">
|
<table className="min-w-full text-sm">
|
||||||
<thead className="bg-zinc-100 text-left">
|
<thead className="bg-zinc-100 text-left">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2">排名</th>
|
<th className="px-3 py-2">{tx("排名", "Rank")}</th>
|
||||||
<th className="px-3 py-2">用户</th>
|
<th className="px-3 py-2">{tx("用户", "User")}</th>
|
||||||
<th className="px-3 py-2">Rating</th>
|
<th className="px-3 py-2">Rating</th>
|
||||||
<th className="px-3 py-2">注册时间</th>
|
<th className="px-3 py-2">{tx("注册时间", "Registered At")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -59,9 +84,17 @@ export default function LeaderboardPage() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { readToken } from "@/lib/auth";
|
import { readToken } from "@/lib/auth";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type Me = {
|
type Me = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -61,6 +62,7 @@ function fmtTs(v: number | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MePage() {
|
export default function MePage() {
|
||||||
|
const { isZh, tx } = useI18nText();
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [profile, setProfile] = useState<Me | null>(null);
|
const [profile, setProfile] = useState<Me | null>(null);
|
||||||
const [items, setItems] = useState<RedeemItem[]>([]);
|
const [items, setItems] = useState<RedeemItem[]>([]);
|
||||||
@@ -92,6 +94,38 @@ export default function MePage() {
|
|||||||
|
|
||||||
const totalCost = useMemo(() => Math.max(0, unitCost * Math.max(1, quantity)), [quantity, unitCost]);
|
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 () => {
|
const loadAll = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -99,7 +133,7 @@ export default function MePage() {
|
|||||||
try {
|
try {
|
||||||
const tk = readToken();
|
const tk = readToken();
|
||||||
setToken(tk);
|
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([
|
const [me, redeemItems, redeemRecords, daily] = await Promise.all([
|
||||||
apiFetch<Me>("/api/v1/me", {}, tk),
|
apiFetch<Me>("/api/v1/me", {}, tk),
|
||||||
@@ -127,6 +161,7 @@ export default function MePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadAll();
|
void loadAll();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const redeem = async () => {
|
const redeem = async () => {
|
||||||
@@ -134,9 +169,11 @@ export default function MePage() {
|
|||||||
setError("");
|
setError("");
|
||||||
setMsg("");
|
setMsg("");
|
||||||
try {
|
try {
|
||||||
if (!token) throw new Error("请先登录");
|
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
|
||||||
if (!selectedItemId) throw new Error("请选择兑换物品");
|
if (!selectedItemId) throw new Error(tx("请选择兑换物品", "Please select a redeem item"));
|
||||||
if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("兑换数量必须大于 0");
|
if (!Number.isFinite(quantity) || quantity <= 0) {
|
||||||
|
throw new Error(tx("兑换数量必须大于 0", "Quantity must be greater than 0"));
|
||||||
|
}
|
||||||
|
|
||||||
const created = await apiFetch<RedeemCreateResp>(
|
const created = await apiFetch<RedeemCreateResp>(
|
||||||
"/api/v1/me/redeem/records",
|
"/api/v1/me/redeem/records",
|
||||||
@@ -153,9 +190,13 @@ export default function MePage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setMsg(
|
setMsg(
|
||||||
`兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
|
isZh
|
||||||
|
? `兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
|
||||||
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : ""
|
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("");
|
setNote("");
|
||||||
await loadAll();
|
await loadAll();
|
||||||
@@ -168,25 +209,28 @@ export default function MePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">我的信息与积分兑换</h1>
|
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
{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>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
|
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
|
||||||
|
|
||||||
{profile && (
|
{profile && (
|
||||||
<section className="mt-4 rounded-xl border bg-white p-4 text-sm">
|
<section className="mt-4 rounded-xl border bg-white p-4 text-sm">
|
||||||
<p>ID: {profile.id}</p>
|
<p>ID: {profile.id}</p>
|
||||||
<p>用户名: {profile.username}</p>
|
<p>{tx("用户名", "Username")}: {profile.username}</p>
|
||||||
<p>Rating: {profile.rating}</p>
|
<p>Rating: {profile.rating}</p>
|
||||||
<p>创建时间: {fmtTs(profile.created_at)}</p>
|
<p>{tx("创建时间", "Created At")}: {fmtTs(profile.created_at)}</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<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">
|
<p className="text-xs text-zinc-600">
|
||||||
{dailyDayKey ? `${dailyDayKey} · ` : ""}已获 {dailyGainedReward}/{dailyTotalReward} 分
|
{dailyDayKey ? `${dailyDayKey} · ` : ""}
|
||||||
|
{tx("已获", "Earned")} {dailyGainedReward}/{dailyTotalReward} {tx("分", "pts")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 divide-y">
|
<div className="mt-3 divide-y">
|
||||||
@@ -194,68 +238,82 @@ export default function MePage() {
|
|||||||
<article key={task.code} className="py-2 text-sm">
|
<article key={task.code} className="py-2 text-sm">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{task.title} · +{task.reward}
|
{taskTitle(task)} · +{task.reward}
|
||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
className={`rounded px-2 py-0.5 text-xs ${
|
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 ? "bg-emerald-100 text-emerald-700" : "bg-zinc-100 text-zinc-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{task.completed ? "已完成" : "未完成"}
|
{task.completed ? tx("已完成", "Completed") : tx("未完成", "Incomplete")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 && (
|
{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>
|
</article>
|
||||||
))}
|
))}
|
||||||
{!loading && dailyTasks.length === 0 && (
|
{!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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
<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">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<article key={item.id} className="rounded border bg-zinc-50 p-3 text-sm">
|
<article key={item.id} className="rounded border bg-zinc-50 p-3 text-sm">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<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
|
<button
|
||||||
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100"
|
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100"
|
||||||
onClick={() => setSelectedItemId(item.id)}
|
onClick={() => setSelectedItemId(item.id)}
|
||||||
>
|
>
|
||||||
选中
|
{tx("选中", "Select")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-zinc-600">{item.description || "-"}</p>
|
<p className="mt-1 text-xs text-zinc-600">{itemDesc(item.description) || "-"}</p>
|
||||||
<p className="mt-1 text-xs text-zinc-700">假期:{item.holiday_cost} / {item.unit_label}</p>
|
<p className="mt-1 text-xs text-zinc-700">
|
||||||
<p className="text-xs text-zinc-700">学习日:{item.studyday_cost} / {item.unit_label}</p>
|
{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>
|
</article>
|
||||||
))}
|
))}
|
||||||
{!loading && items.length === 0 && (
|
{!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>
|
||||||
|
|
||||||
<div className="mt-4 rounded-lg border p-3">
|
<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">
|
<div className="mt-2 grid gap-2 md:grid-cols-2">
|
||||||
<select
|
<select
|
||||||
className="rounded border px-3 py-2 text-sm"
|
className="rounded border px-3 py-2 text-sm"
|
||||||
value={selectedItemId}
|
value={selectedItemId}
|
||||||
onChange={(e) => setSelectedItemId(Number(e.target.value))}
|
onChange={(e) => setSelectedItemId(Number(e.target.value))}
|
||||||
>
|
>
|
||||||
<option value={0}>请选择兑换物品</option>
|
<option value={0}>{tx("请选择兑换物品", "Please select an item")}</option>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
{item.name}
|
{itemName(item.name)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -265,8 +323,8 @@ export default function MePage() {
|
|||||||
value={dayType}
|
value={dayType}
|
||||||
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
|
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
|
||||||
>
|
>
|
||||||
<option value="holiday">假期时间(按假期单价)</option>
|
<option value="holiday">{tx("假期时间(按假期单价)", "Holiday time (holiday price)")}</option>
|
||||||
<option value="studyday">学习日/非节假日(按学习日单价)</option>
|
<option value="studyday">{tx("学习日/非节假日(按学习日单价)", "Study day/non-holiday (study-day price)")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -276,19 +334,19 @@ export default function MePage() {
|
|||||||
max={24}
|
max={24}
|
||||||
value={quantity}
|
value={quantity}
|
||||||
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
|
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
|
||||||
placeholder="兑换时长(小时)"
|
placeholder={tx("兑换时长(小时)", "Redeem duration (hours)")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className="rounded border px-3 py-2 text-sm"
|
className="rounded border px-3 py-2 text-sm"
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
placeholder="备注(可选)"
|
placeholder={tx("备注(可选)", "Note (optional)")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-xs text-zinc-600">
|
<p className="mt-2 text-xs text-zinc-600">
|
||||||
当前单价:{unitCost} / 小时;预计扣分:{totalCost}
|
{tx("当前单价", "Current unit price")}: {unitCost} / {tx("小时", "hour")};{tx("预计扣分", "Estimated cost")}: {totalCost}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -296,20 +354,20 @@ export default function MePage() {
|
|||||||
onClick={() => void redeem()}
|
onClick={() => void redeem()}
|
||||||
disabled={redeemLoading || !selectedItemId}
|
disabled={redeemLoading || !selectedItemId}
|
||||||
>
|
>
|
||||||
{redeemLoading ? "兑换中..." : "确认兑换"}
|
{redeemLoading ? tx("兑换中...", "Redeeming...") : tx("确认兑换", "Confirm Redeem")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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
|
<button
|
||||||
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100"
|
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100"
|
||||||
onClick={() => void loadAll()}
|
onClick={() => void loadAll()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
刷新
|
{tx("刷新", "Refresh")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -317,15 +375,18 @@ export default function MePage() {
|
|||||||
{records.map((row) => (
|
{records.map((row) => (
|
||||||
<article key={row.id} className="py-2 text-sm">
|
<article key={row.id} className="py-2 text-sm">
|
||||||
<p>
|
<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>
|
||||||
<p className="text-xs text-zinc-600">
|
<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>
|
</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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
文件差异内容过多而无法显示
加载差异
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type Problem = {
|
type Problem = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,7 +34,8 @@ type ProblemProfile = {
|
|||||||
|
|
||||||
type Preset = {
|
type Preset = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
labelZh: string;
|
||||||
|
labelEn: string;
|
||||||
sourcePrefix?: string;
|
sourcePrefix?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
};
|
};
|
||||||
@@ -41,33 +43,39 @@ type Preset = {
|
|||||||
const PRESETS: Preset[] = [
|
const PRESETS: Preset[] = [
|
||||||
{
|
{
|
||||||
key: "csp-beginner-default",
|
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"],
|
tags: ["csp-j", "csp-s", "noip-junior", "noip-senior"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "csp-j",
|
key: "csp-j",
|
||||||
label: "仅 CSP-J / 普及",
|
labelZh: "仅 CSP-J / 普及",
|
||||||
|
labelEn: "CSP-J / Junior Only",
|
||||||
tags: ["csp-j", "noip-junior"],
|
tags: ["csp-j", "noip-junior"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "csp-s",
|
key: "csp-s",
|
||||||
label: "仅 CSP-S / 提高",
|
labelZh: "仅 CSP-S / 提高",
|
||||||
|
labelEn: "CSP-S / Senior Only",
|
||||||
tags: ["csp-s", "noip-senior"],
|
tags: ["csp-s", "noip-senior"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "noip-junior",
|
key: "noip-junior",
|
||||||
label: "仅 NOIP 入门",
|
labelZh: "仅 NOIP 入门",
|
||||||
|
labelEn: "NOIP Junior Only",
|
||||||
tags: ["noip-junior"],
|
tags: ["noip-junior"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "luogu-all",
|
key: "luogu-all",
|
||||||
label: "洛谷导入全部",
|
labelZh: "洛谷导入全部",
|
||||||
|
labelEn: "All Luogu Imports",
|
||||||
sourcePrefix: "luogu:",
|
sourcePrefix: "luogu:",
|
||||||
tags: [],
|
tags: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "all",
|
key: "all",
|
||||||
label: "全站全部来源",
|
labelZh: "全站全部来源",
|
||||||
|
labelEn: "All Sources",
|
||||||
tags: [],
|
tags: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -75,33 +83,39 @@ const PRESETS: Preset[] = [
|
|||||||
const QUICK_CARDS = [
|
const QUICK_CARDS = [
|
||||||
{
|
{
|
||||||
presetKey: "csp-j",
|
presetKey: "csp-j",
|
||||||
title: "CSP-J 真题",
|
titleZh: "CSP-J 真题",
|
||||||
desc: "普及组入门训练",
|
titleEn: "CSP-J Problems",
|
||||||
|
descZh: "普及组入门训练",
|
||||||
|
descEn: "Junior training set",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
presetKey: "csp-s",
|
presetKey: "csp-s",
|
||||||
title: "CSP-S 真题",
|
titleZh: "CSP-S 真题",
|
||||||
desc: "提高组进阶训练",
|
titleEn: "CSP-S Problems",
|
||||||
|
descZh: "提高组进阶训练",
|
||||||
|
descEn: "Senior advanced set",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
presetKey: "noip-junior",
|
presetKey: "noip-junior",
|
||||||
title: "NOIP 入门",
|
titleZh: "NOIP 入门",
|
||||||
desc: "基础算法与思维",
|
titleEn: "NOIP Junior",
|
||||||
|
descZh: "基础算法与思维",
|
||||||
|
descEn: "Basic algorithm thinking",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DIFFICULTY_OPTIONS = [
|
const DIFFICULTY_OPTIONS = [
|
||||||
{ value: "0", label: "全部难度" },
|
{ value: "0", labelZh: "全部难度", labelEn: "All Levels" },
|
||||||
{ value: "1", label: "1" },
|
{ value: "1", labelZh: "1", labelEn: "1" },
|
||||||
{ value: "2", label: "2" },
|
{ value: "2", labelZh: "2", labelEn: "2" },
|
||||||
{ value: "3", label: "3" },
|
{ value: "3", labelZh: "3", labelEn: "3" },
|
||||||
{ value: "4", label: "4" },
|
{ value: "4", labelZh: "4", labelEn: "4" },
|
||||||
{ value: "5", label: "5" },
|
{ value: "5", labelZh: "5", labelEn: "5" },
|
||||||
{ value: "6", label: "6" },
|
{ value: "6", labelZh: "6", labelEn: "6" },
|
||||||
{ value: "7", label: "7" },
|
{ value: "7", labelZh: "7", labelEn: "7" },
|
||||||
{ value: "8", label: "8" },
|
{ value: "8", labelZh: "8", labelEn: "8" },
|
||||||
{ value: "9", label: "9" },
|
{ value: "9", labelZh: "9", labelEn: "9" },
|
||||||
{ value: "10", label: "10" },
|
{ value: "10", labelZh: "10", labelEn: "10" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function parseProfile(raw: string): ProblemProfile | null {
|
function parseProfile(raw: string): ProblemProfile | null {
|
||||||
@@ -145,6 +159,7 @@ function resolveTags(profile: ProblemProfile | null): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProblemsPage() {
|
export default function ProblemsPage() {
|
||||||
|
const { isZh, tx } = useI18nText();
|
||||||
const [presetKey, setPresetKey] = useState(PRESETS[0].key);
|
const [presetKey, setPresetKey] = useState(PRESETS[0].key);
|
||||||
const [keywordInput, setKeywordInput] = useState("");
|
const [keywordInput, setKeywordInput] = useState("");
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
@@ -217,15 +232,25 @@ export default function ProblemsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<section className="mt-4 grid gap-3 md:grid-cols-3">
|
<section className="mt-4 grid gap-3 md:grid-cols-3">
|
||||||
@@ -242,9 +267,9 @@ export default function ProblemsPage() {
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => selectPreset(card.presetKey)}
|
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"}`}>
|
<p className={`mt-1 text-xs ${active ? "text-zinc-200" : "text-zinc-500"}`}>
|
||||||
{card.desc}
|
{isZh ? card.descZh : card.descEn}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -261,14 +286,14 @@ export default function ProblemsPage() {
|
|||||||
>
|
>
|
||||||
{PRESETS.map((item) => (
|
{PRESETS.map((item) => (
|
||||||
<option key={item.key} value={item.key}>
|
<option key={item.key} value={item.key}>
|
||||||
{item.label}
|
{isZh ? item.labelZh : item.labelEn}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className="rounded border px-3 py-2 text-sm lg:col-span-2"
|
className="rounded border px-3 py-2 text-sm lg:col-span-2"
|
||||||
placeholder="搜索题号/标题/题面关键词"
|
placeholder={tx("搜索题号/标题/题面关键词", "Search id/title/statement keywords")}
|
||||||
value={keywordInput}
|
value={keywordInput}
|
||||||
onChange={(e) => setKeywordInput(e.target.value)}
|
onChange={(e) => setKeywordInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -286,7 +311,7 @@ export default function ProblemsPage() {
|
|||||||
>
|
>
|
||||||
{DIFFICULTY_OPTIONS.map((item) => (
|
{DIFFICULTY_OPTIONS.map((item) => (
|
||||||
<option key={item.value} value={item.value}>
|
<option key={item.value} value={item.value}>
|
||||||
难度 {item.label}
|
{tx("难度", "Difficulty")} {isZh ? item.labelZh : item.labelEn}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -301,12 +326,12 @@ export default function ProblemsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="id:asc">题号升序</option>
|
<option value="id:asc">{tx("题号升序", "ID Asc")}</option>
|
||||||
<option value="id:desc">题号降序</option>
|
<option value="id:desc">{tx("题号降序", "ID Desc")}</option>
|
||||||
<option value="difficulty:asc">难度升序</option>
|
<option value="difficulty:asc">{tx("难度升序", "Difficulty Asc")}</option>
|
||||||
<option value="difficulty:desc">难度降序</option>
|
<option value="difficulty:desc">{tx("难度降序", "Difficulty Desc")}</option>
|
||||||
<option value="created_at:desc">最新导入</option>
|
<option value="created_at:desc">{tx("最新导入", "Newest Imported")}</option>
|
||||||
<option value="title:asc">标题 A-Z</option>
|
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -314,22 +339,60 @@ export default function ProblemsPage() {
|
|||||||
onClick={applySearch}
|
onClick={applySearch}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "加载中..." : "搜索"}
|
{loading ? tx("加载中...", "Loading...") : tx("搜索", "Search")}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
<section className="mt-4 overflow-x-auto rounded-xl border bg-white">
|
<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">
|
<table className="min-w-full text-sm">
|
||||||
<thead className="bg-zinc-100 text-left text-zinc-700">
|
<thead className="bg-zinc-100 text-left text-zinc-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2">题号</th>
|
<th className="px-3 py-2">{tx("题号", "ID")}</th>
|
||||||
<th className="px-3 py-2">标题</th>
|
<th className="px-3 py-2">{tx("标题", "Title")}</th>
|
||||||
<th className="px-3 py-2">通过/提交</th>
|
<th className="px-3 py-2">{tx("通过/提交", "Accepted/Submissions")}</th>
|
||||||
<th className="px-3 py-2">难度</th>
|
<th className="px-3 py-2">{tx("难度", "Difficulty")}</th>
|
||||||
<th className="px-3 py-2">标签</th>
|
<th className="px-3 py-2">{tx("标签", "Tags")}</th>
|
||||||
<th className="px-3 py-2">来源</th>
|
<th className="px-3 py-2">{tx("来源", "Source")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -365,37 +428,41 @@ export default function ProblemsPage() {
|
|||||||
{!loading && rows.length === 0 && (
|
{!loading && rows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-3 py-6 text-center text-zinc-500" colSpan={6}>
|
<td className="px-3 py-6 text-center text-zinc-500" colSpan={6}>
|
||||||
当前筛选下暂无题目,请切换题单预设或先执行导入脚本。
|
{tx(
|
||||||
|
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。",
|
||||||
|
"No problems under current filters. Switch preset or run import first."
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
|
<div className="mt-4 flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={loading || page <= 1}
|
disabled={loading || page <= 1}
|
||||||
>
|
>
|
||||||
上一页
|
{tx("上一页", "Prev")}
|
||||||
</button>
|
</button>
|
||||||
<span>
|
<span>
|
||||||
第 {page} / {totalPages} 页
|
{isZh ? `第 ${page} / ${totalPages} 页` : `Page ${page} / ${totalPages}`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={loading || page >= totalPages}
|
disabled={loading || page >= totalPages}
|
||||||
>
|
>
|
||||||
下一页
|
{tx("下一页", "Next")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 sm:justify-end">
|
||||||
<span>每页</span>
|
<span>{tx("每页", "Per Page")}</span>
|
||||||
<select
|
<select
|
||||||
className="rounded border px-2 py-1"
|
className="rounded border px-2 py-1"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type RunResult = {
|
type RunResult = {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -23,6 +24,7 @@ int main() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export default function RunPage() {
|
export default function RunPage() {
|
||||||
|
const { tx } = useI18nText();
|
||||||
const [code, setCode] = useState(starterCode);
|
const [code, setCode] = useState(starterCode);
|
||||||
const [input, setInput] = useState("hello csp");
|
const [input, setInput] = useState("hello csp");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -47,21 +49,23 @@ export default function RunPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||||
<h1 className="text-2xl font-semibold">在线 C++ 编写 / 编译 / 运行</h1>
|
<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">
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
<section className="rounded-xl border bg-white p-4">
|
<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
|
<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}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border bg-white p-4">
|
<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
|
<textarea
|
||||||
className="mt-2 h-32 w-full rounded border p-3 font-mono text-sm"
|
className="mt-2 h-32 w-full rounded border p-3 font-mono text-sm"
|
||||||
value={input}
|
value={input}
|
||||||
@@ -69,11 +73,11 @@ export default function RunPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<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()}
|
onClick={() => void run()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "运行中..." : "运行"}
|
{loading ? tx("运行中...", "Running...") : tx("运行", "Run")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
@@ -81,7 +85,7 @@ export default function RunPage() {
|
|||||||
{result && (
|
{result && (
|
||||||
<div className="mt-4 space-y-3 text-sm">
|
<div className="mt-4 space-y-3 text-sm">
|
||||||
<p>
|
<p>
|
||||||
状态: <b>{result.status}</b> · 耗时: {result.time_ms}ms
|
{tx("状态", "Status")}: <b>{result.status}</b> · {tx("耗时", "Time")}: {result.time_ms}ms
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">stdout</h3>
|
<h3 className="font-medium">stdout</h3>
|
||||||
|
|||||||
@@ -3,7 +3,19 @@
|
|||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||||
import { apiFetch } from "@/lib/api";
|
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 = {
|
type Submission = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,22 +26,40 @@ type Submission = {
|
|||||||
code: string;
|
code: string;
|
||||||
status: string;
|
status: string;
|
||||||
score: number;
|
score: number;
|
||||||
|
rating_delta: number;
|
||||||
time_ms: number;
|
time_ms: number;
|
||||||
memory_kb: number;
|
memory_kb: number;
|
||||||
compile_log: string;
|
compile_log: string;
|
||||||
runtime_log: string;
|
runtime_log: string;
|
||||||
created_at: number;
|
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() {
|
export default function SubmissionDetailPage() {
|
||||||
|
const { tx } = useI18nText();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const id = useMemo(() => Number(params.id), [params.id]);
|
const id = useMemo(() => Number(params.id), [params.id]);
|
||||||
|
|
||||||
const [data, setData] = useState<Submission | null>(null);
|
const [data, setData] = useState<Submission | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [analysisLoading, setAnalysisLoading] = useState(false);
|
||||||
|
const [analysisMsg, setAnalysisMsg] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -42,44 +72,138 @@ export default function SubmissionDetailPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (Number.isFinite(id) && id > 0) void load();
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Number.isFinite(id) && id > 0) {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [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 (
|
return (
|
||||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||||
<h1 className="text-2xl font-semibold">提交详情 #{id}</h1>
|
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||||
{loading && <p className="mt-4 text-sm text-zinc-500">加载中...</p>}
|
{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>}
|
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div className="rounded-xl border bg-white p-4 text-sm">
|
<section className="rounded-xl border bg-white p-4 text-sm">
|
||||||
<p>用户: {data.user_id}</p>
|
<div className="grid gap-1 sm:grid-cols-2">
|
||||||
<p>题目: {data.problem_id}</p>
|
<p>{tx("用户", "User")}: {data.user_id}</p>
|
||||||
<p>比赛: {data.contest_id ?? "-"}</p>
|
<p>{tx("题目", "Problem")}: {data.problem_id}</p>
|
||||||
<p>语言: {data.language}</p>
|
<p>{tx("比赛", "Contest")}: {data.contest_id ?? "-"}</p>
|
||||||
<p>状态: {data.status}</p>
|
<p>{tx("语言", "Language")}: {data.language}</p>
|
||||||
<p>分数: {data.score}</p>
|
<p>{tx("状态", "Status")}: {data.status}</p>
|
||||||
<p>时间: {data.time_ms} ms</p>
|
<p>{tx("分数", "Score")}: {data.score}</p>
|
||||||
<p>内存: {data.memory_kb} KB</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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border bg-white p-4">
|
<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">
|
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||||
{data.code}
|
{data.code || "(empty)"}
|
||||||
</pre>
|
</pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border bg-white p-4">
|
<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">
|
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||||
{data.compile_log || "(empty)"}
|
{data.compile_log || "(empty)"}
|
||||||
</pre>
|
</pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border bg-white p-4">
|
<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">
|
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||||
{data.runtime_log || "(empty)"}
|
{data.runtime_log || "(empty)"}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type Submission = {
|
type Submission = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -12,6 +13,7 @@ type Submission = {
|
|||||||
contest_id: number | null;
|
contest_id: number | null;
|
||||||
status: string;
|
status: string;
|
||||||
score: number;
|
score: number;
|
||||||
|
rating_delta: number;
|
||||||
time_ms: number;
|
time_ms: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
};
|
};
|
||||||
@@ -19,6 +21,7 @@ type Submission = {
|
|||||||
type ListResp = { items: Submission[]; page: number; page_size: number };
|
type ListResp = { items: Submission[]; page: number; page_size: number };
|
||||||
|
|
||||||
export default function SubmissionsPage() {
|
export default function SubmissionsPage() {
|
||||||
|
const { tx } = useI18nText();
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [problemId, setProblemId] = useState("");
|
const [problemId, setProblemId] = useState("");
|
||||||
const [contestId, setContestId] = useState("");
|
const [contestId, setContestId] = useState("");
|
||||||
@@ -26,6 +29,17 @@ export default function SubmissionsPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
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 () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -49,8 +63,10 @@ export default function SubmissionsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||||
<h1 className="text-2xl font-semibold">提交记录</h1>
|
<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">
|
<div className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-4">
|
||||||
<input
|
<input
|
||||||
@@ -76,23 +92,49 @@ export default function SubmissionsPage() {
|
|||||||
onClick={() => void load()}
|
onClick={() => void load()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "加载中..." : "筛选"}
|
{loading ? tx("加载中...", "Loading...") : tx("筛选", "Filter")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
<div className="mt-4 overflow-x-auto rounded-xl border bg-white">
|
<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">
|
<table className="min-w-full text-sm">
|
||||||
<thead className="bg-zinc-100 text-left">
|
<thead className="bg-zinc-100 text-left">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2">ID</th>
|
<th className="px-3 py-2">ID</th>
|
||||||
<th className="px-3 py-2">用户</th>
|
<th className="px-3 py-2">{tx("用户", "User")}</th>
|
||||||
<th className="px-3 py-2">题目</th>
|
<th className="px-3 py-2">{tx("题目", "Problem")}</th>
|
||||||
<th className="px-3 py-2">状态</th>
|
<th className="px-3 py-2">{tx("状态", "Status")}</th>
|
||||||
<th className="px-3 py-2">分数</th>
|
<th className="px-3 py-2">{tx("分数", "Score")}</th>
|
||||||
<th className="px-3 py-2">耗时(ms)</th>
|
<th className="px-3 py-2">{tx("Rating 变化", "Rating Delta")}</th>
|
||||||
<th className="px-3 py-2">详情</th>
|
<th className="px-3 py-2">{tx("耗时(ms)", "Time(ms)")}</th>
|
||||||
|
<th className="px-3 py-2">{tx("详情", "Detail")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -103,17 +145,28 @@ export default function SubmissionsPage() {
|
|||||||
<td className="px-3 py-2">{s.problem_id}</td>
|
<td className="px-3 py-2">{s.problem_id}</td>
|
||||||
<td className="px-3 py-2">{s.status}</td>
|
<td className="px-3 py-2">{s.status}</td>
|
||||||
<td className="px-3 py-2">{s.score}</td>
|
<td className="px-3 py-2">{s.score}</td>
|
||||||
|
<td className={`px-3 py-2 ${ratingDeltaClass(s.rating_delta)}`}>
|
||||||
|
{fmtRatingDelta(s.rating_delta)}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2">{s.time_ms}</td>
|
<td className="px-3 py-2">{s.time_ms}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
|
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
|
||||||
查看
|
{tx("查看", "View")}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { readToken } from "@/lib/auth";
|
import { readToken } from "@/lib/auth";
|
||||||
|
import { useI18nText } from "@/lib/i18n";
|
||||||
|
|
||||||
type WrongBookItem = {
|
type WrongBookItem = {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -14,7 +16,13 @@ type WrongBookItem = {
|
|||||||
updated_at: number;
|
updated_at: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function fmtTs(v: number): string {
|
||||||
|
if (!v) return "-";
|
||||||
|
return new Date(v * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
export default function WrongBookPage() {
|
export default function WrongBookPage() {
|
||||||
|
const { tx } = useI18nText();
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [items, setItems] = useState<WrongBookItem[]>([]);
|
const [items, setItems] = useState<WrongBookItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -28,7 +36,7 @@ export default function WrongBookPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
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);
|
const data = await apiFetch<WrongBookItem[]>("/api/v1/me/wrong-book", {}, token);
|
||||||
setItems(data);
|
setItems(data);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -65,9 +73,13 @@ export default function WrongBookPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||||
<h1 className="text-2xl font-semibold">错题本</h1>
|
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||||
<p className="mt-2 text-sm text-zinc-600">未通过提交会自动进入错题本。</p>
|
{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">
|
<div className="mt-4">
|
||||||
<button
|
<button
|
||||||
@@ -75,7 +87,7 @@ export default function WrongBookPage() {
|
|||||||
onClick={() => void load()}
|
onClick={() => void load()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "刷新中..." : "刷新"}
|
{loading ? tx("刷新中...", "Refreshing...") : tx("刷新", "Refresh")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,22 +96,42 @@ export default function WrongBookPage() {
|
|||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.problem_id} className="rounded-xl border bg-white p-4">
|
<div key={item.problem_id} className="rounded-xl border bg-white p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<p className="font-medium">
|
<Link className="font-medium text-blue-700 hover:underline" href={`/problems/${item.problem_id}`}>
|
||||||
#{item.problem_id} {item.problem_title}
|
#{item.problem_id} {item.problem_title}
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
|
||||||
|
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>
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
|
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
|
||||||
onClick={() => void removeItem(item.problem_id)}
|
onClick={() => void removeItem(item.problem_id)}
|
||||||
>
|
>
|
||||||
移除
|
{tx("移除", "Remove")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
|
||||||
最近提交: {item.last_submission_id ?? "-"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="mt-2 h-24 w-full rounded border p-2 text-sm"
|
className="mt-2 h-24 w-full rounded border p-2 text-sm"
|
||||||
value={item.note}
|
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"
|
className="mt-2 rounded border px-3 py-1 text-sm hover:bg-zinc-100"
|
||||||
onClick={() => void updateNote(item.problem_id, item.note)}
|
onClick={() => void updateNote(item.problem_id, item.note)}
|
||||||
>
|
>
|
||||||
保存备注
|
{tx("保存备注", "Save Note")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,64 +1,321 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
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 { clearToken, readToken } from "@/lib/auth";
|
||||||
|
import type { ThemeId } from "@/themes/types";
|
||||||
|
|
||||||
const links = [
|
type NavLink = {
|
||||||
["首页", "/"],
|
label: string;
|
||||||
["登录", "/auth"],
|
href: string;
|
||||||
["题库", "/problems"],
|
};
|
||||||
["提交", "/submissions"],
|
|
||||||
["错题本", "/wrong-book"],
|
type NavGroup = {
|
||||||
["比赛", "/contests"],
|
key: string;
|
||||||
["知识库", "/kb"],
|
label: string;
|
||||||
["导入任务", "/imports"],
|
links: NavLink[];
|
||||||
["在线运行", "/run"],
|
};
|
||||||
["我的", "/me"],
|
|
||||||
["排行榜", "/leaderboard"],
|
function buildNavGroups(t: (key: string) => string, isAdmin: boolean): NavGroup[] {
|
||||||
["API文档", "/api-docs"],
|
const groups: NavGroup[] = [
|
||||||
] as const;
|
{
|
||||||
|
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() {
|
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 [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(() => {
|
useEffect(() => {
|
||||||
const refresh = () => setHasToken(Boolean(readToken()));
|
let canceled = false;
|
||||||
window.addEventListener("storage", refresh);
|
const refresh = async () => {
|
||||||
window.addEventListener("focus", refresh);
|
const token = readToken();
|
||||||
return () => {
|
if (canceled) return;
|
||||||
window.removeEventListener("storage", refresh);
|
setHasToken(Boolean(token));
|
||||||
window.removeEventListener("focus", refresh);
|
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 (
|
return (
|
||||||
<header className="border-b bg-white">
|
<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 flex max-w-6xl flex-wrap items-center gap-2 px-4 py-3">
|
<div className="mx-auto max-w-6xl px-3 py-3 max-[390px]:px-2 max-[390px]:py-2 sm:px-4">
|
||||||
{links.map(([label, href]) => (
|
<div className="flex items-center justify-between md:hidden">
|
||||||
<Link
|
<span className="text-sm font-medium text-zinc-700">{t("nav.menu")}</span>
|
||||||
key={href}
|
<button
|
||||||
href={href}
|
type="button"
|
||||||
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100"
|
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}
|
{menuOpen ? t("nav.collapse") : t("nav.expand")}
|
||||||
</Link>
|
</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"}>
|
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
|
||||||
{hasToken ? "已登录" : "未登录"}
|
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
|
||||||
</span>
|
</span>
|
||||||
{hasToken && (
|
{hasToken && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearToken();
|
clearToken();
|
||||||
setHasToken(false);
|
setHasToken(false);
|
||||||
|
setIsAdmin(false);
|
||||||
}}
|
}}
|
||||||
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
|
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
|
||||||
>
|
>
|
||||||
退出
|
{t("nav.logout")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,160 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
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 MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||||
|
const POLICY_MARKER_OWNER = "csp-cpp14-policy";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
height?: string;
|
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 constexpr(C++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("检测到 %I64d(Windows 特有)", "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 (
|
return (
|
||||||
<MonacoEditor
|
<MonacoEditor
|
||||||
height={height}
|
height={height}
|
||||||
language="cpp"
|
language="cpp"
|
||||||
value={value}
|
value={value}
|
||||||
options={{
|
options={{
|
||||||
fontSize: 14,
|
fontSize,
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
|
glyphMargin: true,
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
wordWrap: "on",
|
wordWrap: "on",
|
||||||
suggestOnTriggerCharacters: true,
|
suggestOnTriggerCharacters: true,
|
||||||
@@ -30,9 +165,34 @@ export function CodeEditor({ value, onChange, height = "420px" }: Props) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onMount={(editor, monaco) => {
|
onMount={(editor, monaco) => {
|
||||||
monaco.languages.registerCompletionItemProvider("cpp", {
|
editorRef.current = editor;
|
||||||
|
monacoRef.current = monaco;
|
||||||
|
|
||||||
|
if (!completionRef.current) {
|
||||||
|
completionRef.current = monaco.languages.registerCompletionItemProvider("cpp", {
|
||||||
provideCompletionItems: () => ({
|
provideCompletionItems: () => ({
|
||||||
suggestions: [
|
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",
|
label: "ios",
|
||||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||||
@@ -40,6 +200,33 @@ export function CodeEditor({ value, onChange, height = "420px" }: Props) {
|
|||||||
"ios::sync_with_stdio(false);\\ncin.tie(nullptr);\\n",
|
"ios::sync_with_stdio(false);\\ncin.tie(nullptr);\\n",
|
||||||
documentation: "Fast IO",
|
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",
|
label: "fori",
|
||||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||||
@@ -58,12 +245,18 @@ export function CodeEditor({ value, onChange, height = "420px" }: Props) {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
updatePolicyIssues(editor.getValue());
|
||||||
|
|
||||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||||
// handled by page-level save button; reserve shortcut for UX consistency.
|
// 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.NEXT_PUBLIC_API_BASE ??
|
||||||
(process.env.NODE_ENV === "development" ? "http://localhost:8080" : "/admin139");
|
(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> =
|
type ApiEnvelope<T> =
|
||||||
| { ok: true; data?: T; [k: string]: unknown }
|
| { ok: true; data?: T; [k: string]: unknown }
|
||||||
| { ok: false; error?: string; [k: string]: unknown };
|
| { ok: false; error?: string; [k: string]: unknown };
|
||||||
@@ -17,11 +23,45 @@ export async function apiFetch<T>(
|
|||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await fetch(`${API_BASE}${path}`, {
|
const method = (init?.method ?? "GET").toUpperCase();
|
||||||
|
const retryable = method === "GET" || method === "HEAD";
|
||||||
|
|
||||||
|
let resp: Response;
|
||||||
|
try {
|
||||||
|
resp = await fetch(`${API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
cache: "no-store",
|
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();
|
const text = await resp.text();
|
||||||
let payload: unknown = null;
|
let payload: unknown = null;
|
||||||
|
|||||||
206
frontend/src/lib/cpp14-policy.ts
普通文件
206
frontend/src/lib/cpp14-policy.ts
普通文件
@@ -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 constexpr(C++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: "检测到 %I64d(Windows 特有)",
|
||||||
|
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
普通文件
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 };
|
||||||
|
}
|
||||||
294
frontend/src/themes/README.md
普通文件
294
frontend/src/themes/README.md
普通文件
@@ -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`
|
||||||
|
|
||||||
|
已落地核心规范:
|
||||||
|
|
||||||
|
- 色板 token(grass/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%;
|
||||||
|
}
|
||||||
21
frontend/src/themes/registry.ts
普通文件
21
frontend/src/themes/registry.ts
普通文件
@@ -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;
|
||||||
|
}
|
||||||
13
frontend/src/themes/types.ts
普通文件
13
frontend/src/themes/types.ts
普通文件
@@ -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 json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -15,6 +18,37 @@ from typing import Any
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
RETRYABLE_HTTP_CODES = {500, 502, 503, 504}
|
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
|
@dataclass
|
||||||
@@ -32,6 +66,17 @@ def now_sec() -> int:
|
|||||||
return int(time.time())
|
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:
|
def extract_json_object(text: str) -> dict[str, Any] | None:
|
||||||
raw = text.strip()
|
raw = text.strip()
|
||||||
if raw.startswith("```"):
|
if raw.startswith("```"):
|
||||||
@@ -54,6 +99,253 @@ def extract_json_object(text: str) -> dict[str, Any] | None:
|
|||||||
return 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:
|
def llm_request(prompt: str, timeout: int, retries: int, sleep_sec: float) -> str:
|
||||||
url = os.getenv("OI_LLM_API_URL", "").strip()
|
url = os.getenv("OI_LLM_API_URL", "").strip()
|
||||||
api_key = os.getenv("OI_LLM_API_KEY", "").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 []
|
choices = payload.get("choices") or []
|
||||||
if not choices:
|
if not choices:
|
||||||
raise RuntimeError("llm response missing 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:
|
if not content:
|
||||||
raise RuntimeError("llm response missing content")
|
raise RuntimeError("llm response missing content")
|
||||||
return str(content)
|
return content
|
||||||
|
|
||||||
if last_error:
|
if last_error:
|
||||||
raise RuntimeError(f"llm request failed: {last_error}") from 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": "按题意拆分步骤,先写可过样例的直观解法,再补边界处理。",
|
"idea_md": "按题意拆分步骤,先写可过样例的直观解法,再补边界处理。",
|
||||||
"explanation_md": "适用于数据范围较小或规则清晰的题。",
|
"explanation_md": "适用于数据范围较小或规则清晰的题。",
|
||||||
"complexity": "时间复杂度依题而定,通常 O(n)~O(n^2)",
|
"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"],
|
"tags": ["simulation", "implementation"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,13 +436,55 @@ def fallback_solutions(max_solutions: int) -> list[dict[str, Any]]:
|
|||||||
"idea_md": "分析状态与重复计算,尝试用前缀和、贪心或动态规划优化。",
|
"idea_md": "分析状态与重复计算,尝试用前缀和、贪心或动态规划优化。",
|
||||||
"explanation_md": "比直接模拟更稳定,通常能覆盖更大数据规模。",
|
"explanation_md": "比直接模拟更稳定,通常能覆盖更大数据规模。",
|
||||||
"complexity": "通常优于朴素解法",
|
"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"],
|
"tags": ["optimization", "dp"],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
return base[: max(1, max_solutions)]
|
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:
|
def load_problem(conn: sqlite3.Connection, problem_id: int) -> Problem:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"SELECT id,title,statement_md,difficulty,source,sample_input,sample_output FROM problems WHERE id=?",
|
"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()
|
idea_md = str(row.get("idea_md") or "").strip()
|
||||||
explanation_md = str(row.get("explanation_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()
|
complexity = str(row.get("complexity") or "").strip()
|
||||||
tags = row.get("tags") if isinstance(row.get("tags"), list) else []
|
tags = row.get("tags") if isinstance(row.get("tags"), list) else []
|
||||||
|
|
||||||
@@ -239,68 +590,41 @@ def main() -> int:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
problem = load_problem(conn, args.problem_id)
|
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"""
|
prompt = build_prompt(problem, max_solutions=requested_solutions)
|
||||||
请为下面这道 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()
|
|
||||||
|
|
||||||
update_job(conn, args.job_id, progress=25, message="requesting llm", updated_at=now_sec())
|
update_job(conn, args.job_id, progress=25, message="requesting llm", updated_at=now_sec())
|
||||||
|
|
||||||
source = "fallback"
|
source = "llm"
|
||||||
solutions: list[dict[str, Any]]
|
solutions: list[dict[str, Any]]
|
||||||
try:
|
try:
|
||||||
content = llm_request(
|
solutions = generate_solutions_with_llm(
|
||||||
prompt,
|
prompt=prompt,
|
||||||
|
max_solutions=requested_solutions,
|
||||||
timeout=args.timeout,
|
timeout=args.timeout,
|
||||||
retries=args.retries,
|
retries=args.retries,
|
||||||
sleep_sec=args.retry_sleep_sec,
|
sleep_sec=args.retry_sleep_sec,
|
||||||
)
|
)
|
||||||
obj = extract_json_object(content)
|
except Exception as exc:
|
||||||
raw = obj.get("solutions") if isinstance(obj, dict) else None
|
if not allow_fallback:
|
||||||
if not isinstance(raw, list) or len(raw) == 0:
|
raise RuntimeError(f"llm generation failed: {str(exc)[:280]}") from exc
|
||||||
raise RuntimeError("llm response missing solutions array")
|
source = "fallback"
|
||||||
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:
|
|
||||||
solutions = fallback_solutions(args.max_solutions)
|
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())
|
update_job(conn, args.job_id, progress=70, message="writing solutions", updated_at=now_sec())
|
||||||
saved = store_solutions(conn, args.problem_id, solutions, source)
|
saved = store_solutions(conn, args.problem_id, solutions, source)
|
||||||
|
|
||||||
|
done_msg = f"completed: {saved} solutions ({source})"
|
||||||
update_job(
|
update_job(
|
||||||
conn,
|
conn,
|
||||||
args.job_id,
|
args.job_id,
|
||||||
status="completed",
|
status="completed",
|
||||||
progress=100,
|
progress=100,
|
||||||
message=f"completed: {saved} solutions ({source})",
|
message=done_msg,
|
||||||
finished_at=now_sec(),
|
finished_at=now_sec(),
|
||||||
updated_at=now_sec(),
|
updated_at=now_sec(),
|
||||||
)
|
)
|
||||||
|
|||||||
487
scripts/import_kb_learning_resources.py
可执行文件
487
scripts/import_kb_learning_resources.py
可执行文件
@@ -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())
|
||||||
1275
scripts/import_local_pdf_rag.py
普通文件
1275
scripts/import_local_pdf_rag.py
普通文件
文件差异内容过多而无法显示
加载差异
在新工单中引用
屏蔽一个用户