feat: redeem items support optional duration (permanent/timed)

- Add duration_minutes column to redeem_items (0 = permanent)
- Backend: update RedeemItem/RedeemItemWrite structs, all CRUD SQL
- Backend: EnsureColumn migration for existing databases
- Frontend: add duration selector dropdown (永久/15min/30min/1h/2h/3h/custom)
- Frontend: show ♾️ Permanent or ⏱️ duration in item list

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
这个提交包含在:
cryptocommuniums-afk
2026-02-16 18:38:56 +08:00
父节点 9772ea6764
当前提交 bd300ac597
修改 5 个文件,包含 68 行新增20 行删除

查看文件

@@ -16,6 +16,7 @@ struct RedeemItem {
std::string unit_label;
int holiday_cost = 0;
int studyday_cost = 0;
int duration_minutes = 0; // 0 = permanent
bool is_active = true;
bool is_global = true;
int64_t created_by = 0;
@@ -29,6 +30,7 @@ struct RedeemItemWrite {
std::string unit_label = "小时";
int holiday_cost = 5;
int studyday_cost = 25;
int duration_minutes = 0; // 0 = permanent
bool is_active = true;
bool is_global = true;
};

查看文件

@@ -68,6 +68,7 @@ services::RedeemItemWrite ParseRedeemItemWrite(const Json::Value& json) {
write.unit_label = json.get("unit_label", "小时").asString();
write.holiday_cost = json.get("holiday_cost", 5).asInt();
write.studyday_cost = json.get("studyday_cost", 25).asInt();
write.duration_minutes = json.get("duration_minutes", 0).asInt();
write.is_active = json.get("is_active", true).asBool();
write.is_global = json.get("is_global", true).asBool();
return write;
@@ -81,6 +82,7 @@ Json::Value ToJson(const services::RedeemItem& item) {
j["unit_label"] = item.unit_label;
j["holiday_cost"] = item.holiday_cost;
j["studyday_cost"] = item.studyday_cost;
j["duration_minutes"] = item.duration_minutes;
j["is_active"] = item.is_active;
j["is_global"] = item.is_global;
j["created_by"] = Json::Int64(item.created_by);

查看文件

@@ -524,6 +524,7 @@ CREATE TABLE IF NOT EXISTS redeem_items (
unit_label TEXT NOT NULL DEFAULT "小时",
holiday_cost INTEGER NOT NULL DEFAULT 5,
studyday_cost INTEGER NOT NULL DEFAULT 25,
duration_minutes INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_global INTEGER NOT NULL DEFAULT 1,
created_by INTEGER NOT NULL DEFAULT 0,
@@ -594,6 +595,8 @@ CREATE TABLE IF NOT EXISTS daily_task_logs (
EnsureColumn(db, "problem_solutions", "complexity",
"complexity TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problem_solutions", "tags_json", "tags_json TEXT NOT NULL DEFAULT '[]'");
EnsureColumn(db, "redeem_items", "duration_minutes",
"duration_minutes INTEGER NOT NULL DEFAULT 0");
// Build indexes after compatibility ALTERs so old schemas won't fail on
// missing columns (e.g. legacy submissions table without contest_id).

查看文件

@@ -36,11 +36,12 @@ RedeemItem ReadItem(sqlite3_stmt* stmt) {
item.unit_label = ColText(stmt, 3);
item.holiday_cost = sqlite3_column_int(stmt, 4);
item.studyday_cost = sqlite3_column_int(stmt, 5);
item.is_active = sqlite3_column_int(stmt, 6) != 0;
item.is_global = sqlite3_column_int(stmt, 7) != 0;
item.created_by = sqlite3_column_int64(stmt, 8);
item.created_at = sqlite3_column_int64(stmt, 9);
item.updated_at = sqlite3_column_int64(stmt, 10);
item.duration_minutes = sqlite3_column_int(stmt, 6);
item.is_active = sqlite3_column_int(stmt, 7) != 0;
item.is_global = sqlite3_column_int(stmt, 8) != 0;
item.created_by = sqlite3_column_int64(stmt, 9);
item.created_at = sqlite3_column_int64(stmt, 10);
item.updated_at = sqlite3_column_int64(stmt, 11);
return item;
}
@@ -114,10 +115,10 @@ std::vector<RedeemItem> RedeemService::ListItems(bool include_inactive) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql_all =
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at "
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,duration_minutes,is_active,is_global,created_by,created_at,updated_at "
"FROM redeem_items ORDER BY id ASC";
const char* sql_active =
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at "
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,duration_minutes,is_active,is_global,created_by,created_at,updated_at "
"FROM redeem_items WHERE is_active=1 ORDER BY id ASC";
CheckSqlite(sqlite3_prepare_v2(db, include_inactive ? sql_all : sql_active, -1, &stmt, nullptr),
db,
@@ -136,7 +137,7 @@ std::optional<RedeemItem> RedeemService::GetItemById(int64_t item_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at "
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,duration_minutes,is_active,is_global,created_by,created_at,updated_at "
"FROM redeem_items WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get redeem item");
@@ -156,8 +157,8 @@ RedeemItem RedeemService::CreateItem(int64_t admin_user_id, const RedeemItemWrit
sqlite3_stmt* stmt = nullptr;
const int64_t now = NowSec();
const char* sql =
"INSERT INTO redeem_items(name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?)";
"INSERT INTO redeem_items(name,description,unit_label,holiday_cost,studyday_cost,duration_minutes,is_active,is_global,created_by,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare create redeem item");
CheckSqlite(sqlite3_bind_text(stmt, 1, input.name.c_str(), -1, SQLITE_TRANSIENT), db,
@@ -168,11 +169,12 @@ RedeemItem RedeemService::CreateItem(int64_t admin_user_id, const RedeemItemWrit
"bind unit_label");
CheckSqlite(sqlite3_bind_int(stmt, 4, input.holiday_cost), db, "bind holiday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 5, input.studyday_cost), db, "bind studyday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 6, input.is_active ? 1 : 0), db, "bind is_active");
CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_global ? 1 : 0), db, "bind is_global");
CheckSqlite(sqlite3_bind_int64(stmt, 8, admin_user_id), db, "bind created_by");
CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind created_at");
CheckSqlite(sqlite3_bind_int64(stmt, 10, now), db, "bind updated_at");
CheckSqlite(sqlite3_bind_int(stmt, 6, input.duration_minutes), db, "bind duration_minutes");
CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_active ? 1 : 0), db, "bind is_active");
CheckSqlite(sqlite3_bind_int(stmt, 8, input.is_global ? 1 : 0), db, "bind is_global");
CheckSqlite(sqlite3_bind_int64(stmt, 9, admin_user_id), db, "bind created_by");
CheckSqlite(sqlite3_bind_int64(stmt, 10, now), db, "bind created_at");
CheckSqlite(sqlite3_bind_int64(stmt, 11, now), db, "bind updated_at");
CheckSqlite(sqlite3_step(stmt), db, "create redeem item");
sqlite3_finalize(stmt);
@@ -190,7 +192,7 @@ RedeemItem RedeemService::UpdateItem(int64_t item_id, const RedeemItemWrite& inp
const int64_t now = NowSec();
const char* sql =
"UPDATE redeem_items "
"SET name=?,description=?,unit_label=?,holiday_cost=?,studyday_cost=?,is_active=?,is_global=?,updated_at=? "
"SET name=?,description=?,unit_label=?,holiday_cost=?,studyday_cost=?,duration_minutes=?,is_active=?,is_global=?,updated_at=? "
"WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare update redeem item");
@@ -202,10 +204,11 @@ RedeemItem RedeemService::UpdateItem(int64_t item_id, const RedeemItemWrite& inp
"bind unit_label");
CheckSqlite(sqlite3_bind_int(stmt, 4, input.holiday_cost), db, "bind holiday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 5, input.studyday_cost), db, "bind studyday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 6, input.is_active ? 1 : 0), db, "bind is_active");
CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_global ? 1 : 0), db, "bind is_global");
CheckSqlite(sqlite3_bind_int64(stmt, 8, now), db, "bind updated_at");
CheckSqlite(sqlite3_bind_int64(stmt, 9, item_id), db, "bind item_id");
CheckSqlite(sqlite3_bind_int(stmt, 6, input.duration_minutes), db, "bind duration_minutes");
CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_active ? 1 : 0), db, "bind is_active");
CheckSqlite(sqlite3_bind_int(stmt, 8, input.is_global ? 1 : 0), db, "bind is_global");
CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind updated_at");
CheckSqlite(sqlite3_bind_int64(stmt, 10, item_id), db, "bind item_id");
CheckSqlite(sqlite3_step(stmt), db, "update redeem item");
sqlite3_finalize(stmt);
if (sqlite3_changes(db) <= 0) throw std::runtime_error("redeem item not found");

