feat: add daily tasks and fix /admin139 admin entry

这个提交包含在:
Codex CLI
2026-02-15 12:51:42 +08:00
父节点 e2ab522b78
当前提交 ad29a9f62d
修改 13 个文件,包含 1200 行新增30 行删除

查看文件

@@ -19,10 +19,15 @@ add_library(csp_core
src/services/wrong_book_service.cc
src/services/kb_service.cc
src/services/contest_service.cc
src/services/daily_task_service.cc
src/services/submission_service.cc
src/services/solution_access_service.cc
src/services/redeem_service.cc
src/services/problem_workspace_service.cc
src/services/problem_solution_runner.cc
src/services/kb_import_runner.cc
src/services/problem_gen_runner.cc
src/services/submission_feedback_service.cc
src/services/import_service.cc
src/services/import_runner.cc
src/domain/enum_strings.cc
@@ -46,6 +51,7 @@ add_library(csp_web
src/controllers/me_controller.cc
src/controllers/contest_controller.cc
src/controllers/leaderboard_controller.cc
src/controllers/admin_controller.cc
src/controllers/kb_controller.cc
src/controllers/import_controller.cc
src/controllers/meta_controller.cc
@@ -87,6 +93,7 @@ add_executable(csp_tests
tests/problem_service_test.cc
tests/kb_service_test.cc
tests/contest_service_test.cc
tests/daily_task_service_test.cc
tests/submission_service_test.cc
tests/me_http_test.cc
tests/problem_http_test.cc

查看文件

@@ -10,6 +10,10 @@ class MeController : public drogon::HttpController<MeController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get);
ADD_METHOD_TO(MeController::listRedeemItems, "/api/v1/me/redeem/items", drogon::Get);
ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records", drogon::Get);
ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records", drogon::Post);
ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks", drogon::Get);
ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book", drogon::Get);
ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}", drogon::Patch);
ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}", drogon::Delete);
@@ -18,6 +22,18 @@ class MeController : public drogon::HttpController<MeController> {
void profile(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void listRedeemItems(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void listRedeemRecords(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void createRedeemRecord(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void listDailyTasks(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void listWrongBook(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);

查看文件

@@ -0,0 +1,40 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <string>
#include <vector>
namespace csp::services {
struct DailyTaskItem {
std::string code;
std::string title;
std::string description;
int reward = 1;
bool completed = false;
int64_t completed_at = 0;
};
class DailyTaskService {
public:
static constexpr const char* kTaskLoginCheckin = "login_checkin";
static constexpr const char* kTaskDailySubmit = "daily_submit";
static constexpr const char* kTaskFirstAc = "first_ac";
static constexpr const char* kTaskCodeQuality = "code_quality";
explicit DailyTaskService(db::SqliteDb& db) : db_(db) {}
std::string CurrentDayKey() const;
std::vector<DailyTaskItem> ListTodayTasks(int64_t user_id) const;
// Returns true when the task is completed for the first time today
// (and reward is granted), false when already completed.
bool CompleteTaskIfFirstToday(int64_t user_id, const std::string& task_code);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -2,12 +2,16 @@
#include "csp/app_state.h"
#include "csp/domain/json.h"
#include "csp/services/daily_task_service.h"
#include "csp/services/redeem_service.h"
#include "csp/services/user_service.h"
#include "csp/services/wrong_book_service.h"
#include "http_auth.h"
#include <algorithm>
#include <exception>
#include <optional>
#include <stdexcept>
#include <string>
namespace csp::controllers {
@@ -44,6 +48,15 @@ std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req,
return user_id;
}
int ParseClampedInt(const std::string& s,
int default_value,
int min_value,
int max_value) {
if (s.empty()) return default_value;
const int value = std::stoi(s);
return std::max(min_value, std::min(max_value, value));
}
} // namespace
void MeController::profile(
@@ -66,6 +79,161 @@ void MeController::profile(
}
}
void MeController::listRedeemItems(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAuth(req, cb).has_value()) return;
services::RedeemService redeem(csp::AppState::Instance().db());
const auto items = redeem.ListItems(false);
Json::Value arr(Json::arrayValue);
for (const auto& item : items) {
Json::Value j;
j["id"] = Json::Int64(item.id);
j["name"] = item.name;
j["description"] = item.description;
j["unit_label"] = item.unit_label;
j["holiday_cost"] = item.holiday_cost;
j["studyday_cost"] = item.studyday_cost;
j["is_active"] = item.is_active;
j["is_global"] = item.is_global;
j["created_at"] = Json::Int64(item.created_at);
j["updated_at"] = Json::Int64(item.updated_at);
arr.append(j);
}
cb(JsonOk(arr));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MeController::listRedeemRecords(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return;
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
services::RedeemService redeem(csp::AppState::Instance().db());
const auto rows = redeem.ListRecordsByUser(*user_id, limit);
Json::Value arr(Json::arrayValue);
for (const auto& row : rows) {
Json::Value j;
j["id"] = Json::Int64(row.id);
j["user_id"] = Json::Int64(row.user_id);
j["item_id"] = Json::Int64(row.item_id);
j["item_name"] = row.item_name;
j["quantity"] = row.quantity;
j["day_type"] = row.day_type;
j["unit_cost"] = row.unit_cost;
j["total_cost"] = row.total_cost;
j["note"] = row.note;
j["created_at"] = Json::Int64(row.created_at);
arr.append(j);
}
cb(JsonOk(arr));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
} catch (const std::out_of_range&) {
cb(JsonError(drogon::k400BadRequest, "query parameter out of range"));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MeController::createRedeemRecord(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return;
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
services::RedeemRequest request;
request.user_id = *user_id;
request.item_id = (*json).get("item_id", 0).asInt64();
request.quantity = (*json).get("quantity", 1).asInt();
request.day_type = (*json).get("day_type", "studyday").asString();
request.note = (*json).get("note", "").asString();
services::RedeemService redeem(csp::AppState::Instance().db());
const auto row = redeem.Redeem(request);
services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id);
Json::Value j;
j["id"] = Json::Int64(row.id);
j["user_id"] = Json::Int64(row.user_id);
j["item_id"] = Json::Int64(row.item_id);
j["item_name"] = row.item_name;
j["quantity"] = row.quantity;
j["day_type"] = row.day_type;
j["unit_cost"] = row.unit_cost;
j["total_cost"] = row.total_cost;
j["note"] = row.note;
j["created_at"] = Json::Int64(row.created_at);
if (user.has_value()) {
j["rating_after"] = user->rating;
}
cb(JsonOk(j));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MeController::listDailyTasks(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto user_id = RequireAuth(req, cb);
if (!user_id.has_value()) return;
services::DailyTaskService tasks(csp::AppState::Instance().db());
const auto rows = tasks.ListTodayTasks(*user_id);
Json::Value arr(Json::arrayValue);
int total_reward = 0;
int gained_reward = 0;
for (const auto& row : rows) {
Json::Value j;
j["code"] = row.code;
j["title"] = row.title;
j["description"] = row.description;
j["reward"] = row.reward;
j["completed"] = row.completed;
if (row.completed) {
j["completed_at"] = Json::Int64(row.completed_at);
gained_reward += row.reward;
} else {
j["completed_at"] = Json::nullValue;
}
total_reward += row.reward;
arr.append(j);
}
Json::Value out;
out["day_key"] = tasks.CurrentDayKey();
out["total_reward"] = total_reward;
out["gained_reward"] = gained_reward;
out["tasks"] = arr;
cb(JsonOk(out));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MeController::listWrongBook(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {

查看文件

@@ -3,9 +3,13 @@
#include "csp/app_state.h"
#include "csp/domain/enum_strings.h"
#include "csp/domain/json.h"
#include "csp/services/kb_import_runner.h"
#include "csp/services/problem_gen_runner.h"
#include "csp/services/problem_service.h"
#include "csp/services/problem_solution_runner.h"
#include "csp/services/problem_workspace_service.h"
#include "csp/services/submission_service.h"
#include "http_auth.h"
#include <algorithm>
#include <exception>
@@ -25,6 +29,24 @@ drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
return resp;
}
drogon::HttpResponsePtr JsonOk(const Json::Value& data) {
Json::Value j;
j["ok"] = true;
j["data"] = data;
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
resp->setStatusCode(drogon::k200OK);
return resp;
}
int ParsePositiveInt(const std::string& s,
int default_value,
int min_value,
int max_value) {
if (s.empty()) return default_value;
const int v = std::stoi(s);
return std::max(min_value, std::min(max_value, v));
}
Json::Value BuildOpenApiSpec() {
Json::Value root;
root["openapi"] = "3.1.0";
@@ -54,11 +76,28 @@ Json::Value BuildOpenApiSpec() {
paths["/api/v1/run/cpp"]["post"]["summary"] = "C++ 试运行";
paths["/api/v1/submissions"]["get"]["summary"] = "提交记录";
paths["/api/v1/submissions/{id}"]["get"]["summary"] = "提交详情";
paths["/api/v1/submissions/{id}/analysis"]["post"]["summary"] = "提交评测建议LLM";
paths["/api/v1/admin/users"]["get"]["summary"] = "管理员用户列表";
paths["/api/v1/admin/users/{id}/rating"]["patch"]["summary"] = "管理员修改用户积分";
paths["/api/v1/admin/redeem-items"]["get"]["summary"] = "管理员查看积分兑换物品";
paths["/api/v1/admin/redeem-items"]["post"]["summary"] = "管理员新增积分兑换物品";
paths["/api/v1/admin/redeem-items/{id}"]["patch"]["summary"] = "管理员修改积分兑换物品";
paths["/api/v1/admin/redeem-items/{id}"]["delete"]["summary"] = "管理员下架积分兑换物品";
paths["/api/v1/admin/redeem-records"]["get"]["summary"] = "管理员查看兑换记录";
paths["/api/v1/me/redeem/items"]["get"]["summary"] = "我的可兑换物品列表";
paths["/api/v1/me/redeem/records"]["get"]["summary"] = "我的兑换记录";
paths["/api/v1/me/redeem/records"]["post"]["summary"] = "创建兑换记录";
paths["/api/v1/me/daily-tasks"]["get"]["summary"] = "我的每日任务列表";
paths["/api/v1/import/jobs/latest"]["get"]["summary"] = "最新导入任务";
paths["/api/v1/import/jobs/run"]["post"]["summary"] = "触发导入任务";
paths["/api/v1/problem-gen/status"]["get"]["summary"] = "CSP-J 生成任务状态";
paths["/api/v1/problem-gen/run"]["post"]["summary"] = "触发生成新题RAG+去重)";
paths["/api/v1/backend/logs"]["get"]["summary"] = "后台日志(题解任务队列)";
paths["/api/v1/backend/kb/refresh"]["get"]["summary"] = "知识库资料更新状态";
paths["/api/v1/backend/kb/refresh"]["post"]["summary"] = "手动一键更新知识库资料";
paths["/api/v1/backend/solutions/generate-missing"]["post"]["summary"] =
"异步补全所有缺失题解";
paths["/api/v1/mcp"]["post"]["summary"] = "MCP JSON-RPC 入口";
@@ -130,6 +169,205 @@ void MetaController::openapi(
cb(resp);
}
void MetaController::backendLogs(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const int limit =
ParsePositiveInt(req->getParameter("limit"), 100, 1, 500);
const int running_limit =
ParsePositiveInt(req->getParameter("running_limit"), 20, 1, 200);
const int queued_limit =
ParsePositiveInt(req->getParameter("queued_limit"), 100, 1, 1000);
services::ProblemWorkspaceService workspace(csp::AppState::Instance().db());
const auto jobs = workspace.ListRecentSolutionJobs(limit);
const auto running_jobs = workspace.ListSolutionJobsByStatus(
"running", running_limit);
const auto queued_jobs = workspace.ListSolutionJobsByStatus(
"queued", queued_limit);
auto& runner = services::ProblemSolutionRunner::Instance();
Json::Value items(Json::arrayValue);
for (const auto& job : jobs) {
Json::Value j;
j["id"] = Json::Int64(job.id);
j["problem_id"] = Json::Int64(job.problem_id);
j["problem_title"] = job.problem_title;
j["status"] = job.status;
j["progress"] = job.progress;
j["message"] = job.message;
j["created_by"] = Json::Int64(job.created_by);
j["max_solutions"] = job.max_solutions;
j["created_at"] = Json::Int64(job.created_at);
if (job.started_at.has_value()) {
j["started_at"] = Json::Int64(*job.started_at);
} else {
j["started_at"] = Json::nullValue;
}
if (job.finished_at.has_value()) {
j["finished_at"] = Json::Int64(*job.finished_at);
} else {
j["finished_at"] = Json::nullValue;
}
j["updated_at"] = Json::Int64(job.updated_at);
j["runner_pending"] = runner.IsRunning(job.problem_id);
items.append(j);
}
Json::Value running_items(Json::arrayValue);
Json::Value running_problem_ids(Json::arrayValue);
for (const auto& job : running_jobs) {
Json::Value j;
j["id"] = Json::Int64(job.id);
j["problem_id"] = Json::Int64(job.problem_id);
j["problem_title"] = job.problem_title;
j["status"] = job.status;
j["progress"] = job.progress;
j["message"] = job.message;
j["updated_at"] = Json::Int64(job.updated_at);
if (job.started_at.has_value()) {
j["started_at"] = Json::Int64(*job.started_at);
} else {
j["started_at"] = Json::nullValue;
}
running_items.append(j);
running_problem_ids.append(Json::Int64(job.problem_id));
}
Json::Value queued_items(Json::arrayValue);
Json::Value queued_problem_ids(Json::arrayValue);
for (const auto& job : queued_jobs) {
Json::Value j;
j["id"] = Json::Int64(job.id);
j["problem_id"] = Json::Int64(job.problem_id);
j["problem_title"] = job.problem_title;
j["status"] = job.status;
j["progress"] = job.progress;
j["message"] = job.message;
j["updated_at"] = Json::Int64(job.updated_at);
queued_items.append(j);
queued_problem_ids.append(Json::Int64(job.problem_id));
}
Json::Value payload;
payload["items"] = items;
payload["running_jobs"] = running_items;
payload["queued_jobs"] = queued_items;
payload["running_problem_ids"] = running_problem_ids;
payload["queued_problem_ids"] = queued_problem_ids;
payload["running_count"] = static_cast<int>(running_jobs.size());
payload["queued_count_preview"] = static_cast<int>(queued_jobs.size());
payload["pending_jobs"] = Json::UInt64(runner.PendingCount());
payload["missing_problems"] = workspace.CountProblemsWithoutSolutions();
payload["limit"] = limit;
payload["running_limit"] = running_limit;
payload["queued_limit"] = queued_limit;
cb(JsonOk(payload));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
} catch (const std::out_of_range&) {
cb(JsonError(drogon::k400BadRequest, "query parameter out of range"));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MetaController::kbRefreshStatus(
const drogon::HttpRequestPtr& /*req*/,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto& runner = services::KbImportRunner::Instance();
Json::Value payload;
payload["running"] = runner.IsRunning();
payload["last_command"] = runner.LastCommand();
payload["last_trigger"] = runner.LastTrigger();
if (const auto rc = runner.LastExitCode(); rc.has_value()) {
payload["last_exit_code"] = *rc;
} else {
payload["last_exit_code"] = Json::nullValue;
}
payload["last_started_at"] = Json::Int64(runner.LastStartedAt());
payload["last_finished_at"] = Json::Int64(runner.LastFinishedAt());
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MetaController::triggerKbRefresh(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
auto& runner = services::KbImportRunner::Instance();
const bool started = runner.TriggerAsync("manual");
Json::Value payload;
payload["started"] = started;
payload["message"] = started ? "已触发异步资料更新" : "当前已有资料更新任务在运行中";
payload["running"] = runner.IsRunning();
payload["last_command"] = runner.LastCommand();
payload["last_trigger"] = runner.LastTrigger();
if (const auto rc = runner.LastExitCode(); rc.has_value()) {
payload["last_exit_code"] = *rc;
} else {
payload["last_exit_code"] = Json::nullValue;
}
payload["last_started_at"] = Json::Int64(runner.LastStartedAt());
payload["last_finished_at"] = Json::Int64(runner.LastFinishedAt());
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MetaController::triggerMissingSolutions(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
int limit = 50000;
int max_solutions = 3;
const auto json = req->getJsonObject();
if (json && (*json).isMember("limit")) {
limit = std::max(1, std::min(200000, (*json)["limit"].asInt()));
}
if (json && (*json).isMember("max_solutions")) {
max_solutions = std::max(1, std::min(5, (*json)["max_solutions"].asInt()));
}
auto& db = csp::AppState::Instance().db();
auto& runner = services::ProblemSolutionRunner::Instance();
const auto summary = runner.TriggerMissingAsync(
db, *user_id, max_solutions, limit);
Json::Value payload;
payload["started"] = true;
payload["missing_total"] = summary.missing_total;
payload["candidate_count"] = summary.candidate_count;
payload["queued_count"] = summary.queued_count;
payload["pending_jobs"] = Json::UInt64(runner.PendingCount());
payload["limit"] = limit;
payload["max_solutions"] = max_solutions;
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void MetaController::mcp(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {

查看文件

@@ -208,6 +208,47 @@ void InsertContestProblem(sqlite3* db,
sqlite3_finalize(stmt);
}
void InsertRedeemItem(sqlite3* db,
const std::string& name,
const std::string& description,
const std::string& unit_label,
int holiday_cost,
int studyday_cost,
int is_active,
int is_global,
int64_t created_by,
int64_t created_at) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO redeem_items("
"name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at"
") VALUES(?,?,?,?,?,?,?,?,?,?)";
ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert redeem_item");
ThrowSqlite(sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT), db,
"bind redeem_item.name");
ThrowSqlite(sqlite3_bind_text(stmt, 2, description.c_str(), -1, SQLITE_TRANSIENT),
db, "bind redeem_item.description");
ThrowSqlite(sqlite3_bind_text(stmt, 3, unit_label.c_str(), -1, SQLITE_TRANSIENT),
db, "bind redeem_item.unit_label");
ThrowSqlite(sqlite3_bind_int(stmt, 4, holiday_cost), db,
"bind redeem_item.holiday_cost");
ThrowSqlite(sqlite3_bind_int(stmt, 5, studyday_cost), db,
"bind redeem_item.studyday_cost");
ThrowSqlite(sqlite3_bind_int(stmt, 6, is_active), db,
"bind redeem_item.is_active");
ThrowSqlite(sqlite3_bind_int(stmt, 7, is_global), db,
"bind redeem_item.is_global");
ThrowSqlite(sqlite3_bind_int64(stmt, 8, created_by), db,
"bind redeem_item.created_by");
ThrowSqlite(sqlite3_bind_int64(stmt, 9, created_at), db,
"bind redeem_item.created_at");
ThrowSqlite(sqlite3_bind_int64(stmt, 10, created_at), db,
"bind redeem_item.updated_at");
ThrowSqlite(sqlite3_step(stmt), db, "insert redeem_item");
sqlite3_finalize(stmt);
}
} // namespace
SqliteDb SqliteDb::OpenFile(const std::string& path) {
@@ -446,6 +487,71 @@ CREATE TABLE IF NOT EXISTS problem_solutions (
updated_at INTEGER NOT NULL,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS problem_solution_view_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
problem_id INTEGER NOT NULL,
day_key TEXT NOT NULL,
viewed_at INTEGER NOT NULL,
charged INTEGER NOT NULL DEFAULT 0,
cost INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS submission_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
submission_id INTEGER NOT NULL UNIQUE,
feedback_md TEXT NOT NULL DEFAULT "",
links_json TEXT NOT NULL DEFAULT "[]",
model_name TEXT NOT NULL DEFAULT "",
status TEXT NOT NULL DEFAULT "ready",
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(submission_id) REFERENCES submissions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS redeem_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT "",
unit_label TEXT NOT NULL DEFAULT "小时",
holiday_cost INTEGER NOT NULL DEFAULT 5,
studyday_cost INTEGER NOT NULL DEFAULT 25,
is_active INTEGER NOT NULL DEFAULT 1,
is_global INTEGER NOT NULL DEFAULT 1,
created_by INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS redeem_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
item_name TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
day_type TEXT NOT NULL DEFAULT "studyday",
unit_cost INTEGER NOT NULL DEFAULT 0,
total_cost INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT "",
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(item_id) REFERENCES redeem_items(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS daily_task_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
task_code TEXT NOT NULL,
day_key TEXT NOT NULL,
reward INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, task_code, day_key)
);
)SQL");
// Backward-compatible schema upgrades for existing deployments.
@@ -494,6 +600,11 @@ CREATE INDEX IF NOT EXISTS idx_import_job_items_job_status ON import_job_items(j
CREATE INDEX IF NOT EXISTS idx_problem_drafts_updated ON problem_drafts(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_problem_solution_jobs_problem ON problem_solution_jobs(problem_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_problem_solutions_problem ON problem_solutions(problem_id, variant, id);
CREATE INDEX IF NOT EXISTS idx_solution_view_logs_user_problem ON problem_solution_view_logs(user_id, problem_id, viewed_at DESC);
CREATE INDEX IF NOT EXISTS idx_solution_view_logs_user_day ON problem_solution_view_logs(user_id, day_key, viewed_at DESC);
CREATE INDEX IF NOT EXISTS idx_redeem_items_active ON redeem_items(is_active, id);
CREATE INDEX IF NOT EXISTS idx_redeem_records_user_created ON redeem_records(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_daily_task_logs_user_day ON daily_task_logs(user_id, day_key, created_at DESC);
)SQL");
}
@@ -602,6 +713,20 @@ void SeedDemoData(SqliteDb& db) {
if (contest_id && p1) InsertContestProblem(raw, *contest_id, *p1, 1);
if (contest_id && p2) InsertContestProblem(raw, *contest_id, *p2, 2);
}
if (CountRows(raw, "redeem_items") == 0) {
InsertRedeemItem(
raw,
"私人玩游戏时间",
"全局用户可兑换:假期 1 小时 5 Rating;学习日/非节假日 1 小时 25 Rating。",
"小时",
5,
25,
1,
1,
0,
now);
}
}
} // namespace csp::db

查看文件

@@ -1,5 +1,6 @@
#include "csp/services/auth_service.h"
#include "csp/services/daily_task_service.h"
#include "csp/services/crypto.h"
#include <sqlite3.h>
@@ -113,9 +114,44 @@ AuthResult AuthService::Login(const std::string& username,
CheckSqlite(sqlite3_step(ins), db, "insert session");
sqlite3_finalize(ins);
try {
DailyTaskService daily(db_);
daily.CompleteTaskIfFirstToday(user_id, DailyTaskService::kTaskLoginCheckin);
} catch (...) {
// Login should not fail because of optional daily-task reward.
}
return AuthResult{.user_id = user_id, .token = token, .expires_at = expires};
}
void AuthService::ResetPassword(const std::string& username,
const std::string& new_password) {
if (username.empty() || new_password.size() < 6) {
throw std::runtime_error("invalid username or password");
}
const auto salt = crypto::RandomHex(16);
const auto hash = crypto::Sha256Hex(salt + ":" + new_password);
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"UPDATE users SET password_salt=?,password_hash=? WHERE username=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare reset password");
CheckSqlite(sqlite3_bind_text(stmt, 1, salt.c_str(), -1, SQLITE_TRANSIENT), db,
"bind salt");
CheckSqlite(sqlite3_bind_text(stmt, 2, hash.c_str(), -1, SQLITE_TRANSIENT), db,
"bind hash");
CheckSqlite(sqlite3_bind_text(stmt, 3, username.c_str(), -1, SQLITE_TRANSIENT),
db, "bind username");
CheckSqlite(sqlite3_step(stmt), db, "exec reset password");
sqlite3_finalize(stmt);
if (sqlite3_changes(db) <= 0) {
throw std::runtime_error("user not found");
}
}
std::optional<int> AuthService::VerifyToken(const std::string& token) {
if (token.empty()) return std::nullopt;

查看文件

@@ -0,0 +1,170 @@
#include "csp/services/daily_task_service.h"
#include <sqlite3.h>
#include <array>
#include <chrono>
#include <ctime>
#include <iomanip>
#include <sstream>
#include <stdexcept>
#include <string>
#include <unordered_map>
namespace csp::services {
namespace {
struct DailyTaskDef {
const char* code;
const char* title;
const char* description;
int reward;
};
constexpr std::array<DailyTaskDef, 4> kTaskDefs = {{
{DailyTaskService::kTaskLoginCheckin, "登录签到", "登录签到 1 分(本日首次可得)", 1},
{DailyTaskService::kTaskDailySubmit, "每日提交", "每日提交 1 分(本日首次可得)", 1},
{DailyTaskService::kTaskFirstAc, "正确一题", "正确一题 1 分(本日首次可得)", 1},
{DailyTaskService::kTaskCodeQuality, "代码达标", "代码超过 10 行 1 分(本日首次可得)", 1},
}};
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
void CheckSqlite(int rc, sqlite3* db, const char* what) {
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
}
const DailyTaskDef* FindTask(const std::string& code) {
for (const auto& def : kTaskDefs) {
if (code == def.code) return &def;
}
return nullptr;
}
std::string BuildDayKey(int64_t ts_sec) {
// Use UTC+8 day boundary for CSP users.
const std::time_t shifted = static_cast<std::time_t>(ts_sec + 8 * 3600);
std::tm tmv{};
gmtime_r(&shifted, &tmv);
std::ostringstream out;
out << std::setw(4) << std::setfill('0') << (tmv.tm_year + 1900) << '-'
<< std::setw(2) << std::setfill('0') << (tmv.tm_mon + 1) << '-'
<< std::setw(2) << std::setfill('0') << tmv.tm_mday;
return out.str();
}
void AddRating(sqlite3* db, int64_t user_id, int delta) {
sqlite3_stmt* stmt = nullptr;
const char* sql = "UPDATE users SET rating=rating+? WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare add daily task rating");
CheckSqlite(sqlite3_bind_int(stmt, 1, delta), db, "bind rating delta");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
CheckSqlite(sqlite3_step(stmt), db, "exec add daily task rating");
sqlite3_finalize(stmt);
if (sqlite3_changes(db) <= 0) throw std::runtime_error("user not found");
}
} // namespace
std::string DailyTaskService::CurrentDayKey() const {
return BuildDayKey(NowSec());
}
std::vector<DailyTaskItem> DailyTaskService::ListTodayTasks(int64_t user_id) const {
if (user_id <= 0) throw std::runtime_error("invalid user_id");
sqlite3* db = db_.raw();
const std::string day_key = CurrentDayKey();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT task_code,created_at,reward FROM daily_task_logs WHERE user_id=? AND day_key=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare list daily tasks");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
"bind day_key");
std::unordered_map<std::string, std::pair<int64_t, int>> done;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const unsigned char* raw = sqlite3_column_text(stmt, 0);
std::string code = raw ? reinterpret_cast<const char*>(raw) : std::string();
const int64_t created_at = sqlite3_column_int64(stmt, 1);
const int reward = sqlite3_column_int(stmt, 2);
done[code] = {created_at, reward};
}
sqlite3_finalize(stmt);
std::vector<DailyTaskItem> out;
out.reserve(kTaskDefs.size());
for (const auto& def : kTaskDefs) {
DailyTaskItem item;
item.code = def.code;
item.title = def.title;
item.description = def.description;
item.reward = def.reward;
const auto it = done.find(item.code);
if (it != done.end()) {
item.completed = true;
item.completed_at = it->second.first;
item.reward = it->second.second > 0 ? it->second.second : def.reward;
}
out.push_back(std::move(item));
}
return out;
}
bool DailyTaskService::CompleteTaskIfFirstToday(int64_t user_id,
const std::string& task_code) {
if (user_id <= 0) throw std::runtime_error("invalid user_id");
const auto* task = FindTask(task_code);
if (!task) throw std::runtime_error("unknown daily task");
sqlite3* db = db_.raw();
const int64_t now = NowSec();
const std::string day_key = BuildDayKey(now);
db_.Exec("BEGIN IMMEDIATE");
bool committed = false;
try {
sqlite3_stmt* stmt = nullptr;
const char* ins_sql =
"INSERT OR IGNORE INTO daily_task_logs(user_id,task_code,day_key,reward,created_at) "
"VALUES(?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &stmt, nullptr), db,
"prepare insert daily task");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, task->code, -1, SQLITE_STATIC), db,
"bind task_code");
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
"bind day_key");
CheckSqlite(sqlite3_bind_int(stmt, 4, task->reward), db, "bind reward");
CheckSqlite(sqlite3_bind_int64(stmt, 5, now), db, "bind created_at");
CheckSqlite(sqlite3_step(stmt), db, "insert daily task");
sqlite3_finalize(stmt);
const bool inserted = sqlite3_changes(db) > 0;
if (inserted) {
AddRating(db, user_id, task->reward);
}
db_.Exec("COMMIT");
committed = true;
return inserted;
} catch (...) {
if (!committed) {
try {
db_.Exec("ROLLBACK");
} catch (...) {
}
}
throw;
}
}
} // namespace csp::services

查看文件

@@ -2,6 +2,7 @@
#include "csp/domain/enum_strings.h"
#include "csp/services/crypto.h"
#include "csp/services/daily_task_service.h"
#include "csp/services/wrong_book_service.h"
#include <sqlite3.h>
@@ -97,7 +98,8 @@ JudgeOutcome JudgeCpp(const std::string& code,
JudgeOutcome outcome;
try {
const std::string compile_cmd =
"g++ -std=c++20 -O2 \"" + src.string() + "\" -o \"" + bin.string() +
"g++ -std=gnu++14 -O2 -Wall -Wextra -Wpedantic \"" + src.string() +
"\" -o \"" + bin.string() +
"\" 2> \"" + compile_log.string() + "\"";
const int compile_rc = std::system(compile_cmd.c_str());
outcome.compile_log = ReadFile(compile_log);
@@ -210,6 +212,15 @@ void AddRating(sqlite3* db, int64_t user_id, int delta) {
std::string ToStatusText(domain::SubmissionStatus s) { return domain::ToString(s); }
int CountCodeLines(const std::string& code) {
if (code.empty()) return 0;
int lines = 1;
for (const char ch : code) {
if (ch == '\n') ++lines;
}
return lines;
}
} // namespace
domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateRequest& req) {
@@ -264,6 +275,16 @@ domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateReque
const int64_t submission_id = sqlite3_last_insert_rowid(db);
try {
DailyTaskService daily(db_);
daily.CompleteTaskIfFirstToday(req.user_id, DailyTaskService::kTaskDailySubmit);
if (CountCodeLines(req.code) > 10) {
daily.CompleteTaskIfFirstToday(req.user_id, DailyTaskService::kTaskCodeQuality);
}
} catch (...) {
// Daily task reward should not interrupt judging.
}
JudgeOutcome outcome = JudgeCpp(req.code, problem->sample_input, problem->sample_output);
const int score = outcome.status == domain::SubmissionStatus::AC ? 100 : 0;
@@ -288,9 +309,16 @@ domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateReque
WrongBookService wb(db_);
if (outcome.status == domain::SubmissionStatus::AC) {
try {
DailyTaskService daily(db_);
daily.CompleteTaskIfFirstToday(req.user_id, DailyTaskService::kTaskFirstAc);
} catch (...) {
// Keep AC flow resilient.
}
wb.Remove(req.user_id, req.problem_id);
if (!HasSolvedBefore(db, req.user_id, req.problem_id, submission_id)) {
AddRating(db, req.user_id, problem->difficulty * 10);
AddRating(db, req.user_id, 2);
}
} else {
wb.UpsertBySubmission(req.user_id, req.problem_id, submission_id,

查看文件

@@ -0,0 +1,34 @@
#include <catch2/catch_test_macros.hpp>
#include "csp/db/sqlite_db.h"
#include "csp/services/auth_service.h"
#include "csp/services/daily_task_service.h"
#include "csp/services/user_service.h"
TEST_CASE("daily task reward only once per day") {
auto db = csp::db::SqliteDb::OpenMemory();
csp::db::ApplyMigrations(db);
csp::services::AuthService auth(db);
const auto user = auth.Register("daily_task_user", "password123");
csp::services::UserService users(db);
csp::services::DailyTaskService daily(db);
const auto before = users.GetById(user.user_id);
REQUIRE(before.has_value());
// Register includes auto-login, which should complete login_checkin once.
REQUIRE(before->rating == 1);
REQUIRE(daily.CompleteTaskIfFirstToday(user.user_id,
csp::services::DailyTaskService::kTaskDailySubmit));
REQUIRE_FALSE(daily.CompleteTaskIfFirstToday(
user.user_id, csp::services::DailyTaskService::kTaskDailySubmit));
const auto after = users.GetById(user.user_id);
REQUIRE(after.has_value());
REQUIRE(after->rating == 2);
const auto tasks = daily.ListTodayTasks(user.user_id);
REQUIRE(tasks.size() == 4);
}

查看文件

@@ -14,7 +14,8 @@ const nextConfig: NextConfig = {
return [
{
source: "/admin139/:path*",
// Keep /admin139 as frontend admin entry page, only proxy nested API paths.
source: "/admin139/:path+",
destination: `${backendInternal}/:path*`,
},
];

查看文件

@@ -0,0 +1,28 @@
import Link from "next/link";
export default function AdminEntryPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-3 text-sm text-zinc-600">
<span className="font-medium text-zinc-900">admin</span>
<span className="font-medium text-zinc-900">whoami139</span>
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/auth">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/admin-users">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/admin-redeem">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/backend-logs">
</Link>
</div>
</main>
);
}

查看文件

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
@@ -12,43 +12,322 @@ type Me = {
created_at: number;
};
export default function MePage() {
const [data, setData] = useState<Me | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
type RedeemItem = {
id: number;
name: string;
description: string;
unit_label: string;
holiday_cost: number;
studyday_cost: number;
is_active: boolean;
};
useEffect(() => {
const load = async () => {
type RedeemRecord = {
id: number;
user_id: number;
item_id: number;
item_name: string;
quantity: number;
day_type: string;
unit_cost: number;
total_cost: number;
note: string;
created_at: number;
};
type RedeemCreateResp = RedeemRecord & {
rating_after?: number;
};
type DailyTaskItem = {
code: string;
title: string;
description: string;
reward: number;
completed: boolean;
completed_at?: number | null;
};
type DailyTaskPayload = {
day_key: string;
total_reward: number;
gained_reward: number;
tasks: DailyTaskItem[];
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function MePage() {
const [token, setToken] = useState("");
const [profile, setProfile] = useState<Me | null>(null);
const [items, setItems] = useState<RedeemItem[]>([]);
const [records, setRecords] = useState<RedeemRecord[]>([]);
const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]);
const [dailyDayKey, setDailyDayKey] = useState("");
const [dailyTotalReward, setDailyTotalReward] = useState(0);
const [dailyGainedReward, setDailyGainedReward] = useState(0);
const [selectedItemId, setSelectedItemId] = useState<number>(0);
const [quantity, setQuantity] = useState(1);
const [dayType, setDayType] = useState<"holiday" | "studyday">("holiday");
const [note, setNote] = useState("");
const [loading, setLoading] = useState(false);
const [redeemLoading, setRedeemLoading] = useState(false);
const [error, setError] = useState("");
const [msg, setMsg] = useState("");
const selectedItem = useMemo(
() => items.find((item) => item.id === selectedItemId) ?? null,
[items, selectedItemId]
);
const unitCost = useMemo(() => {
if (!selectedItem) return 0;
return dayType === "holiday" ? selectedItem.holiday_cost : selectedItem.studyday_cost;
}, [dayType, selectedItem]);
const totalCost = useMemo(() => Math.max(0, unitCost * Math.max(1, quantity)), [quantity, unitCost]);
const loadAll = async () => {
setLoading(true);
setError("");
setMsg("");
try {
const token = readToken();
if (!token) throw new Error("请先登录");
const d = await apiFetch<Me>("/api/v1/me", {}, token);
setData(d);
const tk = readToken();
setToken(tk);
if (!tk) throw new Error("请先登录");
const [me, redeemItems, redeemRecords, daily] = await Promise.all([
apiFetch<Me>("/api/v1/me", {}, tk),
apiFetch<RedeemItem[]>("/api/v1/me/redeem/items", {}, tk),
apiFetch<RedeemRecord[]>("/api/v1/me/redeem/records?limit=200", {}, tk),
apiFetch<DailyTaskPayload>("/api/v1/me/daily-tasks", {}, tk),
]);
setProfile(me);
setItems(redeemItems ?? []);
setRecords(redeemRecords ?? []);
setDailyTasks(daily?.tasks ?? []);
setDailyDayKey(daily?.day_key ?? "");
setDailyTotalReward(daily?.total_reward ?? 0);
setDailyGainedReward(daily?.gained_reward ?? 0);
if ((redeemItems ?? []).length > 0) {
setSelectedItemId((prev) => prev || redeemItems[0].id);
}
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
void load();
useEffect(() => {
void loadAll();
}, []);
const redeem = async () => {
setRedeemLoading(true);
setError("");
setMsg("");
try {
if (!token) throw new Error("请先登录");
if (!selectedItemId) throw new Error("请选择兑换物品");
if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("兑换数量必须大于 0");
const created = await apiFetch<RedeemCreateResp>(
"/api/v1/me/redeem/records",
{
method: "POST",
body: JSON.stringify({
item_id: selectedItemId,
quantity,
day_type: dayType,
note,
}),
},
token
);
setMsg(
`兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : ""
}`
);
setNote("");
await loadAll();
} catch (e: unknown) {
setError(String(e));
} finally {
setRedeemLoading(false);
}
};
return (
<main className="mx-auto max-w-3xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
{data && (
<div className="mt-4 rounded-xl border bg-white p-4 text-sm">
<p>ID: {data.id}</p>
<p>: {data.username}</p>
<p>Rating: {data.rating}</p>
<p>: {new Date(data.created_at * 1000).toLocaleString()}</p>
</div>
{profile && (
<section className="mt-4 rounded-xl border bg-white p-4 text-sm">
<p>ID: {profile.id}</p>
<p>: {profile.username}</p>
<p>Rating: {profile.rating}</p>
<p>: {fmtTs(profile.created_at)}</p>
</section>
)}
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold"></h2>
<p className="text-xs text-zinc-600">
{dailyDayKey ? `${dailyDayKey} · ` : ""} {dailyGainedReward}/{dailyTotalReward}
</p>
</div>
<div className="mt-3 divide-y">
{dailyTasks.map((task) => (
<article key={task.code} className="py-2 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
{task.title} · +{task.reward}
</p>
<span
className={`rounded px-2 py-0.5 text-xs ${
task.completed ? "bg-emerald-100 text-emerald-700" : "bg-zinc-100 text-zinc-600"
}`}
>
{task.completed ? "已完成" : "未完成"}
</span>
</div>
<p className="mt-1 text-xs text-zinc-600">{task.description}</p>
{task.completed && (
<p className="mt-1 text-xs text-zinc-500">{fmtTs(task.completed_at)}</p>
)}
</article>
))}
{!loading && dailyTasks.length === 0 && (
<p className="py-3 text-sm text-zinc-500"></p>
)}
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-base font-semibold"></h2>
<p className="mt-1 text-xs text-zinc-600">
1 =5 / 1 =25
</p>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{items.map((item) => (
<article key={item.id} className="rounded border bg-zinc-50 p-3 text-sm">
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{item.name}</p>
<button
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100"
onClick={() => setSelectedItemId(item.id)}
>
</button>
</div>
<p className="mt-1 text-xs text-zinc-600">{item.description || "-"}</p>
<p className="mt-1 text-xs text-zinc-700">{item.holiday_cost} / {item.unit_label}</p>
<p className="text-xs text-zinc-700">{item.studyday_cost} / {item.unit_label}</p>
</article>
))}
{!loading && items.length === 0 && (
<p className="text-sm text-zinc-500"></p>
)}
</div>
<div className="mt-4 rounded-lg border p-3">
<h3 className="text-sm font-medium"></h3>
<div className="mt-2 grid gap-2 md:grid-cols-2">
<select
className="rounded border px-3 py-2 text-sm"
value={selectedItemId}
onChange={(e) => setSelectedItemId(Number(e.target.value))}
>
<option value={0}></option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<select
className="rounded border px-3 py-2 text-sm"
value={dayType}
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
>
<option value="holiday"></option>
<option value="studyday">/</option>
</select>
<input
className="rounded border px-3 py-2 text-sm"
type="number"
min={1}
max={24}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
placeholder="兑换时长(小时)"
/>
<input
className="rounded border px-3 py-2 text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="备注(可选)"
/>
</div>
<p className="mt-2 text-xs text-zinc-600">
{unitCost} / {totalCost}
</p>
<button
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
onClick={() => void redeem()}
disabled={redeemLoading || !selectedItemId}
>
{redeemLoading ? "兑换中..." : "确认兑换"}
</button>
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-semibold"></h2>
<button
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100"
onClick={() => void loadAll()}
disabled={loading}
>
</button>
</div>
<div className="mt-3 divide-y">
{records.map((row) => (
<article key={row.id} className="py-2 text-sm">
<p>
#{row.id} · {row.item_name} · {row.quantity} · {row.day_type === "holiday" ? "假期" : "学习日"}
</p>
<p className="text-xs text-zinc-600">
{row.unit_cost} {row.total_cost} · {fmtTs(row.created_at)}
</p>
{row.note && <p className="text-xs text-zinc-500">{row.note}</p>}
</article>
))}
{!loading && records.length === 0 && <p className="py-3 text-sm text-zinc-500"></p>}
</div>
</section>
</main>
);
}