mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
新增奖励兑换模式与奖励设置,并持久化兑换记录
This commit is contained in:
@@ -7,7 +7,9 @@ use tokio::time::{timeout, Duration};
|
|||||||
|
|
||||||
use crate::db::connection::{create_postgres_connection, create_sqlite_connection};
|
use crate::db::connection::{create_postgres_connection, create_sqlite_connection};
|
||||||
use crate::db::connection::DatabaseType;
|
use crate::db::connection::DatabaseType;
|
||||||
use crate::db::entities::{reasons, score_events, student_tags, students, tags};
|
use crate::db::entities::{
|
||||||
|
reasons, reward_redemptions, reward_settings, score_events, student_tags, students, tags,
|
||||||
|
};
|
||||||
use crate::db::migration::run_migration;
|
use crate::db::migration::run_migration;
|
||||||
use crate::services::permission::PermissionLevel;
|
use crate::services::permission::PermissionLevel;
|
||||||
use crate::services::settings::{SettingsKey, SettingsValue};
|
use crate::services::settings::{SettingsKey, SettingsValue};
|
||||||
@@ -93,6 +95,7 @@ pub enum ConflictStrategy {
|
|||||||
struct StudentNormalized {
|
struct StudentNormalized {
|
||||||
name: String,
|
name: String,
|
||||||
score: i32,
|
score: i32,
|
||||||
|
reward_points: i32,
|
||||||
tags: String,
|
tags: String,
|
||||||
extra_json: Option<String>,
|
extra_json: Option<String>,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
@@ -126,6 +129,24 @@ struct EventNormalized {
|
|||||||
event_time: String,
|
event_time: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct RewardSettingNormalized {
|
||||||
|
name: String,
|
||||||
|
cost_points: i32,
|
||||||
|
created_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct RewardRedemptionNormalized {
|
||||||
|
uuid: String,
|
||||||
|
student_name: String,
|
||||||
|
reward_id: i32,
|
||||||
|
reward_name: String,
|
||||||
|
cost_points: i32,
|
||||||
|
redeemed_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
struct StudentTagPair {
|
struct StudentTagPair {
|
||||||
student_name: String,
|
student_name: String,
|
||||||
@@ -177,6 +198,7 @@ async fn load_students(
|
|||||||
StudentNormalized {
|
StudentNormalized {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
score: row.score,
|
score: row.score,
|
||||||
|
reward_points: row.reward_points,
|
||||||
tags: normalize_tags(&row.tags),
|
tags: normalize_tags(&row.tags),
|
||||||
extra_json: row.extra_json,
|
extra_json: row.extra_json,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
@@ -256,6 +278,52 @@ async fn load_events(
|
|||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn load_reward_settings(
|
||||||
|
conn: &sea_orm::DatabaseConnection,
|
||||||
|
) -> Result<std::collections::HashMap<String, RewardSettingNormalized>, String> {
|
||||||
|
let rows = reward_settings::Entity::find()
|
||||||
|
.all(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut map = std::collections::HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
map.insert(
|
||||||
|
row.name.clone(),
|
||||||
|
RewardSettingNormalized {
|
||||||
|
name: row.name,
|
||||||
|
cost_points: row.cost_points,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_reward_redemptions(
|
||||||
|
conn: &sea_orm::DatabaseConnection,
|
||||||
|
) -> Result<std::collections::HashMap<String, RewardRedemptionNormalized>, String> {
|
||||||
|
let rows = reward_redemptions::Entity::find()
|
||||||
|
.all(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut map = std::collections::HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
map.insert(
|
||||||
|
row.uuid.clone(),
|
||||||
|
RewardRedemptionNormalized {
|
||||||
|
uuid: row.uuid,
|
||||||
|
student_name: row.student_name,
|
||||||
|
reward_id: row.reward_id,
|
||||||
|
reward_name: row.reward_name,
|
||||||
|
cost_points: row.cost_points,
|
||||||
|
redeemed_at: row.redeemed_at,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_student_tag_pairs(
|
async fn load_student_tag_pairs(
|
||||||
conn: &sea_orm::DatabaseConnection,
|
conn: &sea_orm::DatabaseConnection,
|
||||||
) -> Result<std::collections::HashSet<StudentTagPair>, String> {
|
) -> Result<std::collections::HashSet<StudentTagPair>, String> {
|
||||||
@@ -334,6 +402,7 @@ async fn upsert_student(
|
|||||||
let normalized_current = StudentNormalized {
|
let normalized_current = StudentNormalized {
|
||||||
name: row.name.clone(),
|
name: row.name.clone(),
|
||||||
score: row.score,
|
score: row.score,
|
||||||
|
reward_points: row.reward_points,
|
||||||
tags: normalize_tags(&row.tags),
|
tags: normalize_tags(&row.tags),
|
||||||
extra_json: row.extra_json.clone(),
|
extra_json: row.extra_json.clone(),
|
||||||
created_at: row.created_at.clone(),
|
created_at: row.created_at.clone(),
|
||||||
@@ -344,6 +413,7 @@ async fn upsert_student(
|
|||||||
}
|
}
|
||||||
let mut active: students::ActiveModel = row.into();
|
let mut active: students::ActiveModel = row.into();
|
||||||
active.score = Set(data.score);
|
active.score = Set(data.score);
|
||||||
|
active.reward_points = Set(data.reward_points);
|
||||||
active.tags = Set(data.tags.clone());
|
active.tags = Set(data.tags.clone());
|
||||||
active.extra_json = Set(data.extra_json.clone());
|
active.extra_json = Set(data.extra_json.clone());
|
||||||
active.created_at = Set(data.created_at.clone());
|
active.created_at = Set(data.created_at.clone());
|
||||||
@@ -356,6 +426,7 @@ async fn upsert_student(
|
|||||||
id: sea_orm::ActiveValue::NotSet,
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
name: Set(data.name.clone()),
|
name: Set(data.name.clone()),
|
||||||
score: Set(data.score),
|
score: Set(data.score),
|
||||||
|
reward_points: Set(data.reward_points),
|
||||||
tags: Set(data.tags.clone()),
|
tags: Set(data.tags.clone()),
|
||||||
extra_json: Set(data.extra_json.clone()),
|
extra_json: Set(data.extra_json.clone()),
|
||||||
created_at: Set(data.created_at.clone()),
|
created_at: Set(data.created_at.clone()),
|
||||||
@@ -505,6 +576,98 @@ async fn upsert_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn upsert_reward_setting(
|
||||||
|
conn: &sea_orm::DatabaseConnection,
|
||||||
|
data: &RewardSettingNormalized,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let existing = reward_settings::Entity::find()
|
||||||
|
.filter(reward_settings::Column::Name.eq(&data.name))
|
||||||
|
.one(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
match existing {
|
||||||
|
Some(row) => {
|
||||||
|
let normalized_current = RewardSettingNormalized {
|
||||||
|
name: row.name.clone(),
|
||||||
|
cost_points: row.cost_points,
|
||||||
|
created_at: row.created_at.clone(),
|
||||||
|
updated_at: row.updated_at.clone(),
|
||||||
|
};
|
||||||
|
if normalized_current == *data {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let mut active: reward_settings::ActiveModel = row.into();
|
||||||
|
active.cost_points = Set(data.cost_points);
|
||||||
|
active.created_at = Set(data.created_at.clone());
|
||||||
|
active.updated_at = Set(data.updated_at.clone());
|
||||||
|
active.update(conn).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
reward_settings::ActiveModel {
|
||||||
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
|
name: Set(data.name.clone()),
|
||||||
|
cost_points: Set(data.cost_points),
|
||||||
|
created_at: Set(data.created_at.clone()),
|
||||||
|
updated_at: Set(data.updated_at.clone()),
|
||||||
|
}
|
||||||
|
.insert(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_reward_redemption(
|
||||||
|
conn: &sea_orm::DatabaseConnection,
|
||||||
|
data: &RewardRedemptionNormalized,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let existing = reward_redemptions::Entity::find()
|
||||||
|
.filter(reward_redemptions::Column::Uuid.eq(&data.uuid))
|
||||||
|
.one(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
match existing {
|
||||||
|
Some(row) => {
|
||||||
|
let normalized_current = RewardRedemptionNormalized {
|
||||||
|
uuid: row.uuid.clone(),
|
||||||
|
student_name: row.student_name.clone(),
|
||||||
|
reward_id: row.reward_id,
|
||||||
|
reward_name: row.reward_name.clone(),
|
||||||
|
cost_points: row.cost_points,
|
||||||
|
redeemed_at: row.redeemed_at.clone(),
|
||||||
|
};
|
||||||
|
if normalized_current == *data {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let mut active: reward_redemptions::ActiveModel = row.into();
|
||||||
|
active.student_name = Set(data.student_name.clone());
|
||||||
|
active.reward_id = Set(data.reward_id);
|
||||||
|
active.reward_name = Set(data.reward_name.clone());
|
||||||
|
active.cost_points = Set(data.cost_points);
|
||||||
|
active.redeemed_at = Set(data.redeemed_at.clone());
|
||||||
|
active.update(conn).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
reward_redemptions::ActiveModel {
|
||||||
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
|
uuid: Set(data.uuid.clone()),
|
||||||
|
student_name: Set(data.student_name.clone()),
|
||||||
|
reward_id: Set(data.reward_id),
|
||||||
|
reward_name: Set(data.reward_name.clone()),
|
||||||
|
cost_points: Set(data.cost_points),
|
||||||
|
redeemed_at: Set(data.redeemed_at.clone()),
|
||||||
|
}
|
||||||
|
.insert(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_student_tag_pair(
|
async fn ensure_student_tag_pair(
|
||||||
conn: &sea_orm::DatabaseConnection,
|
conn: &sea_orm::DatabaseConnection,
|
||||||
pair: &StudentTagPair,
|
pair: &StudentTagPair,
|
||||||
@@ -625,6 +788,16 @@ pub async fn realtime_dual_write_sync(app_state: &Arc<RwLock<AppState>>) -> Resu
|
|||||||
let _ = upsert_event(&local_conn, event).await?;
|
let _ = upsert_event(&local_conn, event).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let remote_reward_settings = load_reward_settings(&remote_conn).await?;
|
||||||
|
for reward in remote_reward_settings.values() {
|
||||||
|
let _ = upsert_reward_setting(&local_conn, reward).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remote_redemptions = load_reward_redemptions(&remote_conn).await?;
|
||||||
|
for redemption in remote_redemptions.values() {
|
||||||
|
let _ = upsert_reward_redemption(&local_conn, redemption).await?;
|
||||||
|
}
|
||||||
|
|
||||||
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
|
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
|
||||||
for pair in &remote_pairs {
|
for pair in &remote_pairs {
|
||||||
let _ = ensure_student_tag_pair(&local_conn, pair).await?;
|
let _ = ensure_student_tag_pair(&local_conn, pair).await?;
|
||||||
@@ -653,6 +826,8 @@ async fn db_sync_apply_internal(
|
|||||||
let local_reasons = load_reasons(&local_conn).await?;
|
let local_reasons = load_reasons(&local_conn).await?;
|
||||||
let local_tags = load_tags(&local_conn).await?;
|
let local_tags = load_tags(&local_conn).await?;
|
||||||
let local_events = load_events(&local_conn).await?;
|
let local_events = load_events(&local_conn).await?;
|
||||||
|
let local_reward_settings = load_reward_settings(&local_conn).await?;
|
||||||
|
let local_reward_redemptions = load_reward_redemptions(&local_conn).await?;
|
||||||
let local_pairs = load_student_tag_pairs(&local_conn).await?;
|
let local_pairs = load_student_tag_pairs(&local_conn).await?;
|
||||||
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
|
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
|
||||||
|
|
||||||
@@ -709,6 +884,28 @@ async fn db_sync_apply_internal(
|
|||||||
synced_records += 1;
|
synced_records += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for reward in local_reward_settings.values() {
|
||||||
|
if upsert_reward_setting(&remote_conn, reward).await? {
|
||||||
|
synced_records += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let remote_reward_settings_after = load_reward_settings(&remote_conn).await?;
|
||||||
|
for reward in remote_reward_settings_after.values() {
|
||||||
|
if upsert_reward_setting(&local_conn, reward).await? {
|
||||||
|
synced_records += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for redemption in local_reward_redemptions.values() {
|
||||||
|
if upsert_reward_redemption(&remote_conn, redemption).await? {
|
||||||
|
synced_records += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let remote_reward_redemptions_after = load_reward_redemptions(&remote_conn).await?;
|
||||||
|
for redemption in remote_reward_redemptions_after.values() {
|
||||||
|
if upsert_reward_redemption(&local_conn, redemption).await? {
|
||||||
|
synced_records += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for pair in local_pairs.union(&remote_pairs) {
|
for pair in local_pairs.union(&remote_pairs) {
|
||||||
if ensure_student_tag_pair(&local_conn, pair).await? {
|
if ensure_student_tag_pair(&local_conn, pair).await? {
|
||||||
@@ -743,6 +940,18 @@ async fn db_sync_apply_internal(
|
|||||||
resolved_conflicts += 1;
|
resolved_conflicts += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let preferred_reward_settings = load_reward_settings(preferred).await?;
|
||||||
|
for reward in preferred_reward_settings.values() {
|
||||||
|
if upsert_reward_setting(target, reward).await? {
|
||||||
|
resolved_conflicts += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let preferred_reward_redemptions = load_reward_redemptions(preferred).await?;
|
||||||
|
for redemption in preferred_reward_redemptions.values() {
|
||||||
|
if upsert_reward_redemption(target, redemption).await? {
|
||||||
|
resolved_conflicts += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
let preferred_pairs = load_student_tag_pairs(preferred).await?;
|
let preferred_pairs = load_student_tag_pairs(preferred).await?;
|
||||||
for pair in &preferred_pairs {
|
for pair in &preferred_pairs {
|
||||||
if ensure_student_tag_pair(target, pair).await? {
|
if ensure_student_tag_pair(target, pair).await? {
|
||||||
@@ -1005,6 +1214,10 @@ pub async fn db_sync_preview(
|
|||||||
let remote_tags = load_tags(&remote_conn).await?;
|
let remote_tags = load_tags(&remote_conn).await?;
|
||||||
let local_events = load_events(&local_conn).await?;
|
let local_events = load_events(&local_conn).await?;
|
||||||
let remote_events = load_events(&remote_conn).await?;
|
let remote_events = load_events(&remote_conn).await?;
|
||||||
|
let local_reward_settings = load_reward_settings(&local_conn).await?;
|
||||||
|
let remote_reward_settings = load_reward_settings(&remote_conn).await?;
|
||||||
|
let local_reward_redemptions = load_reward_redemptions(&local_conn).await?;
|
||||||
|
let remote_reward_redemptions = load_reward_redemptions(&remote_conn).await?;
|
||||||
let local_pairs = load_student_tag_pairs(&local_conn).await?;
|
let local_pairs = load_student_tag_pairs(&local_conn).await?;
|
||||||
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
|
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
|
||||||
|
|
||||||
@@ -1016,9 +1229,29 @@ pub async fn db_sync_preview(
|
|||||||
compare_maps("tags", &local_tags, &remote_tags);
|
compare_maps("tags", &local_tags, &remote_tags);
|
||||||
let (evt_local_only, evt_remote_only, evt_conflicts) =
|
let (evt_local_only, evt_remote_only, evt_conflicts) =
|
||||||
compare_maps("score_events", &local_events, &remote_events);
|
compare_maps("score_events", &local_events, &remote_events);
|
||||||
|
let (reward_local_only, reward_remote_only, reward_conflicts) = compare_maps(
|
||||||
|
"reward_settings",
|
||||||
|
&local_reward_settings,
|
||||||
|
&remote_reward_settings,
|
||||||
|
);
|
||||||
|
let (redeem_local_only, redeem_remote_only, redeem_conflicts) = compare_maps(
|
||||||
|
"reward_redemptions",
|
||||||
|
&local_reward_redemptions,
|
||||||
|
&remote_reward_redemptions,
|
||||||
|
);
|
||||||
|
|
||||||
let mut local_only = stu_local_only + rea_local_only + tag_local_only + evt_local_only;
|
let mut local_only = stu_local_only
|
||||||
let mut remote_only = stu_remote_only + rea_remote_only + tag_remote_only + evt_remote_only;
|
+ rea_local_only
|
||||||
|
+ tag_local_only
|
||||||
|
+ evt_local_only
|
||||||
|
+ reward_local_only
|
||||||
|
+ redeem_local_only;
|
||||||
|
let mut remote_only = stu_remote_only
|
||||||
|
+ rea_remote_only
|
||||||
|
+ tag_remote_only
|
||||||
|
+ evt_remote_only
|
||||||
|
+ reward_remote_only
|
||||||
|
+ redeem_remote_only;
|
||||||
|
|
||||||
for pair in &local_pairs {
|
for pair in &local_pairs {
|
||||||
if !remote_pairs.contains(pair) {
|
if !remote_pairs.contains(pair) {
|
||||||
@@ -1038,8 +1271,14 @@ pub async fn db_sync_preview(
|
|||||||
conflicts.push(DbSyncConflict {
|
conflicts.push(DbSyncConflict {
|
||||||
table,
|
table,
|
||||||
key,
|
key,
|
||||||
local_summary: format!("score={}, updated_at={}", local.score, local.updated_at),
|
local_summary: format!(
|
||||||
remote_summary: format!("score={}, updated_at={}", remote.score, remote.updated_at),
|
"score={}, reward_points={}, updated_at={}",
|
||||||
|
local.score, local.reward_points, local.updated_at
|
||||||
|
),
|
||||||
|
remote_summary: format!(
|
||||||
|
"score={}, reward_points={}, updated_at={}",
|
||||||
|
remote.score, remote.reward_points, remote.updated_at
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (table, key) in rea_conflicts {
|
for (table, key) in rea_conflicts {
|
||||||
@@ -1078,6 +1317,46 @@ pub async fn db_sync_preview(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (table, key) in reward_conflicts {
|
||||||
|
let local = local_reward_settings
|
||||||
|
.get(&key)
|
||||||
|
.expect("local reward setting exists");
|
||||||
|
let remote = remote_reward_settings
|
||||||
|
.get(&key)
|
||||||
|
.expect("remote reward setting exists");
|
||||||
|
conflicts.push(DbSyncConflict {
|
||||||
|
table,
|
||||||
|
key,
|
||||||
|
local_summary: format!(
|
||||||
|
"cost_points={}, updated_at={}",
|
||||||
|
local.cost_points, local.updated_at
|
||||||
|
),
|
||||||
|
remote_summary: format!(
|
||||||
|
"cost_points={}, updated_at={}",
|
||||||
|
remote.cost_points, remote.updated_at
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (table, key) in redeem_conflicts {
|
||||||
|
let local = local_reward_redemptions
|
||||||
|
.get(&key)
|
||||||
|
.expect("local redemption exists");
|
||||||
|
let remote = remote_reward_redemptions
|
||||||
|
.get(&key)
|
||||||
|
.expect("remote redemption exists");
|
||||||
|
conflicts.push(DbSyncConflict {
|
||||||
|
table,
|
||||||
|
key,
|
||||||
|
local_summary: format!(
|
||||||
|
"student={}, reward={}, cost={}, redeemed_at={}",
|
||||||
|
local.student_name, local.reward_name, local.cost_points, local.redeemed_at
|
||||||
|
),
|
||||||
|
remote_summary: format!(
|
||||||
|
"student={}, reward={}, cost={}, redeemed_at={}",
|
||||||
|
remote.student_name, remote.reward_name, remote.cost_points, remote.redeemed_at
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let need_sync = local_only > 0 || remote_only > 0 || !conflicts.is_empty();
|
let need_sync = local_only > 0 || remote_only > 0 || !conflicts.is_empty();
|
||||||
Ok(IpcResponse::success(DbSyncPreviewResult {
|
Ok(IpcResponse::success(DbSyncPreviewResult {
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ pub async fn event_create(
|
|||||||
Ok(Some(student)) => {
|
Ok(Some(student)) => {
|
||||||
let val_prev = student.score;
|
let val_prev = student.score;
|
||||||
let val_curr = val_prev + data.delta;
|
let val_curr = val_prev + data.delta;
|
||||||
|
let reward_points_next = student.reward_points + data.delta;
|
||||||
let now = chrono::Utc::now()
|
let now = chrono::Utc::now()
|
||||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -170,6 +171,7 @@ pub async fn event_create(
|
|||||||
|
|
||||||
let mut active: students::ActiveModel = student.into();
|
let mut active: students::ActiveModel = student.into();
|
||||||
active.score = Set(val_curr);
|
active.score = Set(val_curr);
|
||||||
|
active.reward_points = Set(reward_points_next);
|
||||||
active.updated_at = Set(now);
|
active.updated_at = Set(now);
|
||||||
active.update(&txn).await.map_err(|e| e.to_string())?;
|
active.update(&txn).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
@@ -244,12 +246,14 @@ pub async fn event_delete(
|
|||||||
|
|
||||||
if let Some(student) = student {
|
if let Some(student) = student {
|
||||||
let new_score = student.score - event.delta;
|
let new_score = student.score - event.delta;
|
||||||
|
let new_reward_points = student.reward_points - event.delta;
|
||||||
let now = chrono::Utc::now()
|
let now = chrono::Utc::now()
|
||||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let mut active: students::ActiveModel = student.into();
|
let mut active: students::ActiveModel = student.into();
|
||||||
active.score = Set(new_score);
|
active.score = Set(new_score);
|
||||||
|
active.reward_points = Set(new_reward_points);
|
||||||
active.updated_at = Set(now);
|
active.updated_at = Set(now);
|
||||||
active.update(&txn).await.map_err(|e| e.to_string())?;
|
active.update(&txn).await.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod filesystem;
|
|||||||
pub mod http_server;
|
pub mod http_server;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod reason;
|
pub mod reason;
|
||||||
|
pub mod reward;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod settlement;
|
pub mod settlement;
|
||||||
@@ -26,6 +27,7 @@ pub use filesystem::*;
|
|||||||
pub use http_server::*;
|
pub use http_server::*;
|
||||||
pub use log::*;
|
pub use log::*;
|
||||||
pub use reason::*;
|
pub use reason::*;
|
||||||
|
pub use reward::*;
|
||||||
pub use response::*;
|
pub use response::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use settlement::*;
|
pub use settlement::*;
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
use parking_lot::RwLock;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||||
|
TransactionTrait,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::db::entities::{reward_redemptions, reward_settings, students};
|
||||||
|
use crate::services::PermissionLevel;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::database::realtime_dual_write_sync;
|
||||||
|
use super::response::IpcResponse;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RewardSettingDto {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub cost_points: i32,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RewardRedemptionDto {
|
||||||
|
pub id: i32,
|
||||||
|
pub uuid: String,
|
||||||
|
pub student_name: String,
|
||||||
|
pub reward_id: i32,
|
||||||
|
pub reward_name: String,
|
||||||
|
pub cost_points: i32,
|
||||||
|
pub redeemed_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateRewardSettingData {
|
||||||
|
pub name: String,
|
||||||
|
pub cost_points: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateRewardSettingData {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub cost_points: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RedeemRewardData {
|
||||||
|
pub student_name: String,
|
||||||
|
pub reward_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RedeemRewardResult {
|
||||||
|
pub redemption_id: i32,
|
||||||
|
pub remaining_reward_points: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QueryRewardRedemptionsParams {
|
||||||
|
pub limit: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_permission(
|
||||||
|
state: &Arc<RwLock<AppState>>,
|
||||||
|
level: PermissionLevel,
|
||||||
|
) -> Result<(), IpcResponse<()>> {
|
||||||
|
let state_guard = state.read();
|
||||||
|
let mut permissions = state_guard.permissions.write();
|
||||||
|
if permissions.require_permission(0, level) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(IpcResponse::error("Permission denied"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reward_setting_query(
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<Vec<RewardSettingDto>>, String> {
|
||||||
|
if require_permission(&state, PermissionLevel::View).is_err() {
|
||||||
|
return Ok(IpcResponse::error("Permission denied: view required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_guard = state.read();
|
||||||
|
let db_guard = state_guard.db.read();
|
||||||
|
let Some(conn) = db_guard.as_ref() else {
|
||||||
|
return Ok(IpcResponse::error("Database not connected"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = reward_settings::Entity::find()
|
||||||
|
.order_by_asc(reward_settings::Column::CostPoints)
|
||||||
|
.order_by_asc(reward_settings::Column::Name)
|
||||||
|
.all(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let data = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| RewardSettingDto {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
cost_points: row.cost_points,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(IpcResponse::success(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reward_setting_create(
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
data: CreateRewardSettingData,
|
||||||
|
) -> Result<IpcResponse<i32>, String> {
|
||||||
|
if require_permission(&state, PermissionLevel::Admin).is_err() {
|
||||||
|
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = data.name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Ok(IpcResponse::error("Reward name cannot be empty"));
|
||||||
|
}
|
||||||
|
if data.cost_points <= 0 {
|
||||||
|
return Ok(IpcResponse::error("Reward cost points must be positive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_guard = state.read();
|
||||||
|
let db_guard = state_guard.db.read();
|
||||||
|
let Some(conn) = db_guard.as_ref() else {
|
||||||
|
return Ok(IpcResponse::error("Database not connected"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let existing = reward_settings::Entity::find()
|
||||||
|
.filter(reward_settings::Column::Name.eq(name))
|
||||||
|
.one(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if existing.is_some() {
|
||||||
|
return Ok(IpcResponse::error("Reward with this name already exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now()
|
||||||
|
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let inserted = reward_settings::ActiveModel {
|
||||||
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
cost_points: Set(data.cost_points),
|
||||||
|
created_at: Set(now.clone()),
|
||||||
|
updated_at: Set(now),
|
||||||
|
}
|
||||||
|
.insert(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
realtime_dual_write_sync(state.inner()).await?;
|
||||||
|
Ok(IpcResponse::success(inserted.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reward_setting_update(
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
id: i32,
|
||||||
|
data: UpdateRewardSettingData,
|
||||||
|
) -> Result<IpcResponse<()>, String> {
|
||||||
|
if require_permission(&state, PermissionLevel::Admin).is_err() {
|
||||||
|
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_guard = state.read();
|
||||||
|
let db_guard = state_guard.db.read();
|
||||||
|
let Some(conn) = db_guard.as_ref() else {
|
||||||
|
return Ok(IpcResponse::error("Database not connected"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(row) = reward_settings::Entity::find_by_id(id)
|
||||||
|
.one(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
else {
|
||||||
|
return Ok(IpcResponse::error("Reward not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut active: reward_settings::ActiveModel = row.into();
|
||||||
|
|
||||||
|
if let Some(name) = data.name {
|
||||||
|
let name = name.trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Ok(IpcResponse::error("Reward name cannot be empty"));
|
||||||
|
}
|
||||||
|
let existing = reward_settings::Entity::find()
|
||||||
|
.filter(reward_settings::Column::Name.eq(&name))
|
||||||
|
.filter(reward_settings::Column::Id.ne(id))
|
||||||
|
.one(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Ok(IpcResponse::error("Reward with this name already exists"));
|
||||||
|
}
|
||||||
|
active.name = Set(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cost_points) = data.cost_points {
|
||||||
|
if cost_points <= 0 {
|
||||||
|
return Ok(IpcResponse::error("Reward cost points must be positive"));
|
||||||
|
}
|
||||||
|
active.cost_points = Set(cost_points);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now()
|
||||||
|
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||||
|
.to_string();
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
|
||||||
|
active.update(conn).await.map_err(|e| e.to_string())?;
|
||||||
|
realtime_dual_write_sync(state.inner()).await?;
|
||||||
|
Ok(IpcResponse::success_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reward_setting_delete(
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
id: i32,
|
||||||
|
) -> Result<IpcResponse<()>, String> {
|
||||||
|
if require_permission(&state, PermissionLevel::Admin).is_err() {
|
||||||
|
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_guard = state.read();
|
||||||
|
let db_guard = state_guard.db.read();
|
||||||
|
let Some(conn) = db_guard.as_ref() else {
|
||||||
|
return Ok(IpcResponse::error("Database not connected"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let deleted = reward_settings::Entity::delete_by_id(id)
|
||||||
|
.exec(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if deleted.rows_affected == 0 {
|
||||||
|
return Ok(IpcResponse::error("Reward not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
realtime_dual_write_sync(state.inner()).await?;
|
||||||
|
Ok(IpcResponse::success_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reward_redeem(
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
data: RedeemRewardData,
|
||||||
|
) -> Result<IpcResponse<RedeemRewardResult>, String> {
|
||||||
|
if require_permission(&state, PermissionLevel::Points).is_err() {
|
||||||
|
return Ok(IpcResponse::error("Permission denied: points required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let student_name = data.student_name.trim();
|
||||||
|
if student_name.is_empty() {
|
||||||
|
return Ok(IpcResponse::error("Student name cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_guard = state.read();
|
||||||
|
let db_guard = state_guard.db.read();
|
||||||
|
let Some(conn) = db_guard.as_ref() else {
|
||||||
|
return Ok(IpcResponse::error("Database not connected"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let txn = conn.begin().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let Some(student) = students::Entity::find()
|
||||||
|
.filter(students::Column::Name.eq(student_name))
|
||||||
|
.one(&txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
else {
|
||||||
|
return Ok(IpcResponse::error("Student not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(reward) = reward_settings::Entity::find_by_id(data.reward_id)
|
||||||
|
.one(&txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
else {
|
||||||
|
return Ok(IpcResponse::error("Reward not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
if student.reward_points < reward.cost_points {
|
||||||
|
return Ok(IpcResponse::error("Insufficient reward points"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = student.reward_points - reward.cost_points;
|
||||||
|
let now = chrono::Utc::now()
|
||||||
|
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||||
|
.to_string();
|
||||||
|
let uuid = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let redemption = reward_redemptions::ActiveModel {
|
||||||
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
|
uuid: Set(uuid),
|
||||||
|
student_name: Set(student_name.to_string()),
|
||||||
|
reward_id: Set(reward.id),
|
||||||
|
reward_name: Set(reward.name.clone()),
|
||||||
|
cost_points: Set(reward.cost_points),
|
||||||
|
redeemed_at: Set(now.clone()),
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut active_student: students::ActiveModel = student.into();
|
||||||
|
active_student.reward_points = Set(remaining);
|
||||||
|
active_student.updated_at = Set(now);
|
||||||
|
active_student
|
||||||
|
.update(&txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
txn.commit().await.map_err(|e| e.to_string())?;
|
||||||
|
realtime_dual_write_sync(state.inner()).await?;
|
||||||
|
|
||||||
|
Ok(IpcResponse::success(RedeemRewardResult {
|
||||||
|
redemption_id: redemption.id,
|
||||||
|
remaining_reward_points: remaining,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reward_redemption_query(
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
params: Option<QueryRewardRedemptionsParams>,
|
||||||
|
) -> Result<IpcResponse<Vec<RewardRedemptionDto>>, String> {
|
||||||
|
if require_permission(&state, PermissionLevel::View).is_err() {
|
||||||
|
return Ok(IpcResponse::error("Permission denied: view required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_guard = state.read();
|
||||||
|
let db_guard = state_guard.db.read();
|
||||||
|
let Some(conn) = db_guard.as_ref() else {
|
||||||
|
return Ok(IpcResponse::error("Database not connected"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let limit = params.and_then(|p| p.limit).unwrap_or(100);
|
||||||
|
|
||||||
|
let rows = reward_redemptions::Entity::find()
|
||||||
|
.order_by_desc(reward_redemptions::Column::RedeemedAt)
|
||||||
|
.limit(limit)
|
||||||
|
.all(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let data = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| RewardRedemptionDto {
|
||||||
|
id: row.id,
|
||||||
|
uuid: row.uuid,
|
||||||
|
student_name: row.student_name,
|
||||||
|
reward_id: row.reward_id,
|
||||||
|
reward_name: row.reward_name,
|
||||||
|
cost_points: row.cost_points,
|
||||||
|
redeemed_at: row.redeemed_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(IpcResponse::success(data))
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ pub async fn student_query(
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
score: s.score,
|
score: s.score,
|
||||||
|
reward_points: s.reward_points,
|
||||||
tags: serde_json::from_str(&s.tags).unwrap_or_default(),
|
tags: serde_json::from_str(&s.tags).unwrap_or_default(),
|
||||||
extra_json: s.extra_json,
|
extra_json: s.extra_json,
|
||||||
})
|
})
|
||||||
@@ -120,6 +121,7 @@ pub async fn student_create(
|
|||||||
id: sea_orm::ActiveValue::NotSet,
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
name: Set(name.to_string()),
|
name: Set(name.to_string()),
|
||||||
score: Set(0),
|
score: Set(0),
|
||||||
|
reward_points: Set(0),
|
||||||
tags: Set("[]".to_string()),
|
tags: Set("[]".to_string()),
|
||||||
extra_json: Set(None),
|
extra_json: Set(None),
|
||||||
created_at: Set(now.clone()),
|
created_at: Set(now.clone()),
|
||||||
@@ -176,6 +178,9 @@ pub async fn student_update(
|
|||||||
if let Some(score) = data.score {
|
if let Some(score) = data.score {
|
||||||
active.score = Set(score);
|
active.score = Set(score);
|
||||||
}
|
}
|
||||||
|
if let Some(reward_points) = data.reward_points {
|
||||||
|
active.reward_points = Set(reward_points);
|
||||||
|
}
|
||||||
if let Some(tags) = data.tags {
|
if let Some(tags) = data.tags {
|
||||||
active.tags =
|
active.tags =
|
||||||
Set(serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string()));
|
Set(serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string()));
|
||||||
@@ -227,6 +232,7 @@ pub async fn student_delete(
|
|||||||
id: sea_orm::ActiveValue::Set(student.id),
|
id: sea_orm::ActiveValue::Set(student.id),
|
||||||
name: sea_orm::ActiveValue::Unchanged(student.name),
|
name: sea_orm::ActiveValue::Unchanged(student.name),
|
||||||
score: sea_orm::ActiveValue::Unchanged(student.score),
|
score: sea_orm::ActiveValue::Unchanged(student.score),
|
||||||
|
reward_points: sea_orm::ActiveValue::Unchanged(student.reward_points),
|
||||||
tags: sea_orm::ActiveValue::Unchanged(student.tags),
|
tags: sea_orm::ActiveValue::Unchanged(student.tags),
|
||||||
extra_json: sea_orm::ActiveValue::Unchanged(student.extra_json),
|
extra_json: sea_orm::ActiveValue::Unchanged(student.extra_json),
|
||||||
created_at: sea_orm::ActiveValue::Unchanged(student.created_at),
|
created_at: sea_orm::ActiveValue::Unchanged(student.created_at),
|
||||||
@@ -312,6 +318,7 @@ pub async fn student_import_from_xlsx(
|
|||||||
id: sea_orm::ActiveValue::NotSet,
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
name: Set(name.to_string()),
|
name: Set(name.to_string()),
|
||||||
score: Set(0),
|
score: Set(0),
|
||||||
|
reward_points: Set(0),
|
||||||
tags: Set("[]".to_string()),
|
tags: Set("[]".to_string()),
|
||||||
extra_json: Set(None),
|
extra_json: Set(None),
|
||||||
created_at: Set(now.clone()),
|
created_at: Set(now.clone()),
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
pub mod reasons;
|
pub mod reasons;
|
||||||
|
pub mod reward_redemptions;
|
||||||
|
pub mod reward_settings;
|
||||||
pub mod score_events;
|
pub mod score_events;
|
||||||
pub mod student_tags;
|
pub mod student_tags;
|
||||||
pub mod students;
|
pub mod students;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
|
||||||
pub use reasons::Entity as Reasons;
|
pub use reasons::Entity as Reasons;
|
||||||
|
pub use reward_redemptions::Entity as RewardRedemptions;
|
||||||
|
pub use reward_settings::Entity as RewardSettings;
|
||||||
pub use score_events::Entity as ScoreEvents;
|
pub use score_events::Entity as ScoreEvents;
|
||||||
pub use student_tags::Entity as StudentTags;
|
pub use student_tags::Entity as StudentTags;
|
||||||
pub use students::Entity as Students;
|
pub use students::Entity as Students;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "reward_redemptions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub uuid: String,
|
||||||
|
pub student_name: String,
|
||||||
|
pub reward_id: i32,
|
||||||
|
pub reward_name: String,
|
||||||
|
pub cost_points: i32,
|
||||||
|
pub redeemed_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "reward_settings")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub cost_points: i32,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -8,6 +8,7 @@ pub struct Model {
|
|||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
|
pub reward_points: i32,
|
||||||
pub tags: String,
|
pub tags: String,
|
||||||
pub extra_json: Option<String>,
|
pub extra_json: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ impl Migration {
|
|||||||
Self::create_settings_table(conn, is_sqlite).await?;
|
Self::create_settings_table(conn, is_sqlite).await?;
|
||||||
Self::create_tags_table(conn, is_sqlite).await?;
|
Self::create_tags_table(conn, is_sqlite).await?;
|
||||||
Self::create_student_tags_table(conn, is_sqlite).await?;
|
Self::create_student_tags_table(conn, is_sqlite).await?;
|
||||||
|
Self::create_reward_settings_table(conn, is_sqlite).await?;
|
||||||
|
Self::create_reward_redemptions_table(conn, is_sqlite).await?;
|
||||||
|
Self::ensure_students_reward_points_column(conn, is_sqlite).await?;
|
||||||
|
|
||||||
Self::create_indexes(conn, is_sqlite).await?;
|
Self::create_indexes(conn, is_sqlite).await?;
|
||||||
|
|
||||||
@@ -93,11 +96,64 @@ impl Migration {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_reward_settings_table(
|
||||||
|
conn: &impl ConnectionTrait,
|
||||||
|
sqlite: bool,
|
||||||
|
) -> Result<(), DbErr> {
|
||||||
|
let sql = get_create_reward_settings_table_sql(sqlite);
|
||||||
|
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
|
||||||
|
.await?;
|
||||||
|
info!("Created reward_settings table");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_reward_redemptions_table(
|
||||||
|
conn: &impl ConnectionTrait,
|
||||||
|
sqlite: bool,
|
||||||
|
) -> Result<(), DbErr> {
|
||||||
|
let sql = get_create_reward_redemptions_table_sql(sqlite);
|
||||||
|
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
|
||||||
|
.await?;
|
||||||
|
info!("Created reward_redemptions table");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_students_reward_points_column(
|
||||||
|
conn: &impl ConnectionTrait,
|
||||||
|
sqlite: bool,
|
||||||
|
) -> Result<(), DbErr> {
|
||||||
|
let db_backend = Self::get_db_backend(sqlite);
|
||||||
|
let exists_sql = if sqlite {
|
||||||
|
"SELECT 1 AS exists FROM pragma_table_info('students') WHERE name = 'reward_points' LIMIT 1"
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
"SELECT 1 AS exists FROM information_schema.columns WHERE table_name = 'students' AND column_name = 'reward_points' LIMIT 1"
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let exists = conn
|
||||||
|
.query_one(Statement::from_string(db_backend.clone(), exists_sql))
|
||||||
|
.await?
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
let alter_sql = "ALTER TABLE students ADD COLUMN reward_points INTEGER DEFAULT 0";
|
||||||
|
conn.execute(Statement::from_string(db_backend.clone(), alter_sql.to_string()))
|
||||||
|
.await?;
|
||||||
|
info!("Added students.reward_points column");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_indexes(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
async fn create_indexes(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||||
let indexes = vec![
|
let indexes = vec![
|
||||||
get_create_index_score_events_settlement_id_sql(sqlite),
|
get_create_index_score_events_settlement_id_sql(sqlite),
|
||||||
get_create_index_score_events_student_name_sql(sqlite),
|
get_create_index_score_events_student_name_sql(sqlite),
|
||||||
get_create_index_reasons_content_sql(sqlite),
|
get_create_index_reasons_content_sql(sqlite),
|
||||||
|
get_create_index_reward_settings_name_sql(sqlite),
|
||||||
|
get_create_index_reward_redemptions_student_name_sql(sqlite),
|
||||||
|
get_create_index_reward_redemptions_reward_id_sql(sqlite),
|
||||||
];
|
];
|
||||||
|
|
||||||
for index_sql in indexes {
|
for index_sql in indexes {
|
||||||
@@ -251,6 +307,8 @@ impl Migration {
|
|||||||
TABLE_REASONS,
|
TABLE_REASONS,
|
||||||
TABLE_TAGS,
|
TABLE_TAGS,
|
||||||
TABLE_STUDENTS,
|
TABLE_STUDENTS,
|
||||||
|
TABLE_REWARD_REDEMPTIONS,
|
||||||
|
TABLE_REWARD_SETTINGS,
|
||||||
TABLE_SETTINGS,
|
TABLE_SETTINGS,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ impl StudentRepository {
|
|||||||
pub async fn find_all(&self) -> Result<Vec<StudentWithTags>, sqlx::Error> {
|
pub async fn find_all(&self) -> Result<Vec<StudentWithTags>, sqlx::Error> {
|
||||||
if let Some(pool) = &self.sqlite_pool {
|
if let Some(pool) = &self.sqlite_pool {
|
||||||
let students = sqlx::query_as::<Sqlite, Student>(
|
let students = sqlx::query_as::<Sqlite, Student>(
|
||||||
r#"SELECT id, name, score, tags, extra_json, created_at, updated_at
|
r#"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at
|
||||||
FROM students
|
FROM students
|
||||||
ORDER BY score DESC, name ASC"#
|
ORDER BY score DESC, name ASC"#
|
||||||
)
|
)
|
||||||
@@ -40,7 +40,7 @@ impl StudentRepository {
|
|||||||
Ok(students.into_iter().map(StudentWithTags::from).collect())
|
Ok(students.into_iter().map(StudentWithTags::from).collect())
|
||||||
} else if let Some(pool) = &self.postgres_pool {
|
} else if let Some(pool) = &self.postgres_pool {
|
||||||
let students = sqlx::query_as::<Postgres, Student>(
|
let students = sqlx::query_as::<Postgres, Student>(
|
||||||
r#"SELECT id, name, score, tags, extra_json, created_at, updated_at
|
r#"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at
|
||||||
FROM students
|
FROM students
|
||||||
ORDER BY score DESC, name ASC"#
|
ORDER BY score DESC, name ASC"#
|
||||||
)
|
)
|
||||||
@@ -59,8 +59,8 @@ impl StudentRepository {
|
|||||||
|
|
||||||
if let Some(pool) = &self.sqlite_pool {
|
if let Some(pool) = &self.sqlite_pool {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at)
|
r#"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at)
|
||||||
VALUES (?, 0, '[]', NULL, ?, ?)"#
|
VALUES (?, 0, 0, '[]', NULL, ?, ?)"#
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
@@ -71,8 +71,8 @@ impl StudentRepository {
|
|||||||
Ok(result.last_insert_rowid() as i32)
|
Ok(result.last_insert_rowid() as i32)
|
||||||
} else if let Some(pool) = &self.postgres_pool {
|
} else if let Some(pool) = &self.postgres_pool {
|
||||||
let result = sqlx::query_scalar::<Postgres, i32>(
|
let result = sqlx::query_scalar::<Postgres, i32>(
|
||||||
r#"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at)
|
r#"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at)
|
||||||
VALUES ($1, 0, '[]', NULL, $2, $3)
|
VALUES ($1, 0, 0, '[]', NULL, $2, $3)
|
||||||
RETURNING id"#
|
RETURNING id"#
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
@@ -102,6 +102,10 @@ impl StudentRepository {
|
|||||||
query.push(", score = ");
|
query.push(", score = ");
|
||||||
query.push_bind(score);
|
query.push_bind(score);
|
||||||
}
|
}
|
||||||
|
if let Some(reward_points) = data.reward_points {
|
||||||
|
query.push(", reward_points = ");
|
||||||
|
query.push_bind(reward_points);
|
||||||
|
}
|
||||||
if let Some(tags) = &data.tags {
|
if let Some(tags) = &data.tags {
|
||||||
query.push(", tags = ");
|
query.push(", tags = ");
|
||||||
query.push_bind(serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()));
|
query.push_bind(serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()));
|
||||||
@@ -127,6 +131,10 @@ impl StudentRepository {
|
|||||||
query.push(", score = ");
|
query.push(", score = ");
|
||||||
query.push_bind(score);
|
query.push_bind(score);
|
||||||
}
|
}
|
||||||
|
if let Some(reward_points) = data.reward_points {
|
||||||
|
query.push(", reward_points = ");
|
||||||
|
query.push_bind(reward_points);
|
||||||
|
}
|
||||||
if let Some(tags) = &data.tags {
|
if let Some(tags) = &data.tags {
|
||||||
query.push(", tags = ");
|
query.push(", tags = ");
|
||||||
query.push_bind(serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()));
|
query.push_bind(serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()));
|
||||||
@@ -148,7 +156,7 @@ impl StudentRepository {
|
|||||||
pub async fn delete(&self, id: i32) -> Result<(), sqlx::Error> {
|
pub async fn delete(&self, id: i32) -> Result<(), sqlx::Error> {
|
||||||
if let Some(pool) = &self.sqlite_pool {
|
if let Some(pool) = &self.sqlite_pool {
|
||||||
let student = sqlx::query_as::<Sqlite, Student>(
|
let student = sqlx::query_as::<Sqlite, Student>(
|
||||||
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE id = ?"
|
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE id = ?"
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -171,7 +179,7 @@ impl StudentRepository {
|
|||||||
}
|
}
|
||||||
} else if let Some(pool) = &self.postgres_pool {
|
} else if let Some(pool) = &self.postgres_pool {
|
||||||
let student = sqlx::query_as::<Postgres, Student>(
|
let student = sqlx::query_as::<Postgres, Student>(
|
||||||
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE id = $1"
|
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE id = $1"
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -239,7 +247,7 @@ impl StudentRepository {
|
|||||||
|
|
||||||
for name in &to_insert {
|
for name in &to_insert {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at) VALUES (?, 0, '[]', NULL, ?, ?)"
|
"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) VALUES (?, 0, 0, '[]', NULL, ?, ?)"
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
@@ -274,7 +282,7 @@ impl StudentRepository {
|
|||||||
|
|
||||||
for name in &to_insert {
|
for name in &to_insert {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at) VALUES ($1, 0, '[]', NULL, $2, $3)"
|
"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) VALUES ($1, 0, 0, '[]', NULL, $2, $3)"
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
@@ -298,14 +306,14 @@ impl StudentRepository {
|
|||||||
pub async fn find_by_name(&self, name: &str) -> Result<Option<Student>, sqlx::Error> {
|
pub async fn find_by_name(&self, name: &str) -> Result<Option<Student>, sqlx::Error> {
|
||||||
if let Some(pool) = &self.sqlite_pool {
|
if let Some(pool) = &self.sqlite_pool {
|
||||||
sqlx::query_as::<Sqlite, Student>(
|
sqlx::query_as::<Sqlite, Student>(
|
||||||
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE name = ?"
|
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE name = ?"
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
} else if let Some(pool) = &self.postgres_pool {
|
} else if let Some(pool) = &self.postgres_pool {
|
||||||
sqlx::query_as::<Postgres, Student>(
|
sqlx::query_as::<Postgres, Student>(
|
||||||
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE name = $1"
|
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE name = $1"
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ pub const TABLE_SETTLEMENTS: &str = "settlements";
|
|||||||
pub const TABLE_SETTINGS: &str = "settings";
|
pub const TABLE_SETTINGS: &str = "settings";
|
||||||
pub const TABLE_TAGS: &str = "tags";
|
pub const TABLE_TAGS: &str = "tags";
|
||||||
pub const TABLE_STUDENT_TAGS: &str = "student_tags";
|
pub const TABLE_STUDENT_TAGS: &str = "student_tags";
|
||||||
|
pub const TABLE_REWARD_SETTINGS: &str = "reward_settings";
|
||||||
|
pub const TABLE_REWARD_REDEMPTIONS: &str = "reward_redemptions";
|
||||||
|
|
||||||
pub mod students {
|
pub mod students {
|
||||||
pub const TABLE: &str = "students";
|
pub const TABLE: &str = "students";
|
||||||
@@ -12,6 +14,7 @@ pub mod students {
|
|||||||
pub const NAME: &str = "name";
|
pub const NAME: &str = "name";
|
||||||
pub const TAGS: &str = "tags";
|
pub const TAGS: &str = "tags";
|
||||||
pub const SCORE: &str = "score";
|
pub const SCORE: &str = "score";
|
||||||
|
pub const REWARD_POINTS: &str = "reward_points";
|
||||||
pub const EXTRA_JSON: &str = "extra_json";
|
pub const EXTRA_JSON: &str = "extra_json";
|
||||||
pub const CREATED_AT: &str = "created_at";
|
pub const CREATED_AT: &str = "created_at";
|
||||||
pub const UPDATED_AT: &str = "updated_at";
|
pub const UPDATED_AT: &str = "updated_at";
|
||||||
@@ -70,6 +73,26 @@ pub mod student_tags {
|
|||||||
pub const CREATED_AT: &str = "created_at";
|
pub const CREATED_AT: &str = "created_at";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod reward_settings {
|
||||||
|
pub const TABLE: &str = "reward_settings";
|
||||||
|
pub const ID: &str = "id";
|
||||||
|
pub const NAME: &str = "name";
|
||||||
|
pub const COST_POINTS: &str = "cost_points";
|
||||||
|
pub const CREATED_AT: &str = "created_at";
|
||||||
|
pub const UPDATED_AT: &str = "updated_at";
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod reward_redemptions {
|
||||||
|
pub const TABLE: &str = "reward_redemptions";
|
||||||
|
pub const ID: &str = "id";
|
||||||
|
pub const UUID: &str = "uuid";
|
||||||
|
pub const STUDENT_NAME: &str = "student_name";
|
||||||
|
pub const REWARD_ID: &str = "reward_id";
|
||||||
|
pub const REWARD_NAME: &str = "reward_name";
|
||||||
|
pub const COST_POINTS: &str = "cost_points";
|
||||||
|
pub const REDEEMED_AT: &str = "redeemed_at";
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_create_students_table_sql(sqlite: bool) -> String {
|
pub fn get_create_students_table_sql(sqlite: bool) -> String {
|
||||||
if sqlite {
|
if sqlite {
|
||||||
r#"
|
r#"
|
||||||
@@ -78,6 +101,7 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
tags TEXT DEFAULT '[]',
|
tags TEXT DEFAULT '[]',
|
||||||
score INTEGER DEFAULT 0,
|
score INTEGER DEFAULT 0,
|
||||||
|
reward_points INTEGER DEFAULT 0,
|
||||||
extra_json TEXT,
|
extra_json TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||||
@@ -91,6 +115,7 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
tags TEXT DEFAULT '[]',
|
tags TEXT DEFAULT '[]',
|
||||||
score INTEGER DEFAULT 0,
|
score INTEGER DEFAULT 0,
|
||||||
|
reward_points INTEGER DEFAULT 0,
|
||||||
extra_json TEXT,
|
extra_json TEXT,
|
||||||
created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
||||||
updated_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
updated_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||||
@@ -100,6 +125,62 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_create_reward_settings_table_sql(sqlite: bool) -> String {
|
||||||
|
if sqlite {
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS reward_settings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
cost_points INTEGER NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||||
|
)
|
||||||
|
"#
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS reward_settings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
cost_points INTEGER NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
||||||
|
updated_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||||
|
)
|
||||||
|
"#
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_create_reward_redemptions_table_sql(sqlite: bool) -> String {
|
||||||
|
if sqlite {
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS reward_redemptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
|
student_name TEXT NOT NULL,
|
||||||
|
reward_id INTEGER NOT NULL,
|
||||||
|
reward_name TEXT NOT NULL,
|
||||||
|
cost_points INTEGER NOT NULL,
|
||||||
|
redeemed_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||||
|
)
|
||||||
|
"#
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS reward_redemptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
|
student_name TEXT NOT NULL,
|
||||||
|
reward_id INTEGER NOT NULL,
|
||||||
|
reward_name TEXT NOT NULL,
|
||||||
|
cost_points INTEGER NOT NULL,
|
||||||
|
redeemed_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||||
|
)
|
||||||
|
"#
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_create_reasons_table_sql(sqlite: bool) -> String {
|
pub fn get_create_reasons_table_sql(sqlite: bool) -> String {
|
||||||
if sqlite {
|
if sqlite {
|
||||||
r#"
|
r#"
|
||||||
@@ -260,6 +341,20 @@ pub fn get_create_student_tags_table_sql(sqlite: bool) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_create_index_reward_settings_name_sql(_sqlite: bool) -> String {
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_reward_settings_name ON reward_settings(name)".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_create_index_reward_redemptions_student_name_sql(_sqlite: bool) -> String {
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_reward_redemptions_student_name ON reward_redemptions(student_name)"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_create_index_reward_redemptions_reward_id_sql(_sqlite: bool) -> String {
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_reward_redemptions_reward_id ON reward_redemptions(reward_id)"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_create_index_score_events_settlement_id_sql(sqlite: bool) -> String {
|
pub fn get_create_index_score_events_settlement_id_sql(sqlite: bool) -> String {
|
||||||
if sqlite {
|
if sqlite {
|
||||||
"CREATE INDEX IF NOT EXISTS idx_score_events_settlement_id ON score_events(settlement_id)"
|
"CREATE INDEX IF NOT EXISTS idx_score_events_settlement_id ON score_events(settlement_id)"
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ pub fn run() {
|
|||||||
reason_create,
|
reason_create,
|
||||||
reason_update,
|
reason_update,
|
||||||
reason_delete,
|
reason_delete,
|
||||||
|
reward_setting_query,
|
||||||
|
reward_setting_create,
|
||||||
|
reward_setting_update,
|
||||||
|
reward_setting_delete,
|
||||||
|
reward_redeem,
|
||||||
|
reward_redemption_query,
|
||||||
event_query,
|
event_query,
|
||||||
event_create,
|
event_create,
|
||||||
event_delete,
|
event_delete,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub struct Student {
|
|||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
|
pub reward_points: i32,
|
||||||
pub tags: String,
|
pub tags: String,
|
||||||
pub extra_json: Option<String>,
|
pub extra_json: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
@@ -15,6 +16,7 @@ pub struct Student {
|
|||||||
pub struct StudentUpdate {
|
pub struct StudentUpdate {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub score: Option<i32>,
|
pub score: Option<i32>,
|
||||||
|
pub reward_points: Option<i32>,
|
||||||
pub tags: Option<Vec<String>>,
|
pub tags: Option<Vec<String>>,
|
||||||
pub extra_json: Option<String>,
|
pub extra_json: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,7 @@ pub struct StudentWithTags {
|
|||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
|
pub reward_points: i32,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
pub extra_json: Option<String>,
|
pub extra_json: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -35,6 +38,7 @@ impl From<Student> for StudentWithTags {
|
|||||||
id: student.id,
|
id: student.id,
|
||||||
name: student.name,
|
name: student.name,
|
||||||
score: student.score,
|
score: student.score,
|
||||||
|
reward_points: student.reward_points,
|
||||||
tags,
|
tags,
|
||||||
extra_json: student.extra_json,
|
extra_json: student.extra_json,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ function MainContent(): React.JSX.Element {
|
|||||||
if (p.startsWith("/settlements")) return "settlements"
|
if (p.startsWith("/settlements")) return "settlements"
|
||||||
if (p.startsWith("/reasons")) return "reasons"
|
if (p.startsWith("/reasons")) return "reasons"
|
||||||
if (p.startsWith("/auto-score")) return "auto-score"
|
if (p.startsWith("/auto-score")) return "auto-score"
|
||||||
|
if (p.startsWith("/reward-exchange")) return "reward-exchange"
|
||||||
|
if (p.startsWith("/reward-settings")) return "reward-settings"
|
||||||
if (p.startsWith("/settings")) return "settings"
|
if (p.startsWith("/settings")) return "settings"
|
||||||
return "home"
|
return "home"
|
||||||
}, [location.pathname])
|
}, [location.pathname])
|
||||||
@@ -272,6 +274,8 @@ function MainContent(): React.JSX.Element {
|
|||||||
if (key === "settlements") navigate("/settlements")
|
if (key === "settlements") navigate("/settlements")
|
||||||
if (key === "reasons") navigate("/reasons")
|
if (key === "reasons") navigate("/reasons")
|
||||||
if (key === "auto-score") navigate("/auto-score")
|
if (key === "auto-score") navigate("/auto-score")
|
||||||
|
if (key === "reward-exchange") navigate("/reward-exchange")
|
||||||
|
if (key === "reward-settings") navigate("/reward-settings")
|
||||||
if (key === "settings") navigate("/settings")
|
if (key === "settings") navigate("/settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const loadScoreManager = () => import("./ScoreManager")
|
|||||||
const loadLeaderboard = () => import("./Leaderboard")
|
const loadLeaderboard = () => import("./Leaderboard")
|
||||||
const loadSettlementHistory = () => import("./SettlementHistory")
|
const loadSettlementHistory = () => import("./SettlementHistory")
|
||||||
const loadAutoScoreManager = () => import("./AutoScoreManager")
|
const loadAutoScoreManager = () => import("./AutoScoreManager")
|
||||||
|
const loadRewardExchange = () => import("./RewardExchange")
|
||||||
|
const loadRewardSettings = () => import("./RewardSettings")
|
||||||
|
|
||||||
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
|
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
|
||||||
const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager })))
|
const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager })))
|
||||||
@@ -26,6 +28,12 @@ const SettlementHistory = lazy(() =>
|
|||||||
const AutoScoreManager = lazy(() =>
|
const AutoScoreManager = lazy(() =>
|
||||||
loadAutoScoreManager().then((m) => ({ default: m.AutoScoreManager }))
|
loadAutoScoreManager().then((m) => ({ default: m.AutoScoreManager }))
|
||||||
)
|
)
|
||||||
|
const RewardExchange = lazy(() =>
|
||||||
|
loadRewardExchange().then((m) => ({ default: m.RewardExchange }))
|
||||||
|
)
|
||||||
|
const RewardSettings = lazy(() =>
|
||||||
|
loadRewardSettings().then((m) => ({ default: m.RewardSettings }))
|
||||||
|
)
|
||||||
|
|
||||||
const warmupRouteChunks = () =>
|
const warmupRouteChunks = () =>
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
@@ -37,6 +45,8 @@ const warmupRouteChunks = () =>
|
|||||||
loadLeaderboard(),
|
loadLeaderboard(),
|
||||||
loadSettlementHistory(),
|
loadSettlementHistory(),
|
||||||
loadAutoScoreManager(),
|
loadAutoScoreManager(),
|
||||||
|
loadRewardExchange(),
|
||||||
|
loadRewardSettings(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const { Content } = Layout
|
const { Content } = Layout
|
||||||
@@ -235,6 +245,14 @@ export function ContentArea({
|
|||||||
<Route path="/settlements" element={<SettlementHistory />} />
|
<Route path="/settlements" element={<SettlementHistory />} />
|
||||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
||||||
<Route path="/auto-score" element={<AutoScoreManager />} />
|
<Route path="/auto-score" element={<AutoScoreManager />} />
|
||||||
|
<Route
|
||||||
|
path="/reward-exchange"
|
||||||
|
element={<RewardExchange canEdit={permission === "admin" || permission === "points"} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/reward-settings"
|
||||||
|
element={<RewardSettings canEdit={permission === "admin"} />}
|
||||||
|
/>
|
||||||
<Route path="/settings" element={<Settings permission={permission} />} />
|
<Route path="/settings" element={<Settings permission={permission} />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { Button, Card, Modal, Table, Tag, message } from "antd"
|
||||||
|
import type { ColumnsType } from "antd/es/table"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
interface StudentItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
score: number
|
||||||
|
reward_points: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RewardItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
cost_points: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RedemptionItem {
|
||||||
|
id: number
|
||||||
|
uuid: string
|
||||||
|
student_name: string
|
||||||
|
reward_id: number
|
||||||
|
reward_name: string
|
||||||
|
cost_points: number
|
||||||
|
redeemed_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [students, setStudents] = useState<StudentItem[]>([])
|
||||||
|
const [rewards, setRewards] = useState<RewardItem[]>([])
|
||||||
|
const [records, setRecords] = useState<RedemptionItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [exchangeMode, setExchangeMode] = useState(false)
|
||||||
|
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null)
|
||||||
|
const [chooseRewardVisible, setChooseRewardVisible] = useState(false)
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
|
const emitDataUpdated = () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!(window as any).api) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [stuRes, rewardRes, recordRes] = await Promise.all([
|
||||||
|
(window as any).api.queryStudents({}),
|
||||||
|
(window as any).api.rewardSettingQuery(),
|
||||||
|
(window as any).api.rewardRedemptionQuery({ limit: 100 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (stuRes.success && Array.isArray(stuRes.data)) {
|
||||||
|
setStudents(stuRes.data)
|
||||||
|
}
|
||||||
|
if (rewardRes.success && Array.isArray(rewardRes.data)) {
|
||||||
|
setRewards(rewardRes.data)
|
||||||
|
}
|
||||||
|
if (recordRes.success && Array.isArray(recordRes.data)) {
|
||||||
|
setRecords(recordRes.data)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
const onDataUpdated = (e: any) => {
|
||||||
|
const category = e?.detail?.category
|
||||||
|
if (category === "all" || category === "students" || category === "events") {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("ss:data-updated", onDataUpdated as any)
|
||||||
|
return () => window.removeEventListener("ss:data-updated", onDataUpdated as any)
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const sortedStudents = useMemo(
|
||||||
|
() => [...students].sort((a, b) => b.reward_points - a.reward_points || a.name.localeCompare(b.name, "zh-CN")),
|
||||||
|
[students]
|
||||||
|
)
|
||||||
|
|
||||||
|
const affordableRewards = useMemo(() => {
|
||||||
|
if (!selectedStudent) return []
|
||||||
|
return rewards.filter((r) => r.cost_points <= selectedStudent.reward_points)
|
||||||
|
}, [rewards, selectedStudent])
|
||||||
|
|
||||||
|
const handleStudentClick = (student: StudentItem) => {
|
||||||
|
if (!exchangeMode) return
|
||||||
|
if (!canEdit) {
|
||||||
|
messageApi.error(t("common.readOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedStudent(student)
|
||||||
|
setChooseRewardVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRedeem = async (reward: RewardItem) => {
|
||||||
|
if (!(window as any).api || !selectedStudent) return
|
||||||
|
if (!canEdit) {
|
||||||
|
messageApi.error(t("common.readOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await (window as any).api.rewardRedeem({
|
||||||
|
student_name: selectedStudent.name,
|
||||||
|
reward_id: reward.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
messageApi.success(
|
||||||
|
t("rewardExchange.redeemSuccess", {
|
||||||
|
student: selectedStudent.name,
|
||||||
|
reward: reward.name,
|
||||||
|
points: reward.cost_points,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setChooseRewardVisible(false)
|
||||||
|
setSelectedStudent(null)
|
||||||
|
setExchangeMode(false)
|
||||||
|
fetchData()
|
||||||
|
emitDataUpdated()
|
||||||
|
} else {
|
||||||
|
messageApi.error(res.message || t("rewardExchange.redeemFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<RedemptionItem> = [
|
||||||
|
{ title: t("rewardExchange.recordStudent"), dataIndex: "student_name", key: "student_name", width: 120 },
|
||||||
|
{ title: t("rewardExchange.recordReward"), dataIndex: "reward_name", key: "reward_name" },
|
||||||
|
{
|
||||||
|
title: t("rewardExchange.recordCost"),
|
||||||
|
dataIndex: "cost_points",
|
||||||
|
key: "cost_points",
|
||||||
|
width: 120,
|
||||||
|
render: (v: number) => <Tag color="gold">-{v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("rewardExchange.recordTime"),
|
||||||
|
dataIndex: "redeemed_at",
|
||||||
|
key: "redeemed_at",
|
||||||
|
width: 180,
|
||||||
|
render: (time: string) => new Date(time).toLocaleString(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "24px" }}>
|
||||||
|
{contextHolder}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px" }}>
|
||||||
|
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("rewardExchange.title")}</h2>
|
||||||
|
<Button
|
||||||
|
type={exchangeMode ? "default" : "primary"}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onClick={() => {
|
||||||
|
if (!canEdit) {
|
||||||
|
messageApi.error(t("common.readOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExchangeMode((prev) => !prev)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exchangeMode ? t("rewardExchange.exitMode") : t("rewardExchange.enterMode")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "12px", color: "var(--ss-text-secondary)", fontSize: 13 }}>
|
||||||
|
{exchangeMode ? t("rewardExchange.modeHint") : t("rewardExchange.normalHint")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
|
||||||
|
gap: "12px",
|
||||||
|
marginBottom: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortedStudents.map((student) => {
|
||||||
|
const points = exchangeMode ? student.reward_points : student.score
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={student.id}
|
||||||
|
hoverable={exchangeMode}
|
||||||
|
onClick={() => handleStudentClick(student)}
|
||||||
|
style={{
|
||||||
|
cursor: exchangeMode ? "pointer" : "default",
|
||||||
|
border: exchangeMode ? "1px solid var(--ant-color-primary, #1677ff)" : "1px solid var(--ss-border-color)",
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: "12px" }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>{student.name}</div>
|
||||||
|
<Tag color={points > 0 ? "success" : points < 0 ? "error" : "default"}>
|
||||||
|
{points > 0 ? `+${points}` : points}
|
||||||
|
</Tag>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||||
|
{exchangeMode ? t("rewardExchange.remainingRewardPoints") : t("rewardExchange.currentScore")}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title={t("rewardExchange.recordsTitle")} loading={loading}>
|
||||||
|
<Table
|
||||||
|
rowKey="uuid"
|
||||||
|
dataSource={records}
|
||||||
|
columns={columns}
|
||||||
|
pagination={{ pageSize: 10, total: records.length, defaultCurrent: 1 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t("rewardExchange.chooseRewardTitle", { name: selectedStudent?.name || "" })}
|
||||||
|
open={chooseRewardVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setChooseRewardVisible(false)
|
||||||
|
setSelectedStudent(null)
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{!selectedStudent ? null : (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: "12px", color: "var(--ss-text-secondary)", fontSize: 13 }}>
|
||||||
|
{t("rewardExchange.currentRewardPoints", { points: selectedStudent.reward_points })}
|
||||||
|
</div>
|
||||||
|
{affordableRewards.length === 0 ? (
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)" }}>{t("rewardExchange.noAffordableRewards")}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{affordableRewards
|
||||||
|
.sort((a, b) => a.cost_points - b.cost_points || a.name.localeCompare(b.name, "zh-CN"))
|
||||||
|
.map((reward) => (
|
||||||
|
<div
|
||||||
|
key={reward.id}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--ss-border-color)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{reward.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||||
|
{t("rewardExchange.costLabel", { points: reward.cost_points })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" onClick={() => handleRedeem(reward)}>
|
||||||
|
{t("rewardExchange.redeemNow")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react"
|
||||||
|
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Table, message } from "antd"
|
||||||
|
import type { ColumnsType } from "antd/es/table"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
interface RewardItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
cost_points: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RewardSettings: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [data, setData] = useState<RewardItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<RewardItem | null>(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
|
const emitDataUpdated = () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRewards = useCallback(async () => {
|
||||||
|
if (!(window as any).api) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await (window as any).api.rewardSettingQuery()
|
||||||
|
if (res.success && Array.isArray(res.data)) {
|
||||||
|
setData(res.data)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRewards()
|
||||||
|
}, [fetchRewards])
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
if (!canEdit) {
|
||||||
|
messageApi.error(t("common.readOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEditing(null)
|
||||||
|
form.setFieldsValue({ name: "", cost_points: 1 })
|
||||||
|
setVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (item: RewardItem) => {
|
||||||
|
if (!canEdit) {
|
||||||
|
messageApi.error(t("common.readOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEditing(item)
|
||||||
|
form.setFieldsValue({ name: item.name, cost_points: item.cost_points })
|
||||||
|
setVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (item: RewardItem) => {
|
||||||
|
if (!(window as any).api) return
|
||||||
|
if (!canEdit) {
|
||||||
|
messageApi.error(t("common.readOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await (window as any).api.rewardSettingDelete(item.id)
|
||||||
|
if (res.success) {
|
||||||
|
messageApi.success(t("rewardSettings.deleteSuccess"))
|
||||||
|
fetchRewards()
|
||||||
|
emitDataUpdated()
|
||||||
|
} else {
|
||||||
|
messageApi.error(res.message || t("rewardSettings.deleteFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!(window as any).api) return
|
||||||
|
if (!canEdit) {
|
||||||
|
messageApi.error(t("common.readOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = await form.validateFields()
|
||||||
|
const payload = {
|
||||||
|
name: String(values.name || "").trim(),
|
||||||
|
cost_points: Number(values.cost_points),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.name) {
|
||||||
|
messageApi.warning(t("rewardSettings.nameRequired"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(payload.cost_points) || payload.cost_points <= 0) {
|
||||||
|
messageApi.warning(t("rewardSettings.costRequired"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = editing
|
||||||
|
? await (window as any).api.rewardSettingUpdate(editing.id, payload)
|
||||||
|
: await (window as any).api.rewardSettingCreate(payload)
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
messageApi.success(editing ? t("rewardSettings.updateSuccess") : t("rewardSettings.createSuccess"))
|
||||||
|
setVisible(false)
|
||||||
|
form.resetFields()
|
||||||
|
setEditing(null)
|
||||||
|
fetchRewards()
|
||||||
|
emitDataUpdated()
|
||||||
|
} else {
|
||||||
|
messageApi.error(res.message || (editing ? t("rewardSettings.updateFailed") : t("rewardSettings.createFailed")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<RewardItem> = [
|
||||||
|
{
|
||||||
|
title: t("rewardSettings.rewardName"),
|
||||||
|
dataIndex: "name",
|
||||||
|
key: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("rewardSettings.costPoints"),
|
||||||
|
dataIndex: "cost_points",
|
||||||
|
key: "cost_points",
|
||||||
|
width: 140,
|
||||||
|
render: (v: number) => <b>{v}</b>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common.operation"),
|
||||||
|
key: "operation",
|
||||||
|
width: 180,
|
||||||
|
render: (_, row) => (
|
||||||
|
<>
|
||||||
|
<Button type="link" disabled={!canEdit} onClick={() => openEdit(row)}>
|
||||||
|
{t("common.edit")}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={t("rewardSettings.deleteConfirm", { name: row.name })}
|
||||||
|
onConfirm={() => handleDelete(row)}
|
||||||
|
disabled={!canEdit}
|
||||||
|
>
|
||||||
|
<Button type="link" danger disabled={!canEdit}>
|
||||||
|
{t("common.delete")}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "24px" }}>
|
||||||
|
{contextHolder}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "16px" }}>
|
||||||
|
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("rewardSettings.title")}</h2>
|
||||||
|
<Button type="primary" disabled={!canEdit} onClick={openCreate}>
|
||||||
|
{t("rewardSettings.addReward")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={data}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
bordered
|
||||||
|
pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? t("rewardSettings.editTitle") : t("rewardSettings.addTitle")}
|
||||||
|
open={visible}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => {
|
||||||
|
setVisible(false)
|
||||||
|
setEditing(null)
|
||||||
|
}}
|
||||||
|
okText={t("common.save")}
|
||||||
|
cancelText={t("common.cancel")}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" initialValues={{ cost_points: 1 }}>
|
||||||
|
<Form.Item
|
||||||
|
label={t("rewardSettings.rewardName")}
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: t("rewardSettings.nameRequired") }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t("rewardSettings.namePlaceholder")} maxLength={64} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("rewardSettings.costPoints")}
|
||||||
|
name="cost_points"
|
||||||
|
rules={[{ required: true, message: t("rewardSettings.costRequired") }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: "100%" }} placeholder={t("rewardSettings.costPlaceholder")} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
CloudOutlined,
|
CloudOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
|
GiftOutlined,
|
||||||
|
AppstoreAddOutlined,
|
||||||
} from "@ant-design/icons"
|
} from "@ant-design/icons"
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
@@ -166,6 +168,17 @@ export function Sidebar({
|
|||||||
icon: <SyncOutlined />,
|
icon: <SyncOutlined />,
|
||||||
label: t("sidebar.autoScore"),
|
label: t("sidebar.autoScore"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "reward-exchange",
|
||||||
|
icon: <GiftOutlined />,
|
||||||
|
label: t("sidebar.rewardExchange"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reward-settings",
|
||||||
|
icon: <AppstoreAddOutlined />,
|
||||||
|
label: t("sidebar.rewardSettings"),
|
||||||
|
disabled: permission !== "admin",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "leaderboard",
|
key: "leaderboard",
|
||||||
icon: <UnorderedListOutlined />,
|
icon: <UnorderedListOutlined />,
|
||||||
|
|||||||
@@ -310,6 +310,8 @@
|
|||||||
"settlements": "Settlements",
|
"settlements": "Settlements",
|
||||||
"reasons": "Reasons",
|
"reasons": "Reasons",
|
||||||
"autoScore": "Auto Score",
|
"autoScore": "Auto Score",
|
||||||
|
"rewardExchange": "Reward Exchange",
|
||||||
|
"rewardSettings": "Reward Settings",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"remoteDb": "Remote Database",
|
"remoteDb": "Remote Database",
|
||||||
"refreshStatus": "Refresh Status",
|
"refreshStatus": "Refresh Status",
|
||||||
@@ -516,6 +518,46 @@
|
|||||||
"deleteFailed": "Delete failed",
|
"deleteFailed": "Delete failed",
|
||||||
"others": "Others"
|
"others": "Others"
|
||||||
},
|
},
|
||||||
|
"rewardExchange": {
|
||||||
|
"title": "Reward Exchange",
|
||||||
|
"enterMode": "Enter Exchange Mode",
|
||||||
|
"exitMode": "Exit Exchange Mode",
|
||||||
|
"modeHint": "Reward exchange mode is active. Click a student card to choose an available reward.",
|
||||||
|
"normalHint": "Current cards show original score. Enter mode to display redeemable reward points.",
|
||||||
|
"remainingRewardPoints": "Remaining Reward Points",
|
||||||
|
"currentScore": "Current Score",
|
||||||
|
"chooseRewardTitle": "Choose Reward for {{name}}",
|
||||||
|
"currentRewardPoints": "Current reward points: {{points}}",
|
||||||
|
"costLabel": "Cost points: {{points}}",
|
||||||
|
"redeemNow": "Redeem",
|
||||||
|
"noAffordableRewards": "No rewards can be redeemed now. Earn more points first.",
|
||||||
|
"redeemSuccess": "{{student}} redeemed \"{{reward}}\" and spent {{points}} points",
|
||||||
|
"redeemFailed": "Redeem failed",
|
||||||
|
"recordsTitle": "Redemption Records",
|
||||||
|
"recordStudent": "Student",
|
||||||
|
"recordReward": "Reward",
|
||||||
|
"recordCost": "Cost",
|
||||||
|
"recordTime": "Redeemed At"
|
||||||
|
},
|
||||||
|
"rewardSettings": {
|
||||||
|
"title": "Reward Settings",
|
||||||
|
"addReward": "Add Reward",
|
||||||
|
"addTitle": "Add Reward",
|
||||||
|
"editTitle": "Edit Reward",
|
||||||
|
"rewardName": "Reward Name",
|
||||||
|
"costPoints": "Cost Points",
|
||||||
|
"namePlaceholder": "e.g. Homework-Free Card",
|
||||||
|
"costPlaceholder": "Enter cost points",
|
||||||
|
"nameRequired": "Please enter reward name",
|
||||||
|
"costRequired": "Please enter positive integer points",
|
||||||
|
"createSuccess": "Reward created",
|
||||||
|
"createFailed": "Create reward failed",
|
||||||
|
"updateSuccess": "Reward updated",
|
||||||
|
"updateFailed": "Update reward failed",
|
||||||
|
"deleteSuccess": "Reward deleted",
|
||||||
|
"deleteFailed": "Delete reward failed",
|
||||||
|
"deleteConfirm": "Delete reward \"{{name}}\"?"
|
||||||
|
},
|
||||||
"leaderboard": {
|
"leaderboard": {
|
||||||
"title": "Points Leaderboard",
|
"title": "Points Leaderboard",
|
||||||
"rank": "Rank",
|
"rank": "Rank",
|
||||||
|
|||||||
@@ -310,6 +310,8 @@
|
|||||||
"settlements": "结算历史",
|
"settlements": "结算历史",
|
||||||
"reasons": "理由管理",
|
"reasons": "理由管理",
|
||||||
"autoScore": "自动加分",
|
"autoScore": "自动加分",
|
||||||
|
"rewardExchange": "奖励兑换",
|
||||||
|
"rewardSettings": "奖励设置",
|
||||||
"settings": "系统设置",
|
"settings": "系统设置",
|
||||||
"remoteDb": "远程数据库",
|
"remoteDb": "远程数据库",
|
||||||
"refreshStatus": "刷新状态",
|
"refreshStatus": "刷新状态",
|
||||||
@@ -516,6 +518,46 @@
|
|||||||
"deleteFailed": "删除失败",
|
"deleteFailed": "删除失败",
|
||||||
"others": "其他"
|
"others": "其他"
|
||||||
},
|
},
|
||||||
|
"rewardExchange": {
|
||||||
|
"title": "奖励兑换",
|
||||||
|
"enterMode": "进入奖励兑换模式",
|
||||||
|
"exitMode": "退出奖励兑换模式",
|
||||||
|
"modeHint": "当前为奖励兑换模式,点击学生卡可选择可兑换奖励。",
|
||||||
|
"normalHint": "当前显示学生原始积分,点击按钮后将显示可兑换积分。",
|
||||||
|
"remainingRewardPoints": "剩余兑换积分",
|
||||||
|
"currentScore": "当前积分",
|
||||||
|
"chooseRewardTitle": "为 {{name}} 选择奖励",
|
||||||
|
"currentRewardPoints": "当前可兑换积分:{{points}}",
|
||||||
|
"costLabel": "消耗积分:{{points}}",
|
||||||
|
"redeemNow": "立即兑换",
|
||||||
|
"noAffordableRewards": "当前没有可兑换的奖励,请先积累积分。",
|
||||||
|
"redeemSuccess": "{{student}} 成功兑换「{{reward}}」,消耗 {{points}} 分",
|
||||||
|
"redeemFailed": "兑换失败",
|
||||||
|
"recordsTitle": "兑换记录",
|
||||||
|
"recordStudent": "学生",
|
||||||
|
"recordReward": "奖励",
|
||||||
|
"recordCost": "消耗积分",
|
||||||
|
"recordTime": "兑换时间"
|
||||||
|
},
|
||||||
|
"rewardSettings": {
|
||||||
|
"title": "奖励设置",
|
||||||
|
"addReward": "添加奖励",
|
||||||
|
"addTitle": "添加奖励",
|
||||||
|
"editTitle": "编辑奖励",
|
||||||
|
"rewardName": "奖励名称",
|
||||||
|
"costPoints": "消耗积分",
|
||||||
|
"namePlaceholder": "例如:免作业券",
|
||||||
|
"costPlaceholder": "请输入消耗积分",
|
||||||
|
"nameRequired": "请输入奖励名称",
|
||||||
|
"costRequired": "请输入正整数积分",
|
||||||
|
"createSuccess": "奖励创建成功",
|
||||||
|
"createFailed": "奖励创建失败",
|
||||||
|
"updateSuccess": "奖励更新成功",
|
||||||
|
"updateFailed": "奖励更新失败",
|
||||||
|
"deleteSuccess": "奖励删除成功",
|
||||||
|
"deleteFailed": "奖励删除失败",
|
||||||
|
"deleteConfirm": "确认删除奖励「{{name}}」?"
|
||||||
|
},
|
||||||
"leaderboard": {
|
"leaderboard": {
|
||||||
"title": "积分排行榜",
|
"title": "积分排行榜",
|
||||||
"rank": "排名",
|
"rank": "排名",
|
||||||
|
|||||||
@@ -100,6 +100,28 @@ const api = {
|
|||||||
invoke("reason_update", { id, data }),
|
invoke("reason_update", { id, data }),
|
||||||
deleteReason: (id: number): Promise<{ success: boolean }> => invoke("reason_delete", { id }),
|
deleteReason: (id: number): Promise<{ success: boolean }> => invoke("reason_delete", { id }),
|
||||||
|
|
||||||
|
// DB - Reward
|
||||||
|
rewardSettingQuery: (): Promise<{ success: boolean; data: any[] }> => invoke("reward_setting_query"),
|
||||||
|
rewardSettingCreate: (data: {
|
||||||
|
name: string
|
||||||
|
cost_points: number
|
||||||
|
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||||
|
invoke("reward_setting_create", { data }),
|
||||||
|
rewardSettingUpdate: (id: number, data: any): Promise<{ success: boolean; message?: string }> =>
|
||||||
|
invoke("reward_setting_update", { id, data }),
|
||||||
|
rewardSettingDelete: (id: number): Promise<{ success: boolean; message?: string }> =>
|
||||||
|
invoke("reward_setting_delete", { id }),
|
||||||
|
rewardRedeem: (data: {
|
||||||
|
student_name: string
|
||||||
|
reward_id: number
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: { redemption_id: number; remaining_reward_points: number }
|
||||||
|
message?: string
|
||||||
|
}> => invoke("reward_redeem", { data }),
|
||||||
|
rewardRedemptionQuery: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> =>
|
||||||
|
invoke("reward_redemption_query", { params }),
|
||||||
|
|
||||||
// DB - Event
|
// DB - Event
|
||||||
queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> =>
|
queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> =>
|
||||||
invoke("event_query", { params }),
|
invoke("event_query", { params }),
|
||||||
|
|||||||
Reference in New Issue
Block a user