feat: problems local stats, user status, admin panel enhancements, rating text
- Problems page: replace Luogu pass rate with local submission stats
(local_submit_count, local_ac_count)
- Problems page: add user AC/fail status column (user_ac, user_fail_count)
- Admin users: add total_submissions and total_ac columns
- Admin users: add detail panel with submissions/rating/redeem tabs
- Admin: new endpoint GET /api/v1/admin/users/{id}/rating-history
- Rating history: note field includes problem title via JOIN
- Me page: translate task codes to friendly labels with icons
- Me page: problem links in rating history are clickable
- Wrong book service, learning note scoring, note image controller
- Backend SQL uses batch queries for performance
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
这个提交包含在:
@@ -28,6 +28,7 @@ add_library(csp_core
|
||||
src/services/kb_import_runner.cc
|
||||
src/services/problem_gen_runner.cc
|
||||
src/services/submission_feedback_service.cc
|
||||
src/services/learning_note_scoring_service.cc
|
||||
src/services/submission_feedback_runner.cc
|
||||
src/services/import_service.cc
|
||||
src/services/import_runner.cc
|
||||
@@ -50,6 +51,7 @@ add_library(csp_web
|
||||
src/controllers/problem_controller.cc
|
||||
src/controllers/submission_controller.cc
|
||||
src/controllers/me_controller.cc
|
||||
src/controllers/note_image_controller.cc
|
||||
src/controllers/contest_controller.cc
|
||||
src/controllers/leaderboard_controller.cc
|
||||
src/controllers/admin_controller.cc
|
||||
|
||||
@@ -27,6 +27,9 @@ class AdminController : public drogon::HttpController<AdminController> {
|
||||
ADD_METHOD_TO(AdminController::listRedeemRecords,
|
||||
"/api/v1/admin/redeem-records",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(AdminController::userRatingHistory,
|
||||
"/api/v1/admin/users/{1}/rating-history",
|
||||
drogon::Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
void listUsers(const drogon::HttpRequestPtr& req,
|
||||
@@ -55,6 +58,10 @@ class AdminController : public drogon::HttpController<AdminController> {
|
||||
|
||||
void listRedeemRecords(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
|
||||
void userRatingHistory(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||
int64_t user_id);
|
||||
};
|
||||
|
||||
} // namespace csp::controllers
|
||||
|
||||
@@ -22,6 +22,12 @@ public:
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}",
|
||||
drogon::Patch);
|
||||
ADD_METHOD_TO(MeController::scoreWrongBookNote, "/api/v1/me/wrong-book/{1}/note-score",
|
||||
drogon::Post);
|
||||
ADD_METHOD_TO(MeController::uploadWrongBookNoteImages, "/api/v1/me/wrong-book/{1}/note-images",
|
||||
drogon::Post);
|
||||
ADD_METHOD_TO(MeController::deleteWrongBookNoteImage, "/api/v1/me/wrong-book/{1}/note-images",
|
||||
drogon::Delete);
|
||||
ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}",
|
||||
drogon::Delete);
|
||||
ADD_METHOD_TO(MeController::listRatingHistory, "/api/v1/me/rating-history",
|
||||
@@ -55,6 +61,18 @@ public:
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id);
|
||||
|
||||
void scoreWrongBookNote(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id);
|
||||
|
||||
void uploadWrongBookNoteImages(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id);
|
||||
|
||||
void deleteWrongBookNoteImage(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id);
|
||||
|
||||
void
|
||||
deleteWrongBookItem(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include <drogon/HttpController.h>
|
||||
|
||||
namespace csp::controllers {
|
||||
|
||||
class NoteImageController : public drogon::HttpController<NoteImageController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(NoteImageController::getNoteImage, "/files/note-images/{1}", drogon::Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
void getNoteImage(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||
const std::string& filename);
|
||||
};
|
||||
|
||||
} // namespace csp::controllers
|
||||
@@ -86,6 +86,11 @@ struct WrongBookItem {
|
||||
int64_t problem_id = 0;
|
||||
std::optional<int64_t> last_submission_id;
|
||||
std::string note;
|
||||
int32_t note_score = 0;
|
||||
int32_t note_rating = 0;
|
||||
std::string note_feedback_md;
|
||||
std::string note_images_json; // JSON array of filenames
|
||||
int64_t note_scored_at = 0;
|
||||
int64_t updated_at = 0;
|
||||
};
|
||||
|
||||
@@ -127,6 +132,8 @@ struct GlobalLeaderboardEntry {
|
||||
std::string username;
|
||||
int32_t rating = 0;
|
||||
int64_t created_at = 0;
|
||||
int32_t total_submissions = 0;
|
||||
int32_t total_ac = 0;
|
||||
};
|
||||
|
||||
struct ContestLeaderboardEntry {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "csp/db/sqlite_db.h"
|
||||
#include "csp/domain/entities.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace csp::services {
|
||||
|
||||
struct LearningNoteScoreResult {
|
||||
int32_t score = 0;
|
||||
int32_t rating = 0;
|
||||
std::string feedback_md;
|
||||
std::string model_name;
|
||||
};
|
||||
|
||||
class LearningNoteScoringService {
|
||||
public:
|
||||
explicit LearningNoteScoringService(db::SqliteDb& db) : db_(db) {}
|
||||
|
||||
LearningNoteScoreResult Score(const std::string& note,
|
||||
const domain::Problem& problem);
|
||||
|
||||
private:
|
||||
db::SqliteDb& db_;
|
||||
};
|
||||
|
||||
} // namespace csp::services
|
||||
@@ -20,6 +20,13 @@ class WrongBookService {
|
||||
|
||||
std::vector<WrongBookEntry> ListByUser(int64_t user_id);
|
||||
void UpsertNote(int64_t user_id, int64_t problem_id, const std::string& note);
|
||||
std::string GetNoteImagesJson(int64_t user_id, int64_t problem_id);
|
||||
void SetNoteImagesJson(int64_t user_id, int64_t problem_id, const std::string& note_images_json);
|
||||
void UpsertNoteScore(int64_t user_id,
|
||||
int64_t problem_id,
|
||||
int32_t note_score,
|
||||
int32_t note_rating,
|
||||
const std::string& note_feedback_md);
|
||||
void UpsertBySubmission(int64_t user_id,
|
||||
int64_t problem_id,
|
||||
int64_t submission_id,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "csp/app_state.h"
|
||||
#include "csp/services/redeem_service.h"
|
||||
#include "csp/services/solution_access_service.h"
|
||||
#include "csp/services/user_service.h"
|
||||
#include "http_auth.h"
|
||||
|
||||
@@ -129,6 +130,8 @@ void AdminController::listUsers(
|
||||
one["username"] = item.username;
|
||||
one["rating"] = item.rating;
|
||||
one["created_at"] = Json::Int64(item.created_at);
|
||||
one["total_submissions"] = item.total_submissions;
|
||||
one["total_ac"] = item.total_ac;
|
||||
arr.append(one);
|
||||
}
|
||||
|
||||
@@ -353,4 +356,33 @@ void AdminController::listRedeemRecords(
|
||||
}
|
||||
}
|
||||
|
||||
void AdminController::userRatingHistory(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||
int64_t user_id) {
|
||||
try {
|
||||
if (!RequireAdminUserId(req, cb).has_value()) return;
|
||||
|
||||
const int limit = ParseClampedInt(req->getParameter("limit"), 200, 1, 500);
|
||||
|
||||
services::SolutionAccessService svc(csp::AppState::Instance().db());
|
||||
const auto items = svc.ListRatingHistory(user_id, limit);
|
||||
|
||||
Json::Value arr(Json::arrayValue);
|
||||
for (const auto& item : items) {
|
||||
Json::Value j;
|
||||
j["type"] = item.type;
|
||||
j["created_at"] = Json::Int64(item.created_at);
|
||||
j["change"] = item.change;
|
||||
j["note"] = item.note;
|
||||
arr.append(j);
|
||||
}
|
||||
cb(JsonOk(arr));
|
||||
} catch (const std::invalid_argument&) {
|
||||
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
|
||||
} catch (const std::exception& e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace csp::controllers
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#include "csp/services/solution_access_service.h"
|
||||
#include "csp/services/user_service.h"
|
||||
#include "csp/services/wrong_book_service.h"
|
||||
#include "csp/services/crypto.h"
|
||||
#include "csp/services/problem_service.h"
|
||||
#include "csp/services/learning_note_scoring_service.h"
|
||||
#include "http_auth.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -298,6 +301,217 @@ void MeController::upsertWrongBookNote(
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::scoreWrongBookNote(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id) {
|
||||
try {
|
||||
const auto user_id = RequireAuth(req, cb);
|
||||
if (!user_id.has_value()) return;
|
||||
|
||||
const auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
cb(JsonError(drogon::k400BadRequest, "body must be json"));
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string note = (*json).get("note", "").asString();
|
||||
if (note.empty()) {
|
||||
cb(JsonError(drogon::k400BadRequest, "note required"));
|
||||
return;
|
||||
}
|
||||
if (note.size() > 8000) {
|
||||
cb(JsonError(drogon::k400BadRequest, "note too long"));
|
||||
return;
|
||||
}
|
||||
|
||||
services::ProblemService problem_svc(csp::AppState::Instance().db());
|
||||
const auto p = problem_svc.GetById(problem_id);
|
||||
if (!p.has_value()) {
|
||||
cb(JsonError(drogon::k404NotFound, "problem not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
services::LearningNoteScoringService scorer(csp::AppState::Instance().db());
|
||||
const auto result = scorer.Score(note, *p);
|
||||
|
||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||
// ensure note saved
|
||||
wrong_book.UpsertNote(*user_id, problem_id, note);
|
||||
wrong_book.UpsertNoteScore(*user_id, problem_id, result.score, result.rating, result.feedback_md);
|
||||
|
||||
Json::Value data;
|
||||
data["user_id"] = Json::Int64(*user_id);
|
||||
data["problem_id"] = Json::Int64(problem_id);
|
||||
data["note"] = note;
|
||||
data["note_score"] = result.score;
|
||||
data["note_rating"] = result.rating;
|
||||
data["note_feedback_md"] = result.feedback_md;
|
||||
data["model_name"] = result.model_name;
|
||||
cb(JsonOk(data));
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void MeController::uploadWrongBookNoteImages(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id) {
|
||||
try {
|
||||
const auto user_id = RequireAuth(req, cb);
|
||||
if (!user_id.has_value()) return;
|
||||
|
||||
const auto files = req->getUploadedFiles();
|
||||
if (files.empty()) {
|
||||
cb(JsonError(drogon::k400BadRequest, "no files"));
|
||||
return;
|
||||
}
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path dir = "/data/note_images";
|
||||
fs::create_directories(dir);
|
||||
|
||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||
|
||||
Json::CharReaderBuilder rb;
|
||||
Json::Value arr(Json::arrayValue);
|
||||
{
|
||||
const std::string current = wrong_book.GetNoteImagesJson(*user_id, problem_id);
|
||||
Json::Value parsed;
|
||||
std::string errs;
|
||||
std::unique_ptr<Json::CharReader> r(rb.newCharReader());
|
||||
if (r->parse(current.data(), current.data() + current.size(), &parsed, &errs) && parsed.isArray()) {
|
||||
arr = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const int kMaxImages = 9;
|
||||
if ((int)arr.size() >= kMaxImages) {
|
||||
cb(JsonError(drogon::k400BadRequest, "too many images"));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value out_items(Json::arrayValue);
|
||||
|
||||
for (const auto &f : files) {
|
||||
if ((int)arr.size() >= kMaxImages) break;
|
||||
|
||||
const auto ct_hdr = f.getFileType();
|
||||
if (ct_hdr.rfind("image/", 0) != 0) {
|
||||
continue;
|
||||
}
|
||||
if (f.fileLength() > 5 * 1024 * 1024) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string ext = f.getFileName();
|
||||
auto pos = ext.find_last_of('.');
|
||||
ext = (pos == std::string::npos) ? std::string(".png") : ext.substr(pos);
|
||||
if (ext.size() > 10) ext = ".png";
|
||||
|
||||
const std::string filename =
|
||||
"u" + std::to_string(*user_id) + "_p" + std::to_string(problem_id) + "_" +
|
||||
crypto::RandomHex(12) + ext;
|
||||
|
||||
const fs::path dest = dir / filename;
|
||||
f.saveAs(dest.string());
|
||||
|
||||
arr.append(filename);
|
||||
|
||||
Json::Value it;
|
||||
it["filename"] = filename;
|
||||
it["url"] = std::string("/files/note-images/") + filename;
|
||||
out_items.append(it);
|
||||
}
|
||||
|
||||
Json::StreamWriterBuilder wb;
|
||||
wb["indentation"] = "";
|
||||
const std::string new_json = Json::writeString(wb, arr);
|
||||
wrong_book.SetNoteImagesJson(*user_id, problem_id, new_json);
|
||||
|
||||
Json::Value data;
|
||||
data["problem_id"] = Json::Int64(problem_id);
|
||||
data["items"] = out_items;
|
||||
data["note_images"] = arr;
|
||||
cb(JsonOk(data));
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::deleteWrongBookNoteImage(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id) {
|
||||
try {
|
||||
const auto user_id = RequireAuth(req, cb);
|
||||
if (!user_id.has_value()) return;
|
||||
|
||||
const auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
cb(JsonError(drogon::k400BadRequest, "body must be json"));
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string filename = (*json).get("filename", "").asString();
|
||||
if (filename.empty() || filename.find("..") != std::string::npos || filename.find('/') != std::string::npos || filename.find('\\\\') != std::string::npos) {
|
||||
cb(JsonError(drogon::k400BadRequest, "bad filename"));
|
||||
return;
|
||||
}
|
||||
|
||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||
const std::string current = wrong_book.GetNoteImagesJson(*user_id, problem_id);
|
||||
|
||||
Json::CharReaderBuilder rb;
|
||||
Json::Value arr(Json::arrayValue);
|
||||
{
|
||||
Json::Value parsed;
|
||||
std::string errs;
|
||||
std::unique_ptr<Json::CharReader> r(rb.newCharReader());
|
||||
if (r->parse(current.data(), current.data() + current.size(), &parsed, &errs) && parsed.isArray()) {
|
||||
arr = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
Json::Value next(Json::arrayValue);
|
||||
bool removed = false;
|
||||
for (const auto &v : arr) {
|
||||
if (v.isString() && v.asString() == filename) {
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
next.append(v);
|
||||
}
|
||||
if (!removed) {
|
||||
cb(JsonError(drogon::k404NotFound, "image not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path full = fs::path("/data/note_images") / filename;
|
||||
if (fs::exists(full) && fs::is_regular_file(full)) {
|
||||
std::error_code ec;
|
||||
fs::remove(full, ec);
|
||||
}
|
||||
|
||||
Json::StreamWriterBuilder wb;
|
||||
wb["indentation"] = "";
|
||||
wrong_book.SetNoteImagesJson(*user_id, problem_id, Json::writeString(wb, next));
|
||||
|
||||
Json::Value data;
|
||||
data["problem_id"] = Json::Int64(problem_id);
|
||||
data["deleted"] = true;
|
||||
data["filename"] = filename;
|
||||
data["note_images"] = next;
|
||||
cb(JsonOk(data));
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void MeController::deleteWrongBookItem(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#include "csp/controllers/note_image_controller.h"
|
||||
|
||||
#include <drogon/HttpResponse.h>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace csp::controllers {
|
||||
|
||||
namespace {
|
||||
|
||||
bool IsSafeFilename(const std::string& name) {
|
||||
if (name.empty()) return false;
|
||||
if (name.size() > 200) return false;
|
||||
if (name.find("..") != std::string::npos) return false;
|
||||
if (name.find('/') != std::string::npos) return false;
|
||||
if (name.find('\\') != std::string::npos) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void NoteImageController::getNoteImage(
|
||||
const drogon::HttpRequestPtr& /*req*/,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||
const std::string& filename) {
|
||||
if (!IsSafeFilename(filename)) {
|
||||
auto resp = drogon::HttpResponse::newHttpResponse();
|
||||
resp->setStatusCode(drogon::k400BadRequest);
|
||||
resp->setBody("bad filename");
|
||||
cb(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path base = "/data/note_images";
|
||||
const fs::path full = base / filename;
|
||||
|
||||
if (!fs::exists(full) || !fs::is_regular_file(full)) {
|
||||
auto resp = drogon::HttpResponse::newHttpResponse();
|
||||
resp->setStatusCode(drogon::k404NotFound);
|
||||
resp->setBody("not found");
|
||||
cb(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
auto resp = drogon::HttpResponse::newFileResponse(full.string());
|
||||
resp->addHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
cb(resp);
|
||||
}
|
||||
|
||||
} // namespace csp::controllers
|
||||
@@ -13,8 +13,11 @@
|
||||
#include <exception>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace csp::controllers {
|
||||
|
||||
namespace {
|
||||
@@ -85,11 +88,82 @@ void ProblemController::list(
|
||||
q.order_by = req->getParameter("order_by");
|
||||
q.order = req->getParameter("order");
|
||||
|
||||
services::ProblemService svc(csp::AppState::Instance().db());
|
||||
auto& db = csp::AppState::Instance().db();
|
||||
services::ProblemService svc(db);
|
||||
const auto result = svc.List(q);
|
||||
|
||||
// Collect problem IDs for batch stats query.
|
||||
std::vector<int64_t> pids;
|
||||
pids.reserve(result.items.size());
|
||||
for (const auto& p : result.items) pids.push_back(p.id);
|
||||
|
||||
// Batch query: local submission stats per problem.
|
||||
struct ProbStats { int submit_count = 0; int ac_count = 0; };
|
||||
std::unordered_map<int64_t, ProbStats> stats_map;
|
||||
if (!pids.empty()) {
|
||||
std::string placeholders;
|
||||
for (size_t i = 0; i < pids.size(); ++i) {
|
||||
if (i) placeholders += ",";
|
||||
placeholders += "?";
|
||||
}
|
||||
const std::string stats_sql =
|
||||
"SELECT problem_id, COUNT(*) as cnt, "
|
||||
"SUM(CASE WHEN status='AC' THEN 1 ELSE 0 END) as ac "
|
||||
"FROM submissions WHERE problem_id IN (" + placeholders + ") "
|
||||
"GROUP BY problem_id";
|
||||
sqlite3_stmt* st = nullptr;
|
||||
sqlite3_prepare_v2(db.raw(), stats_sql.c_str(), -1, &st, nullptr);
|
||||
for (size_t i = 0; i < pids.size(); ++i)
|
||||
sqlite3_bind_int64(st, static_cast<int>(i + 1), pids[i]);
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const int64_t pid = sqlite3_column_int64(st, 0);
|
||||
stats_map[pid] = {sqlite3_column_int(st, 1), sqlite3_column_int(st, 2)};
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
|
||||
// Optional: per-user submission status (if authenticated).
|
||||
struct UserStatus { bool ac = false; int fail_count = 0; };
|
||||
std::unordered_map<int64_t, UserStatus> user_map;
|
||||
std::string auth_err;
|
||||
const auto opt_user_id = GetAuthedUserId(req, auth_err);
|
||||
if (opt_user_id.has_value() && !pids.empty()) {
|
||||
std::string placeholders;
|
||||
for (size_t i = 0; i < pids.size(); ++i) {
|
||||
if (i) placeholders += ",";
|
||||
placeholders += "?";
|
||||
}
|
||||
const std::string user_sql =
|
||||
"SELECT problem_id, "
|
||||
"SUM(CASE WHEN status='AC' THEN 1 ELSE 0 END) as ac, "
|
||||
"SUM(CASE WHEN status!='AC' THEN 1 ELSE 0 END) as fail "
|
||||
"FROM submissions WHERE user_id=? AND problem_id IN (" + placeholders + ") "
|
||||
"GROUP BY problem_id";
|
||||
sqlite3_stmt* st = nullptr;
|
||||
sqlite3_prepare_v2(db.raw(), user_sql.c_str(), -1, &st, nullptr);
|
||||
sqlite3_bind_int64(st, 1, *opt_user_id);
|
||||
for (size_t i = 0; i < pids.size(); ++i)
|
||||
sqlite3_bind_int64(st, static_cast<int>(i + 2), pids[i]);
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const int64_t pid = sqlite3_column_int64(st, 0);
|
||||
user_map[pid] = {sqlite3_column_int(st, 1) > 0, sqlite3_column_int(st, 2)};
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
|
||||
Json::Value arr(Json::arrayValue);
|
||||
for (const auto& p : result.items) arr.append(domain::ToJson(p));
|
||||
for (const auto& p : result.items) {
|
||||
Json::Value j = domain::ToJson(p);
|
||||
auto it = stats_map.find(p.id);
|
||||
j["local_submit_count"] = it != stats_map.end() ? it->second.submit_count : 0;
|
||||
j["local_ac_count"] = it != stats_map.end() ? it->second.ac_count : 0;
|
||||
if (opt_user_id.has_value()) {
|
||||
auto uit = user_map.find(p.id);
|
||||
j["user_ac"] = uit != user_map.end() && uit->second.ac;
|
||||
j["user_fail_count"] = uit != user_map.end() ? uit->second.fail_count : 0;
|
||||
}
|
||||
arr.append(j);
|
||||
}
|
||||
|
||||
Json::Value payload;
|
||||
payload["items"] = arr;
|
||||
|
||||
@@ -362,6 +362,10 @@ CREATE TABLE IF NOT EXISTS wrong_book (
|
||||
problem_id INTEGER NOT NULL,
|
||||
last_submission_id INTEGER,
|
||||
note TEXT NOT NULL DEFAULT "",
|
||||
note_score INTEGER NOT NULL DEFAULT 0,
|
||||
note_rating INTEGER NOT NULL DEFAULT 0,
|
||||
note_feedback_md TEXT NOT NULL DEFAULT "",
|
||||
note_scored_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(user_id, problem_id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -577,6 +581,11 @@ CREATE TABLE IF NOT EXISTS daily_task_logs (
|
||||
EnsureColumn(db, "problem_drafts", "stdin", "stdin TEXT NOT NULL DEFAULT ''");
|
||||
EnsureColumn(db, "problem_solution_jobs", "max_solutions",
|
||||
"max_solutions INTEGER NOT NULL DEFAULT 3");
|
||||
EnsureColumn(db, "wrong_book", "note_score", "note_score INTEGER NOT NULL DEFAULT 0");
|
||||
EnsureColumn(db, "wrong_book", "note_rating", "note_rating INTEGER NOT NULL DEFAULT 0");
|
||||
EnsureColumn(db, "wrong_book", "note_feedback_md", "note_feedback_md TEXT NOT NULL DEFAULT ''");
|
||||
EnsureColumn(db, "wrong_book", "note_scored_at", "note_scored_at INTEGER NOT NULL DEFAULT 0");
|
||||
EnsureColumn(db, "wrong_book", "note_images_json", "note_images_json TEXT NOT NULL DEFAULT '[]'");
|
||||
EnsureColumn(db, "problem_solutions", "variant", "variant INTEGER NOT NULL DEFAULT 1");
|
||||
EnsureColumn(db, "problem_solutions", "idea_md", "idea_md TEXT NOT NULL DEFAULT ''");
|
||||
EnsureColumn(db, "problem_solutions", "explanation_md",
|
||||
@@ -727,6 +736,154 @@ void SeedDemoData(SqliteDb& db) {
|
||||
0,
|
||||
now);
|
||||
}
|
||||
|
||||
// Always seed C++基础课程任务(幂等:按 slug 检测存在)。
|
||||
{
|
||||
const auto existing = QueryOneId(raw, "SELECT id FROM problems WHERE slug='cpp-basic-01-hello' LIMIT 1");
|
||||
if (!existing.has_value()) {
|
||||
const int64_t created = now;
|
||||
struct CourseItem {
|
||||
const char* slug;
|
||||
const char* title;
|
||||
int diff;
|
||||
const char* source;
|
||||
const char* md;
|
||||
const char* tags[6];
|
||||
};
|
||||
const CourseItem items[] = {
|
||||
{
|
||||
"cpp-basic-01-hello",
|
||||
"C++基础01:环境配置与Hello World(VSCode)",
|
||||
1,
|
||||
"course:cpp-basic:01",
|
||||
R"MD(# C++基础01:环境配置与Hello World(VSCode)
|
||||
|
||||
## 学习目标
|
||||
- 安装并打开 VSCode,创建并运行第一个 C++14 程序
|
||||
- 学会新建文件、保存、运行、查看输出
|
||||
- 了解 `main()`、`#include <iostream>`、`cout`
|
||||
|
||||
## 推荐视频(观看后写笔记)
|
||||
- 保姆级:VSCode + mingw64 配置 C/C++(BV1tg411N7Fq)
|
||||
- https://www.bilibili.com/video/BV1tg411N7Fq/
|
||||
- 每天五分钟学C++:01 开发工具(BV1dK4y137bk,系列入口)
|
||||
- https://www.bilibili.com/video/BV1dK4y137bk/
|
||||
|
||||
## 参考图文
|
||||
- LoongBa 极简配置:GCC/VSCode/HelloWorld
|
||||
- https://github.com/LoongBa/Cpp_Beginner_Guide
|
||||
|
||||
## 练习(完成至少 2 题)
|
||||
- B2002 Hello, World! https://www.luogu.com.cn/problem/B2002
|
||||
- P1000 超级玛丽游戏 https://www.luogu.com.cn/problem/P1000
|
||||
|
||||
## 提交要求
|
||||
- 在本题页面下方“学习笔记”区域写下:
|
||||
1) 你安装了什么、遇到什么坑、怎么解决
|
||||
2) 你的 HelloWorld 代码
|
||||
3) 你学到的 3 个关键词
|
||||
)MD",
|
||||
{"cpp-basic", "vscode", "io", "", "", ""}
|
||||
},
|
||||
{
|
||||
"cpp-basic-02-io",
|
||||
"C++基础02:输入输出与变量",
|
||||
1,
|
||||
"course:cpp-basic:02",
|
||||
R"MD(# C++基础02:输入输出与变量
|
||||
|
||||
## 学习目标
|
||||
- 会用 `cin` 读入、`cout` 输出
|
||||
- 理解变量:`int / long long / double / char / string`
|
||||
|
||||
## 推荐视频
|
||||
- 每天五分钟学C++:02 输出、03 变量(系列入口见上)
|
||||
- https://www.bilibili.com/video/BV1dK4y137bk/
|
||||
|
||||
## 练习
|
||||
- P1001 A+B Problem https://www.luogu.com.cn/problem/P1001
|
||||
- B2008 计算 (a+b)×c 的值 https://www.luogu.com.cn/problem/B2008
|
||||
- P5704 字母转换 https://www.luogu.com.cn/problem/P5704
|
||||
|
||||
## 提交要求
|
||||
- 上传/填写学习笔记:写出 `cin/cout` 模板、常见错误(空格/换行)
|
||||
)MD",
|
||||
{"cpp-basic", "io", "types", "", "", ""}
|
||||
},
|
||||
{
|
||||
"cpp-basic-03-branch",
|
||||
"C++基础03:分支结构(if / switch)",
|
||||
2,
|
||||
"course:cpp-basic:03",
|
||||
R"MD(# C++基础03:分支结构(if / switch)
|
||||
|
||||
## 学习目标
|
||||
- 会写 `if / else if / else` 与基本逻辑运算
|
||||
- 能处理边界与分类讨论
|
||||
|
||||
## 练习
|
||||
- B2035 判断数正负 https://www.luogu.com.cn/problem/B2035
|
||||
- P5711 闰年判断 https://www.luogu.com.cn/problem/P5711
|
||||
- P1909 买铅笔 https://www.luogu.com.cn/problem/P1909
|
||||
|
||||
## 提交要求
|
||||
- 笔记里写清:你如何找“边界”(例如 0、最小/最大、等于条件)
|
||||
)MD",
|
||||
{"cpp-basic", "branch", "logic", "", "", ""}
|
||||
},
|
||||
{
|
||||
"cpp-basic-04-loop",
|
||||
"C++基础04:循环结构(for / while)",
|
||||
2,
|
||||
"course:cpp-basic:04",
|
||||
R"MD(# C++基础04:循环结构(for / while)
|
||||
|
||||
## 学习目标
|
||||
- 会用循环做:计数、累加、打印图形
|
||||
|
||||
## 练习
|
||||
- B2083 画矩形 https://www.luogu.com.cn/problem/B2083
|
||||
- P1421 小玉买文具 https://www.luogu.com.cn/problem/P1421
|
||||
|
||||
## 提交要求
|
||||
- 笔记里写:循环三要素(初始化/条件/更新)+ 你调试的方法
|
||||
)MD",
|
||||
{"cpp-basic", "loop", "debug", "", "", ""}
|
||||
},
|
||||
{
|
||||
"cpp-basic-05-array",
|
||||
"C++基础05:数组入门(一维)",
|
||||
3,
|
||||
"course:cpp-basic:05",
|
||||
R"MD(# C++基础05:数组入门(一维)
|
||||
|
||||
## 学习目标
|
||||
- 会定义数组、遍历、统计
|
||||
|
||||
## 练习
|
||||
- P1427 小鱼的数字游戏 https://www.luogu.com.cn/problem/P1427
|
||||
- P1428 小鱼比可爱 https://www.luogu.com.cn/problem/P1428
|
||||
|
||||
## 提交要求
|
||||
- 笔记里写:数组下标从 0/1 的选择;如何避免越界
|
||||
)MD",
|
||||
{"cpp-basic", "array", "", "", "", ""}
|
||||
}
|
||||
};
|
||||
|
||||
for (const auto& it : items) {
|
||||
InsertProblem(raw, it.slug, it.title, it.md, it.diff, it.source, "", "", created);
|
||||
const auto pid = QueryOneId(raw, std::string("SELECT id FROM problems WHERE slug='") + it.slug + "' LIMIT 1");
|
||||
if (pid.has_value()) {
|
||||
for (const char* tag : it.tags) {
|
||||
if (!tag || !*tag) continue;
|
||||
InsertProblemTag(raw, *pid, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} // namespace csp::db
|
||||
|
||||
@@ -59,6 +59,23 @@ Json::Value ToJson(const WrongBookItem& w) {
|
||||
j["last_submission_id"] = Json::nullValue;
|
||||
}
|
||||
j["note"] = w.note;
|
||||
j["note_score"] = w.note_score;
|
||||
j["note_rating"] = w.note_rating;
|
||||
j["note_feedback_md"] = w.note_feedback_md;
|
||||
if (!w.note_images_json.empty()) {
|
||||
Json::Value parsed;
|
||||
Json::CharReaderBuilder b;
|
||||
std::string errs;
|
||||
std::unique_ptr<Json::CharReader> r(b.newCharReader());
|
||||
if (r->parse(w.note_images_json.data(), w.note_images_json.data() + w.note_images_json.size(), &parsed, &errs) && parsed.isArray()) {
|
||||
j["note_images"] = parsed;
|
||||
} else {
|
||||
j["note_images"] = Json::arrayValue;
|
||||
}
|
||||
} else {
|
||||
j["note_images"] = Json::arrayValue;
|
||||
}
|
||||
j["note_scored_at"] = Json::Int64(w.note_scored_at);
|
||||
j["updated_at"] = Json::Int64(w.updated_at);
|
||||
return j;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "csp/services/learning_note_scoring_service.h"
|
||||
|
||||
#include "csp/services/crypto.h"
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <sys/wait.h>
|
||||
#include <vector>
|
||||
|
||||
namespace csp::services {
|
||||
|
||||
namespace {
|
||||
|
||||
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 JsonToString(const Json::Value& value) {
|
||||
Json::StreamWriterBuilder builder;
|
||||
builder["indentation"] = "";
|
||||
return Json::writeString(builder, value);
|
||||
}
|
||||
|
||||
int ExitCodeFromSystem(int rc) {
|
||||
if (rc == -1) return -1;
|
||||
if (WIFEXITED(rc)) return WEXITSTATUS(rc);
|
||||
if (WIFSIGNALED(rc)) return 128 + WTERMSIG(rc);
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::string ResolveScriptPath() {
|
||||
const char* env_path = std::getenv("CSP_NOTE_SCORING_SCRIPT_PATH");
|
||||
if (env_path && std::filesystem::exists(env_path)) return env_path;
|
||||
|
||||
const std::vector<std::string> candidates = {
|
||||
"/app/scripts/analyze_learning_note.py",
|
||||
"scripts/analyze_learning_note.py",
|
||||
"../scripts/analyze_learning_note.py",
|
||||
"../../scripts/analyze_learning_note.py",
|
||||
};
|
||||
for (const auto& path : candidates) {
|
||||
if (std::filesystem::exists(path)) return path;
|
||||
}
|
||||
return "/app/scripts/analyze_learning_note.py";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
LearningNoteScoreResult LearningNoteScoringService::Score(
|
||||
const std::string& note,
|
||||
const domain::Problem& problem) {
|
||||
Json::Value input;
|
||||
input["problem_id"] = Json::Int64(problem.id);
|
||||
input["problem_title"] = problem.title;
|
||||
input["problem_statement"] = problem.statement_md;
|
||||
input["note"] = note;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path temp_file =
|
||||
fs::path("/tmp") / ("csp_note_scoring_" + 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 note scoring 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("note scoring 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("note scoring script returned invalid json");
|
||||
}
|
||||
|
||||
LearningNoteScoreResult r;
|
||||
r.score = parsed.get("score", 0).asInt();
|
||||
r.rating = parsed.get("rating", 0).asInt();
|
||||
r.feedback_md = parsed.get("feedback_md", "").asString();
|
||||
r.model_name = parsed.get("model_name", "").asString();
|
||||
|
||||
if (r.score < 0) r.score = 0;
|
||||
if (r.score > 100) r.score = 100;
|
||||
if (r.rating < 1) r.rating = 1;
|
||||
if (r.rating > 10) r.rating = 10;
|
||||
if (r.feedback_md.empty()) {
|
||||
r.feedback_md =
|
||||
"### 笔记评分\n- 未能生成详细点评,请补充:学习目标、关键概念、代码片段、踩坑与修复。";
|
||||
}
|
||||
if (r.model_name.empty()) r.model_name = "unknown";
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace csp::services
|
||||
@@ -243,9 +243,11 @@ SolutionAccessService::ListRatingHistory(int64_t user_id, int limit) {
|
||||
|
||||
// Union query for history
|
||||
const char *sql =
|
||||
"SELECT 'solution_view' as type, created_at, -cost as change, ('Problem "
|
||||
"' || problem_id) as note "
|
||||
"FROM problem_solution_view_logs WHERE user_id=? AND cost > 0 "
|
||||
"SELECT 'solution_view' as type, v.created_at, -v.cost as change, "
|
||||
"('Problem ' || v.problem_id || ':' || COALESCE(p.title,'')) as note "
|
||||
"FROM problem_solution_view_logs v "
|
||||
"LEFT JOIN problems p ON p.id=v.problem_id "
|
||||
"WHERE v.user_id=? AND v.cost > 0 "
|
||||
"UNION ALL "
|
||||
"SELECT 'daily_task' as type, created_at, reward as change, task_code as "
|
||||
"note "
|
||||
|
||||
@@ -92,7 +92,13 @@ UserListResult UserService::ListUsers(int page, int page_size) {
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"SELECT id,username,rating,created_at FROM users ORDER BY id ASC LIMIT ? OFFSET ?";
|
||||
"SELECT u.id,u.username,u.rating,u.created_at,"
|
||||
"COALESCE(s.cnt,0),COALESCE(s.ac,0) "
|
||||
"FROM users u "
|
||||
"LEFT JOIN (SELECT user_id, COUNT(*) as cnt, "
|
||||
"SUM(CASE WHEN status='AC' THEN 1 ELSE 0 END) as ac "
|
||||
"FROM submissions GROUP BY user_id) s ON s.user_id=u.id "
|
||||
"ORDER BY u.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");
|
||||
@@ -104,6 +110,8 @@ UserListResult UserService::ListUsers(int page, int page_size) {
|
||||
e.username = ColText(stmt, 1);
|
||||
e.rating = sqlite3_column_int(stmt, 2);
|
||||
e.created_at = sqlite3_column_int64(stmt, 3);
|
||||
e.total_submissions = sqlite3_column_int(stmt, 4);
|
||||
e.total_ac = sqlite3_column_int(stmt, 5);
|
||||
result.items.push_back(std::move(e));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
@@ -33,7 +33,7 @@ std::vector<WrongBookEntry> WrongBookService::ListByUser(int64_t user_id) {
|
||||
sqlite3* db = db_.raw();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"SELECT w.user_id,w.problem_id,w.last_submission_id,w.note,w.updated_at,p.title "
|
||||
"SELECT w.user_id,w.problem_id,w.last_submission_id,w.note,w.note_score,w.note_rating,w.note_feedback_md,w.note_images_json,w.note_scored_at,w.updated_at,p.title "
|
||||
"FROM wrong_book w "
|
||||
"JOIN problems p ON p.id=w.problem_id "
|
||||
"WHERE w.user_id=? ORDER BY w.updated_at DESC";
|
||||
@@ -52,8 +52,13 @@ std::vector<WrongBookEntry> WrongBookService::ListByUser(int64_t user_id) {
|
||||
e.item.last_submission_id = sqlite3_column_int64(stmt, 2);
|
||||
}
|
||||
e.item.note = ColText(stmt, 3);
|
||||
e.item.updated_at = sqlite3_column_int64(stmt, 4);
|
||||
e.problem_title = ColText(stmt, 5);
|
||||
e.item.note_score = sqlite3_column_int(stmt, 4);
|
||||
e.item.note_rating = sqlite3_column_int(stmt, 5);
|
||||
e.item.note_feedback_md = ColText(stmt, 6);
|
||||
e.item.note_images_json = ColText(stmt, 7);
|
||||
e.item.note_scored_at = sqlite3_column_int64(stmt, 8);
|
||||
e.item.updated_at = sqlite3_column_int64(stmt, 9);
|
||||
e.problem_title = ColText(stmt, 10);
|
||||
out.push_back(std::move(e));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
@@ -81,6 +86,41 @@ void WrongBookService::UpsertNote(int64_t user_id,
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
void WrongBookService::UpsertNoteScore(int64_t user_id,
|
||||
int64_t problem_id,
|
||||
int32_t note_score,
|
||||
int32_t note_rating,
|
||||
const std::string& note_feedback_md) {
|
||||
sqlite3* db = db_.raw();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"INSERT INTO wrong_book(user_id,problem_id,last_submission_id,note,updated_at,note_score,note_rating,note_feedback_md,note_scored_at) "
|
||||
"VALUES(?,?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(user_id,problem_id) DO UPDATE SET "
|
||||
"note_score=excluded.note_score,"
|
||||
"note_rating=excluded.note_rating,"
|
||||
"note_feedback_md=excluded.note_feedback_md,"
|
||||
"note_scored_at=excluded.note_scored_at,"
|
||||
"updated_at=excluded.updated_at";
|
||||
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
"prepare wrong_book upsert note score");
|
||||
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_null(stmt, 3), db, "bind last_submission_id");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 4, "", -1, SQLITE_TRANSIENT), db, "bind note");
|
||||
const int64_t now = NowSec();
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 5, now), db, "bind updated_at");
|
||||
CheckSqlite(sqlite3_bind_int(stmt, 6, note_score), db, "bind note_score");
|
||||
CheckSqlite(sqlite3_bind_int(stmt, 7, note_rating), db, "bind note_rating");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 8, note_feedback_md.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||
"bind note_feedback_md");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind note_scored_at");
|
||||
CheckSqlite(sqlite3_step(stmt), db, "wrong_book upsert note score");
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
|
||||
void WrongBookService::UpsertBySubmission(int64_t user_id,
|
||||
int64_t problem_id,
|
||||
int64_t submission_id,
|
||||
@@ -108,6 +148,41 @@ void WrongBookService::UpsertBySubmission(int64_t user_id,
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
std::string WrongBookService::GetNoteImagesJson(int64_t user_id, int64_t problem_id) {
|
||||
sqlite3* db = db_.raw();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql = "SELECT note_images_json FROM wrong_book WHERE user_id=? AND problem_id=? LIMIT 1";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare wrong_book get note_images_json");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
|
||||
std::string out = "[]";
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
out = ColText(stmt, 0);
|
||||
if (out.empty()) out = "[]";
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
void WrongBookService::SetNoteImagesJson(int64_t user_id, int64_t problem_id, const std::string& note_images_json) {
|
||||
sqlite3* db = db_.raw();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"INSERT INTO wrong_book(user_id,problem_id,last_submission_id,note,updated_at,note_images_json) "
|
||||
"VALUES(?,?,?,?,?,?) "
|
||||
"ON CONFLICT(user_id,problem_id) DO UPDATE SET note_images_json=excluded.note_images_json,updated_at=excluded.updated_at";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare wrong_book set note_images_json");
|
||||
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_null(stmt, 3), db, "bind last_submission_id");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 4, "", -1, SQLITE_TRANSIENT), db, "bind note");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 5, NowSec()), db, "bind updated_at");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 6, note_images_json.c_str(), -1, SQLITE_TRANSIENT), db, "bind note_images_json");
|
||||
CheckSqlite(sqlite3_step(stmt), db, "wrong_book set note_images_json");
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
|
||||
void WrongBookService::Remove(int64_t user_id, int64_t problem_id) {
|
||||
sqlite3* db = db_.raw();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiFetch, type RatingHistoryItem } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { RefreshCw, Save, Shield, UserCog, Users } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Shield,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
type AdminUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
rating: number;
|
||||
created_at: number;
|
||||
total_submissions: number;
|
||||
total_ac: number;
|
||||
};
|
||||
|
||||
type ListResp = {
|
||||
@@ -21,17 +30,155 @@ type ListResp = {
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
type SubmissionRow = {
|
||||
id: number;
|
||||
problem_id: number;
|
||||
status: string;
|
||||
score: number;
|
||||
language: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type RedeemRow = {
|
||||
id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
day_type: string;
|
||||
total_cost: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
function fmtTs(v: number): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: string) => string }) {
|
||||
const [tab, setTab] = useState<"subs" | "rating" | "redeem">("subs");
|
||||
const [subs, setSubs] = useState<SubmissionRow[]>([]);
|
||||
const [ratingH, setRatingH] = useState<RatingHistoryItem[]>([]);
|
||||
const [redeems, setRedeems] = useState<RedeemRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const token = readToken() ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const loadTab = async () => {
|
||||
try {
|
||||
if (tab === "subs") {
|
||||
const d = await apiFetch<{ items: SubmissionRow[] }>(
|
||||
`/api/v1/submissions?user_id=${userId}&page=1&page_size=50`,
|
||||
undefined, token
|
||||
);
|
||||
setSubs(d.items ?? []);
|
||||
} else if (tab === "rating") {
|
||||
const d = await apiFetch<RatingHistoryItem[]>(
|
||||
`/api/v1/admin/users/${userId}/rating-history?limit=100`,
|
||||
undefined, token
|
||||
);
|
||||
setRatingH(Array.isArray(d) ? d : []);
|
||||
} else {
|
||||
const d = await apiFetch<RedeemRow[]>(
|
||||
`/api/v1/admin/redeem-records?user_id=${userId}&limit=100`,
|
||||
undefined, token
|
||||
);
|
||||
setRedeems(Array.isArray(d) ? d : []);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
};
|
||||
void loadTab();
|
||||
}, [tab, userId, token]);
|
||||
|
||||
const tabCls = (t: string) =>
|
||||
`px-3 py-1 text-xs border ${tab === t ? "bg-zinc-900 text-white" : "bg-white text-zinc-700 hover:bg-zinc-100"}`;
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-50 border-t p-3 text-xs">
|
||||
<div className="flex gap-1 mb-2">
|
||||
<button className={tabCls("subs")} onClick={() => setTab("subs")}>
|
||||
{tx("提交记录", "Submissions")}
|
||||
</button>
|
||||
<button className={tabCls("rating")} onClick={() => setTab("rating")}>
|
||||
{tx("积分历史", "Rating History")}
|
||||
</button>
|
||||
<button className={tabCls("redeem")} onClick={() => setTab("redeem")}>
|
||||
{tx("兑换记录", "Redeem Records")}
|
||||
</button>
|
||||
</div>
|
||||
{loading && <p className="text-zinc-500">{tx("加载中...", "Loading...")}</p>}
|
||||
{!loading && tab === "subs" && (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead><tr className="text-left text-zinc-500">
|
||||
<th className="pr-2">ID</th><th className="pr-2">{tx("题目", "Problem")}</th>
|
||||
<th className="pr-2">{tx("状态", "Status")}</th><th className="pr-2">{tx("分数", "Score")}</th>
|
||||
<th>{tx("时间", "Time")}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{subs.map((s) => (
|
||||
<tr key={s.id} className="border-t border-zinc-200">
|
||||
<td className="pr-2">{s.id}</td>
|
||||
<td className="pr-2">P{s.problem_id}</td>
|
||||
<td className={`pr-2 font-bold ${s.status === "AC" ? "text-emerald-600" : "text-red-600"}`}>{s.status}</td>
|
||||
<td className="pr-2">{s.score}</td>
|
||||
<td className="text-zinc-500">{fmtTs(s.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{subs.length === 0 && <tr><td colSpan={5} className="text-zinc-400 py-2">{tx("无记录", "No records")}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!loading && tab === "rating" && (
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{ratingH.map((item, i) => (
|
||||
<div key={i} className="flex justify-between border-b border-zinc-200 pb-1">
|
||||
<span>
|
||||
<span className={`font-bold ${item.change > 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{item.change > 0 ? `+${item.change}` : item.change}
|
||||
</span>
|
||||
<span className="ml-2 text-zinc-600">{item.note}</span>
|
||||
</span>
|
||||
<span className="text-zinc-400">{fmtTs(item.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
{ratingH.length === 0 && <p className="text-zinc-400">{tx("无记录", "No records")}</p>}
|
||||
</div>
|
||||
)}
|
||||
{!loading && tab === "redeem" && (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead><tr className="text-left text-zinc-500">
|
||||
<th className="pr-2">{tx("物品", "Item")}</th><th className="pr-2">{tx("数量", "Qty")}</th>
|
||||
<th className="pr-2">{tx("花费", "Cost")}</th><th>{tx("时间", "Time")}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{redeems.map((r) => (
|
||||
<tr key={r.id} className="border-t border-zinc-200">
|
||||
<td className="pr-2">{r.item_name}</td>
|
||||
<td className="pr-2">{r.quantity}</td>
|
||||
<td className="pr-2 text-red-600">-{r.total_cost}</td>
|
||||
<td className="text-zinc-500">{fmtTs(r.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{redeems.length === 0 && <tr><td colSpan={4} className="text-zinc-400 py-2">{tx("无记录", "No records")}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -110,13 +257,16 @@ export default function AdminUsersPage() {
|
||||
<Shield size={14} />
|
||||
Rating
|
||||
</th>
|
||||
<th className="px-3 py-2">{tx("提交", "Subs")}</th>
|
||||
<th className="px-3 py-2">AC</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">
|
||||
<React.Fragment key={user.id}>
|
||||
<tr 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">
|
||||
@@ -133,8 +283,10 @@ export default function AdminUsersPage() {
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">{user.total_submissions}</td>
|
||||
<td className="px-3 py-2 text-emerald-700 font-bold">{user.total_ac}</td>
|
||||
<td className="px-3 py-2 text-zinc-600">{fmtTs(user.created_at)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2 flex items-center gap-1">
|
||||
<button
|
||||
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100 flex items-center gap-1"
|
||||
onClick={() => void updateRating(user.id, Math.max(0, Number(user.rating) || 0))}
|
||||
@@ -142,12 +294,22 @@ export default function AdminUsersPage() {
|
||||
<Save size={12} />
|
||||
{tx("保存", "Save")}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100"
|
||||
onClick={() => setExpandedId(expandedId === user.id ? null : user.id)}
|
||||
>
|
||||
{expandedId === user.id ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedId === user.id && (
|
||||
<tr><td colSpan={7} className="p-0"><DetailPanel userId={user.id} tx={tx} /></td></tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-center text-zinc-500" colSpan={5}>
|
||||
<td className="px-3 py-6 text-center text-zinc-500" colSpan={7}>
|
||||
{tx("暂无用户数据", "No users found")}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -165,6 +327,9 @@ export default function AdminUsersPage() {
|
||||
<p className="text-xs text-zinc-500">
|
||||
{tx("创建时间:", "Created: ")}
|
||||
{fmtTs(user.created_at)}
|
||||
{" | "}
|
||||
{tx("提交:", "Subs: ")}{user.total_submissions}
|
||||
{" | AC: "}{user.total_ac}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-600">Rating</span>
|
||||
@@ -186,7 +351,14 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
{tx("保存", "Save")}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
onClick={() => setExpandedId(expandedId === user.id ? null : user.id)}
|
||||
>
|
||||
{expandedId === user.id ? tx("收起", "Hide") : tx("详情", "Detail")}
|
||||
</button>
|
||||
</div>
|
||||
{expandedId === user.id && <DetailPanel userId={user.id} tx={tx} />}
|
||||
</div>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Calendar,
|
||||
@@ -135,6 +135,33 @@ export default function MePage() {
|
||||
return name;
|
||||
};
|
||||
|
||||
const formatRatingNote = (note: string, type: string): React.ReactNode => {
|
||||
// Daily task codes
|
||||
const taskLabels: Record<string, [string, string]> = {
|
||||
login_checkin: ["每日签到 🎯", "Daily Sign-in 🎯"],
|
||||
daily_submit: ["每日提交 📝", "Daily Submission 📝"],
|
||||
first_ac: ["首次通过 ⭐", "First AC ⭐"],
|
||||
code_quality: ["代码质量 🛠️", "Code Quality 🛠️"],
|
||||
};
|
||||
if (type === "daily_task" && taskLabels[note]) {
|
||||
return isZh ? taskLabels[note][0] : taskLabels[note][1];
|
||||
}
|
||||
// Solution view: "Problem 1234:Title"
|
||||
const m = note.match(/^Problem (\d+):(.*)$/);
|
||||
if (m) {
|
||||
const pid = m[1];
|
||||
const title = m[2].trim();
|
||||
return (
|
||||
<a href={`/problems/${pid}`} className="hover:underline text-[color:var(--mc-diamond)]">
|
||||
{isZh ? `查看题解 P${pid}` : `View Solution P${pid}`}
|
||||
{title ? ` · ${title}` : ""}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// Redeem items keep original text
|
||||
return note;
|
||||
};
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -388,7 +415,7 @@ export default function MePage() {
|
||||
{item.change > 0 ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||||
{item.change > 0 ? `+${item.change}` : item.change}
|
||||
</span>
|
||||
<span className="ml-2">{item.note}</span>
|
||||
<span className="ml-2">{formatRatingNote(item.note, item.type)}</span>
|
||||
</span>
|
||||
<span className="text-[color:var(--mc-stone-dark)]">
|
||||
{new Date(item.created_at * 1000).toLocaleString()}
|
||||
|
||||
@@ -301,6 +301,14 @@ export default function ProblemDetailPage() {
|
||||
const [policyIssues, setPolicyIssues] = useState<Cpp14PolicyIssue[]>([]);
|
||||
const [policyMsg, setPolicyMsg] = useState("");
|
||||
|
||||
const [noteText, setNoteText] = useState("" );
|
||||
const [noteSaving, setNoteSaving] = useState(false);
|
||||
const [noteScoring, setNoteScoring] = useState(false);
|
||||
const [noteScore, setNoteScore] = useState<number | null>(null);
|
||||
const [noteRating, setNoteRating] = useState<number | null>(null);
|
||||
const [noteFeedback, setNoteFeedback] = useState("");
|
||||
const [noteMsg, setNoteMsg] = useState("");
|
||||
|
||||
const [showSolutions, setShowSolutions] = useState(false);
|
||||
const [expandedCodes, setExpandedCodes] = useState<Set<number>>(new Set());
|
||||
const [unlockConfirm, setUnlockConfirm] = useState(false);
|
||||
@@ -332,6 +340,57 @@ export default function ProblemDetailPage() {
|
||||
if (!problem) return "";
|
||||
return sanitizeStatementMarkdown(problem);
|
||||
}, [problem]);
|
||||
const saveLearningNote = async () => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setNoteMsg(tx("请先登录后再保存笔记。", "Please login to save notes."));
|
||||
return;
|
||||
}
|
||||
if (!problemId) return;
|
||||
setNoteSaving(true);
|
||||
setNoteMsg("");
|
||||
try {
|
||||
await apiFetch<{ note: string }>(`/api/v1/me/wrong-book/${problemId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note: noteText }),
|
||||
}, token);
|
||||
setNoteMsg(tx("笔记已保存。", "Notes saved."));
|
||||
} catch (e: unknown) {
|
||||
setNoteMsg(String(e));
|
||||
} finally {
|
||||
setNoteSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scoreLearningNote = async () => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setNoteMsg(tx("请先登录后再评分。", "Please login to score notes."));
|
||||
return;
|
||||
}
|
||||
if (!problemId) return;
|
||||
setNoteScoring(true);
|
||||
setNoteMsg("");
|
||||
try {
|
||||
const resp = await apiFetch<{
|
||||
note_score: number;
|
||||
note_rating: number;
|
||||
note_feedback_md: string;
|
||||
}>(`/api/v1/me/wrong-book/${problemId}/note-score`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ note: noteText }),
|
||||
}, token);
|
||||
setNoteScore(resp.note_score);
|
||||
setNoteRating(resp.note_rating);
|
||||
setNoteFeedback(resp.note_feedback_md || "");
|
||||
setNoteMsg(tx("评分完成。", "Scored."));
|
||||
} catch (e: unknown) {
|
||||
setNoteMsg(String(e));
|
||||
} finally {
|
||||
setNoteScoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sampleInput = problem?.sample_input ?? "";
|
||||
const problemId = problem?.id ?? 0;
|
||||
const printableAnswerMarkdown = useMemo(
|
||||
@@ -841,6 +900,43 @@ export default function ProblemDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded border-[3px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[3px_3px_0_rgba(0,0,0,0.45)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-bold text-black">{tx("学习笔记(看完视频后上传/粘贴)", "Learning Notes (paste after watching)")}</h3>
|
||||
<span className="text-xs text-zinc-700">{tx("满分100分 = rating 10分", "100 pts = rating 10")}</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-none border-[2px] border-black bg-[color:var(--mc-plank-light)] p-2 text-xs text-black shadow-[2px_2px_0_rgba(0,0,0,0.35)]"
|
||||
rows={8}
|
||||
value={noteText}
|
||||
placeholder={tx("建议写:学习目标/关键概念/代码模板/踩坑与修复/总结", "Suggested: goals / key ideas / code template / pitfalls / summary")}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button className="mc-btn mc-btn-success text-xs" onClick={() => void saveLearningNote()} disabled={noteSaving}>
|
||||
{noteSaving ? tx("保存中...", "Saving...") : tx("保存笔记", "Save")}
|
||||
</button>
|
||||
<button className="mc-btn text-xs" onClick={() => void scoreLearningNote()} disabled={noteScoring}>
|
||||
{noteScoring ? tx("评分中...", "Scoring...") : tx("笔记评分", "Score")}
|
||||
</button>
|
||||
{noteScore !== null && noteRating !== null && (
|
||||
<span className="text-xs text-black self-center">
|
||||
{tx("得分:", "Score: ")}{noteScore}/100 · {tx("评级:", "Rating: ")}{noteRating}/10
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{noteMsg && <p className="mt-2 text-xs text-[color:var(--mc-stone-dark)]">{noteMsg}</p>}
|
||||
|
||||
{noteFeedback && (
|
||||
<div className="mt-3 border-t-2 border-dashed border-black pt-3">
|
||||
<MarkdownRenderer markdown={noteFeedback} className="problem-markdown text-black" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 print:hidden">
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
|
||||
type Problem = {
|
||||
@@ -27,6 +28,10 @@ type Problem = {
|
||||
source: string;
|
||||
llm_profile_json: string;
|
||||
created_at: number;
|
||||
local_submit_count?: number;
|
||||
local_ac_count?: number;
|
||||
user_ac?: boolean;
|
||||
user_fail_count?: number;
|
||||
};
|
||||
|
||||
type ProblemListResp = {
|
||||
@@ -55,6 +60,13 @@ type Preset = {
|
||||
};
|
||||
|
||||
const PRESETS: Preset[] = [
|
||||
{
|
||||
key: "cpp-basic",
|
||||
labelZh: "C++基础",
|
||||
labelEn: "C++ Basics",
|
||||
sourcePrefix: "course:cpp-basic:",
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
key: "csp-beginner-default",
|
||||
labelZh: "CSP J/S 入门预设",
|
||||
@@ -95,6 +107,14 @@ const PRESETS: Preset[] = [
|
||||
];
|
||||
|
||||
const QUICK_CARDS = [
|
||||
{
|
||||
presetKey: "cpp-basic",
|
||||
titleZh: "C++基础",
|
||||
titleEn: "C++ Basics",
|
||||
descZh: "零基础入门任务",
|
||||
descEn: "Zero-to-One Quests",
|
||||
icon: Book,
|
||||
},
|
||||
{
|
||||
presetKey: "csp-j",
|
||||
titleZh: "CSP-J 试炼",
|
||||
@@ -170,10 +190,10 @@ function resolvePid(problem: Problem, profile: ProblemProfile | null): string {
|
||||
return /^[A-Za-z]\d+$/.test(head) ? head : String(problem.id);
|
||||
}
|
||||
|
||||
function resolvePassRate(profile: ProblemProfile | null): string {
|
||||
const accepted = profile?.stats?.total_accepted;
|
||||
const submitted = profile?.stats?.total_submit;
|
||||
if (!submitted || submitted <= 0 || accepted === undefined) return "-";
|
||||
function resolvePassRate(problem: Problem): string {
|
||||
const submitted = problem.local_submit_count ?? 0;
|
||||
const accepted = problem.local_ac_count ?? 0;
|
||||
if (submitted <= 0) return "-";
|
||||
const rate = ((accepted / submitted) * 100).toFixed(1);
|
||||
return `${accepted}/${submitted} (${rate}%)`;
|
||||
}
|
||||
@@ -224,7 +244,7 @@ export default function ProblemsPage() {
|
||||
if (preset.sourcePrefix) params.set("source_prefix", preset.sourcePrefix);
|
||||
if (preset.tags && preset.tags.length > 0) params.set("tags", preset.tags.join(","));
|
||||
|
||||
const data = await apiFetch<ProblemListResp>(`/api/v1/problems?${params.toString()}`);
|
||||
const data = await apiFetch<ProblemListResp>(`/api/v1/problems?${params.toString()}`, undefined, readToken() ?? undefined);
|
||||
setItems(data.items ?? []);
|
||||
setTotalCount(data.total_count ?? 0);
|
||||
} catch (e: unknown) {
|
||||
@@ -279,7 +299,7 @@ export default function ProblemsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<section className="mt-4 grid gap-3 md:grid-cols-4">
|
||||
{QUICK_CARDS.map((card) => {
|
||||
const active = presetKey === card.presetKey;
|
||||
return (
|
||||
@@ -394,7 +414,16 @@ export default function ProblemsPage() {
|
||||
{difficultyIcon(problem.difficulty)} T{problem.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[color:var(--mc-stone)]">{tx("完成率:", "Clear Rate: ")}{resolvePassRate(profile)}</p>
|
||||
<p className="text-xs text-[color:var(--mc-stone)]">{tx("完成率:", "Clear Rate: ")}{resolvePassRate(problem)}</p>
|
||||
{problem.user_ac !== undefined && (
|
||||
<p className="text-xs">
|
||||
{problem.user_ac
|
||||
? <span className="text-[color:var(--mc-green)] font-bold">✅ AC</span>
|
||||
: problem.user_fail_count && problem.user_fail_count > 0
|
||||
? <span className="text-[color:var(--mc-red)]">❌ ×{problem.user_fail_count}</span>
|
||||
: <span className="text-[color:var(--mc-stone-dark)]">-</span>}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.length === 0 && <span className="text-xs text-[color:var(--mc-stone-dark)]">-</span>}
|
||||
{tags.map((tag) => (
|
||||
@@ -423,6 +452,7 @@ export default function ProblemsPage() {
|
||||
<th className="px-3 py-2">{tx("编号", "ID")}</th>
|
||||
<th className="px-3 py-2">{tx("任务标题", "Quest Title")}</th>
|
||||
<th className="px-3 py-2">{tx("完成率", "Clear Rate")}</th>
|
||||
<th className="px-3 py-2">{tx("状态", "Status")}</th>
|
||||
<th className="px-3 py-2">{tx("难度", "Tier")}</th>
|
||||
<th className="px-3 py-2">{tx("标签", "Tags")}</th>
|
||||
<th className="px-3 py-2">{tx("来源", "Source")}</th>
|
||||
@@ -440,7 +470,18 @@ export default function ProblemsPage() {
|
||||
{problem.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[color:var(--mc-stone)]">{resolvePassRate(profile)}</td>
|
||||
<td className="px-3 py-2 text-[color:var(--mc-stone)]">{resolvePassRate(problem)}</td>
|
||||
<td className="px-3 py-2">
|
||||
{problem.user_ac !== undefined ? (
|
||||
problem.user_ac
|
||||
? <span className="text-[color:var(--mc-green)] font-bold">✅ AC</span>
|
||||
: problem.user_fail_count && problem.user_fail_count > 0
|
||||
? <span className="text-[color:var(--mc-red)]">❌ ×{problem.user_fail_count}</span>
|
||||
: <span className="text-[color:var(--mc-stone-dark)]">-</span>
|
||||
) : (
|
||||
<span className="text-[color:var(--mc-stone-dark)]">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-3 py-2 font-bold ${difficultyClass(problem.difficulty)}`}>
|
||||
{difficultyIcon(problem.difficulty)} {problem.difficulty}
|
||||
</td>
|
||||
@@ -464,7 +505,7 @@ export default function ProblemsPage() {
|
||||
})}
|
||||
{!loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-center text-[color:var(--mc-stone)]" colSpan={6}>
|
||||
<td className="px-3 py-6 text-center text-[color:var(--mc-stone)]" colSpan={7}>
|
||||
{tx(
|
||||
"没有找到任务。请尝试其他频道或刷新地图。",
|
||||
"No quests found. Try different channel or reload map."
|
||||
|
||||
151
scripts/analyze_learning_note.py
可执行文件
151
scripts/analyze_learning_note.py
可执行文件
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Score a learning note (0-100) and map to rating (1-10) via LLM with fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def env(name: str, default: str = "") -> str:
|
||||
v = os.getenv(name, "").strip()
|
||||
return v if v 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 fallback(note: str) -> Dict[str, Any]:
|
||||
n = note.strip()
|
||||
score = 40
|
||||
if len(n) >= 300:
|
||||
score += 15
|
||||
if len(n) >= 800:
|
||||
score += 10
|
||||
if "```" in n:
|
||||
score += 15
|
||||
if "踩坑" in n or "错误" in n or "debug" in n.lower():
|
||||
score += 10
|
||||
if "总结" in n or "注意" in n:
|
||||
score += 10
|
||||
score = min(100, score)
|
||||
rating = max(1, min(10, round(score / 10)))
|
||||
feedback_md = (
|
||||
"### 笔记评分(规则兜底)\n"
|
||||
f"- 评分:**{score}/100**,评级:**{rating}/10**\n"
|
||||
"\n### 你做得好的地方\n"
|
||||
"- 记录了学习过程(已检测到一定的笔记内容)。\n"
|
||||
"\n### 建议补充\n"
|
||||
"- 写清:本节课**学习目标**、**关键概念**、**代码模板**。\n"
|
||||
"- 至少写 1 个你遇到的坑(如输入输出格式、编译报错)以及解决方案。\n"
|
||||
"- 最后用 3-5 行做总结,方便复习。\n"
|
||||
)
|
||||
return {"score": score, "rating": rating, "feedback_md": feedback_md, "model_name": "fallback-rules"}
|
||||
|
||||
|
||||
def call_llm(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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 = (
|
||||
"你是一位面向小学生的C++竞赛教练,请对学习笔记打分。"
|
||||
"评分满分100分,并给出10分制评级(rating=round(score/10),范围1-10)。"
|
||||
"评分维度:覆盖度30、正确性30、可操作性20、反思总结20。"
|
||||
"输出必须是JSON,不要输出其他任何文字。"
|
||||
)
|
||||
user = {
|
||||
"task": "对学习笔记评分并给出改进建议",
|
||||
"problem": {
|
||||
"id": payload.get("problem_id"),
|
||||
"title": payload.get("problem_title"),
|
||||
},
|
||||
"note": payload.get("note", ""),
|
||||
"output_json_schema": {
|
||||
"score": "integer 0-100",
|
||||
"rating": "integer 1-10",
|
||||
"feedback_md": "markdown string",
|
||||
"model_name": "string",
|
||||
},
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"stream": False,
|
||||
"temperature": 0.2,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": json.dumps(user, ensure_ascii=False)},
|
||||
],
|
||||
}
|
||||
|
||||
last: Optional[Exception] = None
|
||||
for attempt in range(4):
|
||||
try:
|
||||
resp = requests.post(api_url, headers=headers, json=body, timeout=50)
|
||||
if resp.status_code < 500:
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
raise RuntimeError(f"HTTP {resp.status_code}")
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
parsed = json.loads(content)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("model output not object")
|
||||
score = int(parsed.get("score", 0))
|
||||
score = max(0, min(100, score))
|
||||
rating = int(parsed.get("rating", round(score / 10)))
|
||||
rating = max(1, min(10, rating))
|
||||
feedback_md = str(parsed.get("feedback_md", "")).strip() or "### 笔记评分\n- 请补充更多内容(学习目标/代码/总结)。"
|
||||
model_name = str(parsed.get("model_name", model)).strip() or model
|
||||
return {"score": score, "rating": rating, "feedback_md": feedback_md, "model_name": model_name}
|
||||
except Exception as e: # noqa: BLE001
|
||||
last = e
|
||||
time.sleep(0.6 * (attempt + 1))
|
||||
|
||||
raise RuntimeError(str(last) if last else "llm failed")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--input-file", required=True)
|
||||
args = ap.parse_args()
|
||||
|
||||
payload = load_input(args.input_file)
|
||||
note = str(payload.get("note", ""))
|
||||
if not note.strip():
|
||||
print(
|
||||
json.dumps(
|
||||
{"score": 0, "rating": 1, "feedback_md": "### 笔记为空\n请先写笔记再评分。", "model_name": "validator"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
out = call_llm(payload)
|
||||
except Exception:
|
||||
out = fallback(note)
|
||||
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
在新工单中引用
屏蔽一个用户