查看文件

@@ -14,6 +14,7 @@ type RedeemItem = {
unit_label: string;
holiday_cost: number;
studyday_cost: number;
duration_minutes: number;
is_active: boolean;
is_global: boolean;
created_at: number;
@@ -40,6 +41,7 @@ type ItemForm = {
unit_label: string;
holiday_cost: number;
studyday_cost: number;
duration_minutes: number;
is_active: boolean;
is_global: boolean;
};
@@ -50,6 +52,7 @@ const DEFAULT_FORM: ItemForm = {
unit_label: "hour",
holiday_cost: 5,
studyday_cost: 25,
duration_minutes: 0,
is_active: true,
is_global: true,
};
@@ -148,6 +151,7 @@ export default function AdminRedeemPage() {
unit_label: item.unit_label,
holiday_cost: item.holiday_cost,
studyday_cost: item.studyday_cost,
duration_minutes: item.duration_minutes,
is_active: item.is_active,
is_global: item.is_global,
});
@@ -217,6 +221,34 @@ export default function AdminRedeemPage() {
setForm((prev) => ({ ...prev, studyday_cost: Math.max(0, Number(e.target.value) || 0) }))
}
/>
<div className="flex items-center gap-2">
<select
className="rounded border px-3 py-2 text-sm flex-1"
value={form.duration_minutes === 0 ? "0" : String(form.duration_minutes)}
onChange={(e) => setForm((prev) => ({ ...prev, duration_minutes: Number(e.target.value) }))}
>
<option value="0">{tx("永久有效 ♾️", "Permanent ♾️")}</option>
<option value="15">15 {tx("分钟", "min")}</option>
<option value="30">30 {tx("分钟", "min")}</option>
<option value="60">1 {tx("小时", "hour")}</option>
<option value="90">1.5 {tx("小时", "hours")}</option>
<option value="120">2 {tx("小时", "hours")}</option>
<option value="180">3 {tx("小时", "hours")}</option>
<option value="-1">{tx("自定义", "Custom")}</option>
</select>
{form.duration_minutes === -1 && (
<input
className="rounded border px-3 py-2 text-sm w-24"
type="number"
min={1}
placeholder={tx("分钟", "min")}
onChange={(e) => {
const v = Math.max(1, Number(e.target.value) || 1);
setForm((prev) => ({ ...prev, duration_minutes: v }));
}}
/>
)}
</div>
<textarea
className="rounded border px-3 py-2 text-sm md:col-span-2"
placeholder={tx("描述", "Description")}
@@ -291,6 +323,12 @@ export default function AdminRedeemPage() {
<p>
#{item.id} · {item.name} · {tx("假期", "Holiday")} {item.holiday_cost}/{item.unit_label} · {tx("学习日", "Study Day")} {item.studyday_cost}/
{item.unit_label}
{" · "}
{item.duration_minutes > 0
? (item.duration_minutes >= 60
? `⏱️ ${item.duration_minutes / 60}${tx("小时", "h")}`
: `⏱️ ${item.duration_minutes}${tx("分钟", "min")}`)
: `♾️ ${tx("永久", "Permanent")}`}
</p>
<p className="text-xs text-zinc-600">
{tx("状态:", "Status: ")}