From cfbe9a036368e92c394b48da2527ec4bd3780d74 Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Mon, 16 Feb 2026 17:35:22 +0800 Subject: [PATCH] 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> --- backend/CMakeLists.txt | 2 + .../csp/controllers/admin_controller.h | 7 + .../include/csp/controllers/me_controller.h | 18 ++ .../csp/controllers/note_image_controller.h | 18 ++ backend/include/csp/domain/entities.h | 7 + .../services/learning_note_scoring_service.h | 29 +++ .../include/csp/services/wrong_book_service.h | 7 + backend/src/controllers/admin_controller.cc | 32 +++ backend/src/controllers/me_controller.cc | 214 ++++++++++++++++++ .../src/controllers/note_image_controller.cc | 51 +++++ backend/src/controllers/problem_controller.cc | 78 ++++++- backend/src/db/sqlite_db.cc | 157 +++++++++++++ backend/src/domain/json.cc | 17 ++ .../services/learning_note_scoring_service.cc | 135 +++++++++++ .../src/services/solution_access_service.cc | 8 +- backend/src/services/user_service.cc | 10 +- backend/src/services/wrong_book_service.cc | 81 ++++++- frontend/src/app/admin-users/page.tsx | 184 ++++++++++++++- frontend/src/app/me/page.tsx | 31 ++- frontend/src/app/problems/[id]/page.tsx | 96 ++++++++ frontend/src/app/problems/page.tsx | 59 ++++- scripts/analyze_learning_note.py | 151 ++++++++++++ 22 files changed, 1366 insertions(+), 26 deletions(-) create mode 100644 backend/include/csp/controllers/note_image_controller.h create mode 100644 backend/include/csp/services/learning_note_scoring_service.h create mode 100644 backend/src/controllers/note_image_controller.cc create mode 100644 backend/src/services/learning_note_scoring_service.cc create mode 100755 scripts/analyze_learning_note.py diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 90b83b7..e348490 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -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 diff --git a/backend/include/csp/controllers/admin_controller.h b/backend/include/csp/controllers/admin_controller.h index 4520f70..c0cddc6 100644 --- a/backend/include/csp/controllers/admin_controller.h +++ b/backend/include/csp/controllers/admin_controller.h @@ -27,6 +27,9 @@ class AdminController : public drogon::HttpController { 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 { void listRedeemRecords(const drogon::HttpRequestPtr& req, std::function&& cb); + + void userRatingHistory(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t user_id); }; } // namespace csp::controllers diff --git a/backend/include/csp/controllers/me_controller.h b/backend/include/csp/controllers/me_controller.h index f04aba9..b88a162 100644 --- a/backend/include/csp/controllers/me_controller.h +++ b/backend/include/csp/controllers/me_controller.h @@ -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 &&cb, int64_t problem_id); + void scoreWrongBookNote(const drogon::HttpRequestPtr &req, + std::function &&cb, + int64_t problem_id); + + void uploadWrongBookNoteImages(const drogon::HttpRequestPtr &req, + std::function &&cb, + int64_t problem_id); + + void deleteWrongBookNoteImage(const drogon::HttpRequestPtr &req, + std::function &&cb, + int64_t problem_id); + void deleteWrongBookItem(const drogon::HttpRequestPtr &req, std::function &&cb, diff --git a/backend/include/csp/controllers/note_image_controller.h b/backend/include/csp/controllers/note_image_controller.h new file mode 100644 index 0000000..e22418c --- /dev/null +++ b/backend/include/csp/controllers/note_image_controller.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace csp::controllers { + +class NoteImageController : public drogon::HttpController { + 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&& cb, + const std::string& filename); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/domain/entities.h b/backend/include/csp/domain/entities.h index 59ed047..9a98ec8 100644 --- a/backend/include/csp/domain/entities.h +++ b/backend/include/csp/domain/entities.h @@ -86,6 +86,11 @@ struct WrongBookItem { int64_t problem_id = 0; std::optional 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 { diff --git a/backend/include/csp/services/learning_note_scoring_service.h b/backend/include/csp/services/learning_note_scoring_service.h new file mode 100644 index 0000000..50c6087 --- /dev/null +++ b/backend/include/csp/services/learning_note_scoring_service.h @@ -0,0 +1,29 @@ +#pragma once + +#include "csp/db/sqlite_db.h" +#include "csp/domain/entities.h" + +#include +#include + +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 diff --git a/backend/include/csp/services/wrong_book_service.h b/backend/include/csp/services/wrong_book_service.h index 0d33e1a..e194634 100644 --- a/backend/include/csp/services/wrong_book_service.h +++ b/backend/include/csp/services/wrong_book_service.h @@ -20,6 +20,13 @@ class WrongBookService { std::vector 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, diff --git a/backend/src/controllers/admin_controller.cc b/backend/src/controllers/admin_controller.cc index 24b0b83..0fc7c44 100644 --- a/backend/src/controllers/admin_controller.cc +++ b/backend/src/controllers/admin_controller.cc @@ -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&& 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 diff --git a/backend/src/controllers/me_controller.cc b/backend/src/controllers/me_controller.cc index b5958a8..56d2d41 100644 --- a/backend/src/controllers/me_controller.cc +++ b/backend/src/controllers/me_controller.cc @@ -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 @@ -298,6 +301,217 @@ void MeController::upsertWrongBookNote( } } +void MeController::scoreWrongBookNote( + const drogon::HttpRequestPtr &req, + std::function &&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 &&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 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 &&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 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 &&cb, diff --git a/backend/src/controllers/note_image_controller.cc b/backend/src/controllers/note_image_controller.cc new file mode 100644 index 0000000..9b99c0d --- /dev/null +++ b/backend/src/controllers/note_image_controller.cc @@ -0,0 +1,51 @@ +#include "csp/controllers/note_image_controller.h" + +#include + +#include + +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&& 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 diff --git a/backend/src/controllers/problem_controller.cc b/backend/src/controllers/problem_controller.cc index e7c479e..a09d1a0 100644 --- a/backend/src/controllers/problem_controller.cc +++ b/backend/src/controllers/problem_controller.cc @@ -13,8 +13,11 @@ #include #include #include +#include #include +#include + 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 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 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(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 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(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; diff --git a/backend/src/db/sqlite_db.cc b/backend/src/db/sqlite_db.cc index 175ac4e..3efa83b 100644 --- a/backend/src/db/sqlite_db.cc +++ b/backend/src/db/sqlite_db.cc @@ -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 `、`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 diff --git a/backend/src/domain/json.cc b/backend/src/domain/json.cc index 4cbea49..5c28ff8 100644 --- a/backend/src/domain/json.cc +++ b/backend/src/domain/json.cc @@ -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 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; } diff --git a/backend/src/services/learning_note_scoring_service.cc b/backend/src/services/learning_note_scoring_service.cc new file mode 100644 index 0000000..65afff4 --- /dev/null +++ b/backend/src/services/learning_note_scoring_service.cc @@ -0,0 +1,135 @@ +#include "csp/services/learning_note_scoring_service.h" + +#include "csp/services/crypto.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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 diff --git a/backend/src/services/solution_access_service.cc b/backend/src/services/solution_access_service.cc index 0405deb..8988a29 100644 --- a/backend/src/services/solution_access_service.cc +++ b/backend/src/services/solution_access_service.cc @@ -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 " diff --git a/backend/src/services/user_service.cc b/backend/src/services/user_service.cc index 4f96f27..05d0fdc 100644 --- a/backend/src/services/user_service.cc +++ b/backend/src/services/user_service.cc @@ -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); diff --git a/backend/src/services/wrong_book_service.cc b/backend/src/services/wrong_book_service.cc index 70cc026..4c97dff 100644 --- a/backend/src/services/wrong_book_service.cc +++ b/backend/src/services/wrong_book_service.cc @@ -33,7 +33,7 @@ std::vector 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 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; diff --git a/frontend/src/app/admin-users/page.tsx b/frontend/src/app/admin-users/page.tsx index c8de311..03dda67 100644 --- a/frontend/src/app/admin-users/page.tsx +++ b/frontend/src/app/admin-users/page.tsx @@ -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([]); + const [ratingH, setRatingH] = useState([]); + const [redeems, setRedeems] = useState([]); + 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( + `/api/v1/admin/users/${userId}/rating-history?limit=100`, + undefined, token + ); + setRatingH(Array.isArray(d) ? d : []); + } else { + const d = await apiFetch( + `/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 ( +
+
+ + + +
+ {loading &&

{tx("加载中...", "Loading...")}

} + {!loading && tab === "subs" && ( +
+ + + + + + + + {subs.map((s) => ( + + + + + + + + ))} + {subs.length === 0 && } + +
ID{tx("题目", "Problem")}{tx("状态", "Status")}{tx("分数", "Score")}{tx("时间", "Time")}
{s.id}P{s.problem_id}{s.status}{s.score}{fmtTs(s.created_at)}
{tx("无记录", "No records")}
+
+ )} + {!loading && tab === "rating" && ( +
+ {ratingH.map((item, i) => ( +
+ + 0 ? "text-emerald-600" : "text-red-600"}`}> + {item.change > 0 ? `+${item.change}` : item.change} + + {item.note} + + {fmtTs(item.created_at)} +
+ ))} + {ratingH.length === 0 &&

{tx("无记录", "No records")}

} +
+ )} + {!loading && tab === "redeem" && ( +
+ + + + + + + {redeems.map((r) => ( + + + + + + + ))} + {redeems.length === 0 && } + +
{tx("物品", "Item")}{tx("数量", "Qty")}{tx("花费", "Cost")}{tx("时间", "Time")}
{r.item_name}{r.quantity}-{r.total_cost}{fmtTs(r.created_at)}
{tx("无记录", "No records")}
+
+ )} +
+ ); +} + export default function AdminUsersPage() { const { tx } = useI18nText(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [msg, setMsg] = useState(""); + const [expandedId, setExpandedId] = useState(null); const load = async () => { setLoading(true); @@ -110,13 +257,16 @@ export default function AdminUsersPage() { Rating + {tx("提交", "Subs")} + AC {tx("创建时间", "Created At")} {tx("操作", "Action")} {items.map((user) => ( - + + {user.id} {user.username} @@ -133,8 +283,10 @@ export default function AdminUsersPage() { }} /> + {user.total_submissions} + {user.total_ac} {fmtTs(user.created_at)} - + + + {expandedId === user.id && ( + + )} + ))} {!loading && items.length === 0 && ( - + {tx("暂无用户数据", "No users found")} @@ -165,6 +327,9 @@ export default function AdminUsersPage() {

{tx("创建时间:", "Created: ")} {fmtTs(user.created_at)} + {" | "} + {tx("提交:", "Subs: ")}{user.total_submissions} + {" | AC: "}{user.total_ac}

Rating @@ -186,7 +351,14 @@ export default function AdminUsersPage() { > {tx("保存", "Save")} +
+ {expandedId === user.id && } ))} {!loading && items.length === 0 && ( diff --git a/frontend/src/app/me/page.tsx b/frontend/src/app/me/page.tsx index 4c19772..d91aac3 100644 --- a/frontend/src/app/me/page.tsx +++ b/frontend/src/app/me/page.tsx @@ -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 = { + 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 ( + + {isZh ? `查看题解 P${pid}` : `View Solution P${pid}`} + {title ? ` · ${title}` : ""} + + ); + } + // Redeem items keep original text + return note; + }; + const loadAll = async () => { setLoading(true); setError(""); @@ -388,7 +415,7 @@ export default function MePage() { {item.change > 0 ? : } {item.change > 0 ? `+${item.change}` : item.change} - {item.note} + {formatRatingNote(item.note, item.type)} {new Date(item.created_at * 1000).toLocaleString()} diff --git a/frontend/src/app/problems/[id]/page.tsx b/frontend/src/app/problems/[id]/page.tsx index ed53950..103c526 100644 --- a/frontend/src/app/problems/[id]/page.tsx +++ b/frontend/src/app/problems/[id]/page.tsx @@ -301,6 +301,14 @@ export default function ProblemDetailPage() { const [policyIssues, setPolicyIssues] = useState([]); const [policyMsg, setPolicyMsg] = useState(""); + const [noteText, setNoteText] = useState("" ); + const [noteSaving, setNoteSaving] = useState(false); + const [noteScoring, setNoteScoring] = useState(false); + const [noteScore, setNoteScore] = useState(null); + const [noteRating, setNoteRating] = useState(null); + const [noteFeedback, setNoteFeedback] = useState(""); + const [noteMsg, setNoteMsg] = useState(""); + const [showSolutions, setShowSolutions] = useState(false); const [expandedCodes, setExpandedCodes] = useState>(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() { )} + +
+
+

{tx("学习笔记(看完视频后上传/粘贴)", "Learning Notes (paste after watching)")}

+ {tx("满分100分 = rating 10分", "100 pts = rating 10")} +
+ +