mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
feat: 自动化支持奖励兑换并修复主页滚动同步
This commit is contained in:
@@ -10,7 +10,9 @@ use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::time::{interval, Duration};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::entities::{score_events, student_tags, students, tags};
|
||||
use crate::db::entities::{
|
||||
reward_redemptions, reward_settings, score_events, student_tags, students, tags,
|
||||
};
|
||||
use crate::services::settings::{SettingsKey, SettingsValue};
|
||||
use crate::state::SafeAppState;
|
||||
|
||||
@@ -32,6 +34,19 @@ pub struct AutoScoreAction {
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RewardExchangeActionValue {
|
||||
#[serde(alias = "reward_id")]
|
||||
reward_id: i32,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "reward_name"
|
||||
)]
|
||||
reward_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AutoScoreExecutionConfig {
|
||||
@@ -63,6 +78,8 @@ pub struct AutoScoreExecutionBatch {
|
||||
pub affected_student_names: Vec<String>,
|
||||
pub created_event_ids: Vec<i32>,
|
||||
pub added_student_tag_ids: Vec<i32>,
|
||||
#[serde(default)]
|
||||
pub reward_redemption_ids: Vec<i32>,
|
||||
pub score_delta_total: i64,
|
||||
pub settled: bool,
|
||||
pub rolled_back: bool,
|
||||
@@ -123,6 +140,7 @@ pub struct AutoScoreRule {
|
||||
enum PlannedAction {
|
||||
AddScore(i32),
|
||||
AddTags(Vec<String>),
|
||||
RewardExchange(RewardExchangeActionValue),
|
||||
SettleScore,
|
||||
}
|
||||
|
||||
@@ -147,6 +165,7 @@ struct RuleExecutionStats {
|
||||
score_delta_total: i64,
|
||||
created_event_ids: Vec<i32>,
|
||||
added_student_tag_ids: Vec<i32>,
|
||||
reward_redemption_ids: Vec<i32>,
|
||||
settled: bool,
|
||||
}
|
||||
|
||||
@@ -390,6 +409,7 @@ impl AutoScoreService {
|
||||
affected_student_names: stats.affected_student_names.clone(),
|
||||
created_event_ids: stats.created_event_ids.clone(),
|
||||
added_student_tag_ids: stats.added_student_tag_ids.clone(),
|
||||
reward_redemption_ids: stats.reward_redemption_ids.clone(),
|
||||
score_delta_total: stats.score_delta_total,
|
||||
settled: stats.settled,
|
||||
rolled_back: false,
|
||||
@@ -941,11 +961,70 @@ fn normalize_action(action: AutoScoreAction) -> Result<AutoScoreAction, String>
|
||||
value: Some(value),
|
||||
})
|
||||
}
|
||||
"reward_exchange" => {
|
||||
let value = parse_reward_exchange_action_value(action.value.as_deref())
|
||||
.ok_or_else(|| "Reward exchange action requires a valid reward".to_string())?;
|
||||
|
||||
Ok(AutoScoreAction {
|
||||
event,
|
||||
value: Some(serialize_reward_exchange_action_value(&value)?),
|
||||
})
|
||||
}
|
||||
"settle_score" => Ok(AutoScoreAction { event, value: None }),
|
||||
_ => Err(format!("Unsupported action event: {}", event)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let trimmed = raw.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_reward_exchange_action_value(raw_value: Option<&str>) -> Option<RewardExchangeActionValue> {
|
||||
let raw_value = raw_value.map(str::trim)?;
|
||||
if raw_value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if raw_value.starts_with('{') {
|
||||
let parsed = serde_json::from_str::<RewardExchangeActionValue>(raw_value).ok()?;
|
||||
if parsed.reward_id <= 0 {
|
||||
return None;
|
||||
}
|
||||
return Some(RewardExchangeActionValue {
|
||||
reward_id: parsed.reward_id,
|
||||
reward_name: normalize_optional_string(parsed.reward_name),
|
||||
});
|
||||
}
|
||||
|
||||
let reward_id = raw_value.parse::<i32>().ok().filter(|value| *value > 0)?;
|
||||
Some(RewardExchangeActionValue {
|
||||
reward_id,
|
||||
reward_name: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize_reward_exchange_action_value(
|
||||
value: &RewardExchangeActionValue,
|
||||
) -> Result<String, String> {
|
||||
serde_json::to_string(&RewardExchangeActionValue {
|
||||
reward_id: value.reward_id,
|
||||
reward_name: normalize_optional_string(value.reward_name.clone()),
|
||||
})
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Failed to serialize reward exchange action value: {}",
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_string(value: String, field_name: &str) -> Result<String, String> {
|
||||
let normalized = value.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
@@ -1473,6 +1552,36 @@ pub async fn rollback_execution_batch(
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
for redemption_id in &batch.reward_redemption_ids {
|
||||
let redemption = reward_redemptions::Entity::find_by_id(*redemption_id)
|
||||
.one(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some(redemption) = redemption else {
|
||||
continue;
|
||||
};
|
||||
|
||||
reward_redemptions::Entity::delete_by_id(*redemption_id)
|
||||
.exec(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(student) = students::Entity::find()
|
||||
.filter(students::Column::Name.eq(redemption.student_name.clone()))
|
||||
.one(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
let mut student_active: students::ActiveModel = student.clone().into();
|
||||
student_active.reward_points = Set(student.reward_points + redemption.cost_points);
|
||||
student_active.updated_at = Set(now_iso());
|
||||
student_active
|
||||
.update(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
batch.rolled_back = true;
|
||||
@@ -1632,6 +1741,7 @@ pub async fn apply_offline_backfill(
|
||||
affected_student_names: stats.affected_student_names.clone(),
|
||||
created_event_ids: stats.created_event_ids.clone(),
|
||||
added_student_tag_ids: stats.added_student_tag_ids.clone(),
|
||||
reward_redemption_ids: stats.reward_redemption_ids.clone(),
|
||||
score_delta_total: stats.score_delta_total,
|
||||
settled: stats.settled,
|
||||
rolled_back: false,
|
||||
@@ -1728,6 +1838,23 @@ async fn execute_rule(
|
||||
tag_name_to_id.insert(tag_name, tag_id);
|
||||
}
|
||||
|
||||
let mut reward_settings_by_id = HashMap::new();
|
||||
for action in &planned_actions {
|
||||
if let PlannedAction::RewardExchange(reward_action) = action {
|
||||
if reward_settings_by_id.contains_key(&reward_action.reward_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let reward = reward_settings::Entity::find_by_id(reward_action.reward_id)
|
||||
.one(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Reward not found: {}", reward_action.reward_id))?;
|
||||
|
||||
reward_settings_by_id.insert(reward.id, reward);
|
||||
}
|
||||
}
|
||||
|
||||
for mut student in target_students {
|
||||
if mode == ExecutionMode::Normal {
|
||||
if !is_student_pass_cooldown(
|
||||
@@ -1803,6 +1930,40 @@ async fn execute_rule(
|
||||
}
|
||||
}
|
||||
}
|
||||
PlannedAction::RewardExchange(reward_action) => {
|
||||
let Some(reward) = reward_settings_by_id.get(&reward_action.reward_id) else {
|
||||
return Err(format!("Reward not found: {}", reward_action.reward_id));
|
||||
};
|
||||
if student.reward_points < reward.cost_points {
|
||||
continue;
|
||||
}
|
||||
|
||||
let now = now_iso();
|
||||
let redemption = reward_redemptions::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
uuid: Set(Uuid::new_v4().to_string()),
|
||||
student_name: Set(student.name.clone()),
|
||||
reward_id: Set(reward.id),
|
||||
reward_name: Set(reward.name.clone()),
|
||||
cost_points: Set(reward.cost_points),
|
||||
redeemed_at: Set(now.clone()),
|
||||
};
|
||||
let inserted_redemption =
|
||||
redemption.insert(&txn).await.map_err(|e| e.to_string())?;
|
||||
stats.reward_redemption_ids.push(inserted_redemption.id);
|
||||
|
||||
let remaining_reward_points = student.reward_points - reward.cost_points;
|
||||
let mut student_active: students::ActiveModel = student.clone().into();
|
||||
student_active.reward_points = Set(remaining_reward_points);
|
||||
student_active.updated_at = Set(now);
|
||||
student_active
|
||||
.update(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
student.reward_points = remaining_reward_points;
|
||||
touched = true;
|
||||
}
|
||||
PlannedAction::SettleScore => {}
|
||||
}
|
||||
}
|
||||
@@ -1840,6 +2001,11 @@ fn plan_actions(actions: &[AutoScoreAction]) -> Result<Vec<PlannedAction>, Strin
|
||||
}
|
||||
planned.push(PlannedAction::AddTags(tags));
|
||||
}
|
||||
"reward_exchange" => {
|
||||
let reward = parse_reward_exchange_action_value(action.value.as_deref())
|
||||
.ok_or_else(|| "Invalid reward_exchange value".to_string())?;
|
||||
planned.push(PlannedAction::RewardExchange(reward));
|
||||
}
|
||||
"settle_score" => {
|
||||
planned.push(PlannedAction::SettleScore);
|
||||
}
|
||||
|
||||
@@ -400,6 +400,31 @@ html.platform-macos #root {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ss-content-scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--ss-text-secondary) 55%, transparent) transparent;
|
||||
}
|
||||
|
||||
.ss-content-scroll-container::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.ss-content-scroll-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ss-content-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--ss-text-secondary) 55%, transparent);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.ss-content-scroll-container::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ss-route-page.is-subpage-enter {
|
||||
animation: ss-subpage-enter 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,22 @@ import React, { useEffect, useMemo } from "react"
|
||||
import { Button, Card, InputNumber, Select, Space } from "antd"
|
||||
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import type { ActionDraft, ActionEvent, AutoScoreTagOption } from "./AutoScoreUtils"
|
||||
import { createDefaultActionDraft } from "./AutoScoreUtils"
|
||||
import type {
|
||||
ActionDraft,
|
||||
ActionEvent,
|
||||
AutoScoreRewardOption,
|
||||
AutoScoreTagOption,
|
||||
} from "./AutoScoreUtils"
|
||||
import {
|
||||
createDefaultActionDraft,
|
||||
parseRewardActionValue,
|
||||
stringifyRewardActionValue,
|
||||
} from "./AutoScoreUtils"
|
||||
|
||||
interface ActionEditorProps {
|
||||
value: ActionDraft[]
|
||||
tagOptions: AutoScoreTagOption[]
|
||||
rewardOptions: AutoScoreRewardOption[]
|
||||
canEdit: boolean
|
||||
onChange: (nextDrafts: ActionDraft[]) => void
|
||||
}
|
||||
@@ -15,6 +25,7 @@ interface ActionEditorProps {
|
||||
export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
value,
|
||||
tagOptions,
|
||||
rewardOptions,
|
||||
canEdit,
|
||||
onChange,
|
||||
}) => {
|
||||
@@ -33,6 +44,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
{ value: "add_score", label: t("autoScore.actionAddScore") },
|
||||
{ value: "add_tag", label: t("autoScore.actionAddTag") },
|
||||
{ value: "settle_score", label: t("autoScore.actionSettleScore") },
|
||||
{ value: "reward_exchange", label: t("rewardExchange.title") },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
@@ -58,6 +70,31 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
return Array.from(optionMap.values())
|
||||
}, [safeValue, tagOptions])
|
||||
|
||||
const mergedRewardOptions = useMemo(() => {
|
||||
const optionMap = new Map(rewardOptions.map((option) => [option.value, option]))
|
||||
|
||||
for (const action of safeValue) {
|
||||
if (action.event !== "reward_exchange") continue
|
||||
const rewardValue = parseRewardActionValue(action.value)
|
||||
if (!rewardValue) continue
|
||||
|
||||
const optionValue = String(rewardValue.rewardId)
|
||||
if (!optionMap.has(optionValue)) {
|
||||
optionMap.set(optionValue, {
|
||||
label: rewardValue.rewardName || optionValue,
|
||||
value: optionValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(optionMap.values())
|
||||
}, [rewardOptions, safeValue])
|
||||
|
||||
const buildRewardDraftValue = (rewardId: string): string => {
|
||||
const rewardOption = mergedRewardOptions.find((option) => option.value === rewardId)
|
||||
return stringifyRewardActionValue(Number(rewardId), rewardOption?.label) || ""
|
||||
}
|
||||
|
||||
const updateAction = (id: string, patch: Partial<Omit<ActionDraft, "id">>) => {
|
||||
onChange(safeValue.map((item) => (item.id === id ? { ...item, ...patch } : item)))
|
||||
}
|
||||
@@ -87,7 +124,14 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
onChange={(event: ActionEvent) =>
|
||||
updateAction(action.id, {
|
||||
event,
|
||||
value: event === "add_score" ? "1" : event === "add_tag" ? [] : "",
|
||||
value:
|
||||
event === "add_score"
|
||||
? "1"
|
||||
: event === "add_tag"
|
||||
? []
|
||||
: event === "reward_exchange" && mergedRewardOptions.length > 0
|
||||
? buildRewardDraftValue(mergedRewardOptions[0].value)
|
||||
: "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -115,6 +159,22 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
options={mergedTagOptions}
|
||||
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
|
||||
/>
|
||||
) : action.event === "reward_exchange" ? (
|
||||
<Select
|
||||
showSearch
|
||||
style={{ minWidth: 260 }}
|
||||
placeholder={t("rewardExchange.rewards")}
|
||||
value={(() => {
|
||||
const rewardValue = parseRewardActionValue(action.value)
|
||||
return rewardValue ? String(rewardValue.rewardId) : undefined
|
||||
})()}
|
||||
disabled={!canEdit}
|
||||
optionFilterProp="label"
|
||||
options={mergedRewardOptions}
|
||||
onChange={(nextValue: string) =>
|
||||
updateAction(action.id, { value: buildRewardDraftValue(nextValue) })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ minWidth: 260, color: "var(--ss-text-secondary)" }}>
|
||||
{t("autoScore.actionSettleScoreHint")}
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface AutoScoreExecutionBatch {
|
||||
affectedStudentNames: string[]
|
||||
createdEventIds: number[]
|
||||
addedStudentTagIds: number[]
|
||||
rewardRedemptionIds?: number[]
|
||||
scoreDeltaTotal: number
|
||||
settled: boolean
|
||||
rolledBack: boolean
|
||||
@@ -58,20 +59,35 @@ export interface AutoScoreRule {
|
||||
lastExecuted?: string | null
|
||||
}
|
||||
|
||||
export type ActionEvent = "add_score" | "add_tag" | "settle_score"
|
||||
export type ActionEvent = "add_score" | "add_tag" | "settle_score" | "reward_exchange"
|
||||
|
||||
export interface AutoScoreTagOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface AutoScoreRewardOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ParsedRewardActionValue {
|
||||
rewardId: number
|
||||
rewardName?: string
|
||||
}
|
||||
|
||||
export interface ActionDraft {
|
||||
id: string
|
||||
event: ActionEvent
|
||||
value: string | string[]
|
||||
}
|
||||
|
||||
export type ActionDraftError = "action_required" | "score_required" | "tag_required" | null
|
||||
export type ActionDraftError =
|
||||
| "action_required"
|
||||
| "score_required"
|
||||
| "tag_required"
|
||||
| "reward_required"
|
||||
| null
|
||||
|
||||
const TRIGGER_FIELD_INTERVAL = "interval_minutes"
|
||||
const TRIGGER_FIELD_TAG = "student_tag"
|
||||
@@ -108,6 +124,11 @@ const toStringValue = (value: unknown): string => {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const normalizeOptionalString = (value: unknown): string | null => {
|
||||
const normalized = toStringValue(value).trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
const normalizeTagValues = (values: unknown[]): string[] => {
|
||||
const normalized = values.map((value) => toStringValue(value).trim()).filter(Boolean)
|
||||
|
||||
@@ -143,6 +164,66 @@ const stringifyTagValues = (values: string[]): string | null => {
|
||||
return JSON.stringify(normalized)
|
||||
}
|
||||
|
||||
export const parseRewardActionValue = (value: unknown): ParsedRewardActionValue | null => {
|
||||
if (value === null || value === undefined) return null
|
||||
|
||||
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
|
||||
return { rewardId: value }
|
||||
}
|
||||
|
||||
const rawValue = Array.isArray(value) ? value.find((item) => toStringValue(item).trim()) : value
|
||||
const text = toStringValue(rawValue).trim()
|
||||
if (!text) return null
|
||||
|
||||
if (text.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
rewardId?: unknown
|
||||
reward_id?: unknown
|
||||
rewardName?: unknown
|
||||
reward_name?: unknown
|
||||
}
|
||||
const rewardId = toFiniteNumber(parsed.rewardId ?? parsed.reward_id)
|
||||
if (rewardId === null || !Number.isInteger(rewardId) || rewardId <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rewardName =
|
||||
normalizeOptionalString(parsed.rewardName ?? parsed.reward_name) ?? undefined
|
||||
return rewardName ? { rewardId, rewardName } : { rewardId }
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
const rewardId = toFiniteNumber(text)
|
||||
if (rewardId === null || !Number.isInteger(rewardId) || rewardId <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { rewardId }
|
||||
}
|
||||
|
||||
export const stringifyRewardActionValue = (
|
||||
rewardId: unknown,
|
||||
rewardName?: unknown
|
||||
): string | null => {
|
||||
const parsedRewardId = toFiniteNumber(rewardId)
|
||||
if (parsedRewardId === null || !Number.isInteger(parsedRewardId) || parsedRewardId <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: ParsedRewardActionValue = {
|
||||
rewardId: parsedRewardId,
|
||||
}
|
||||
const normalizedRewardName = normalizeOptionalString(rewardName)
|
||||
if (normalizedRewardName) {
|
||||
payload.rewardName = normalizedRewardName
|
||||
}
|
||||
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => {
|
||||
if (trigger.event === "interval_time_passed") {
|
||||
const intervalValue = parseIntervalTriggerValue(trigger.value)
|
||||
@@ -520,7 +601,10 @@ export const createDefaultActionDraft = (): ActionDraft => ({
|
||||
})
|
||||
|
||||
const isActionEvent = (value: unknown): value is ActionEvent =>
|
||||
value === "add_score" || value === "add_tag" || value === "settle_score"
|
||||
value === "add_score" ||
|
||||
value === "add_tag" ||
|
||||
value === "settle_score" ||
|
||||
value === "reward_exchange"
|
||||
|
||||
export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined): ActionDraft[] => {
|
||||
if (!Array.isArray(drafts) || drafts.length === 0) {
|
||||
@@ -537,6 +621,15 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
||||
value:
|
||||
draft.event === "add_tag"
|
||||
? parseTagValues(draft.value)
|
||||
: draft.event === "reward_exchange"
|
||||
? (() => {
|
||||
const parsedValue = parseRewardActionValue(
|
||||
Array.isArray(draft.value) ? draft.value[0] : draft.value
|
||||
)
|
||||
return parsedValue
|
||||
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName) || ""
|
||||
: ""
|
||||
})()
|
||||
: draft.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||
@@ -553,7 +646,8 @@ export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
|
||||
if (
|
||||
action.event !== "add_score" &&
|
||||
action.event !== "add_tag" &&
|
||||
action.event !== "settle_score"
|
||||
action.event !== "settle_score" &&
|
||||
action.event !== "reward_exchange"
|
||||
) {
|
||||
return null
|
||||
}
|
||||
@@ -563,6 +657,13 @@ export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
|
||||
value:
|
||||
action.event === "add_tag"
|
||||
? parseTagValues(action.value)
|
||||
: action.event === "reward_exchange"
|
||||
? (() => {
|
||||
const parsedValue = parseRewardActionValue(action.value)
|
||||
return parsedValue
|
||||
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName) || ""
|
||||
: ""
|
||||
})()
|
||||
: action.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(action.value),
|
||||
@@ -597,6 +698,18 @@ export const actionDraftsToPayload = (
|
||||
continue
|
||||
}
|
||||
|
||||
if (draft.event === "reward_exchange") {
|
||||
const parsedValue = parseRewardActionValue(draft.value)
|
||||
const serializedValue = parsedValue
|
||||
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName)
|
||||
: null
|
||||
if (!serializedValue) {
|
||||
return { actions: [], error: "reward_required" }
|
||||
}
|
||||
actions.push({ event: draft.event, value: serializedValue })
|
||||
continue
|
||||
}
|
||||
|
||||
const serializedTagValue = stringifyTagValues(parseTagValues(draft.value))
|
||||
if (!serializedTagValue) {
|
||||
return { actions: [], error: "tag_required" }
|
||||
|
||||
@@ -36,11 +36,13 @@ import {
|
||||
normalizeTriggerTree,
|
||||
queryTreeToJson,
|
||||
normalizeActionDrafts,
|
||||
parseRewardActionValue,
|
||||
queryTreeToTriggers,
|
||||
triggerTreeJsonToQueryTree,
|
||||
type ActionDraft,
|
||||
type AutoScoreExecutionBatch,
|
||||
type AutoScoreExecutionConfig,
|
||||
type AutoScoreRewardOption,
|
||||
type AutoScoreRule,
|
||||
type AutoScoreTagOption,
|
||||
} from "./AutoScore/AutoScoreUtils"
|
||||
@@ -55,6 +57,12 @@ interface TagItem {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface RewardItem {
|
||||
id: number
|
||||
name: string
|
||||
cost_points: number
|
||||
}
|
||||
|
||||
interface RuleFormValues {
|
||||
name?: string
|
||||
studentNames?: string[]
|
||||
@@ -152,6 +160,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
const [form] = Form.useForm<RuleFormValues>()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [tags, setTags] = useState<TagItem[]>([])
|
||||
const [rewards, setRewards] = useState<RewardItem[]>([])
|
||||
|
||||
const tagOptions = useMemo<AutoScoreTagOption[]>(
|
||||
() =>
|
||||
@@ -161,6 +170,18 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
})),
|
||||
[tags]
|
||||
)
|
||||
const rewardOptions = useMemo<AutoScoreRewardOption[]>(
|
||||
() =>
|
||||
rewards.map((reward) => ({
|
||||
label: reward.name,
|
||||
value: String(reward.id),
|
||||
})),
|
||||
[rewards]
|
||||
)
|
||||
const rewardLabelById = useMemo(
|
||||
() => new Map(rewards.map((reward) => [reward.id, reward.name] as const)),
|
||||
[rewards]
|
||||
)
|
||||
|
||||
const triggerConfig = useMemo(
|
||||
() => createTriggerQueryConfig(t, tagOptions),
|
||||
@@ -288,6 +309,20 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRewards = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
|
||||
try {
|
||||
const res = await api.rewardSettingQuery()
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setRewards(res.data)
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRules = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
@@ -323,6 +358,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
useEffect(() => {
|
||||
if (!canEdit) return
|
||||
fetchTags().catch(() => void 0)
|
||||
fetchRewards().catch(() => void 0)
|
||||
fetchStudents().catch(() => void 0)
|
||||
fetchRules().catch(() => void 0)
|
||||
fetchBatches().catch(() => void 0)
|
||||
@@ -445,6 +481,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
messageApi.warning(t("autoScore.tagNamePlaceholder"))
|
||||
return
|
||||
}
|
||||
if (actionPayload.error === "reward_required") {
|
||||
messageApi.warning(t("autoScore.rewardRequired"))
|
||||
return
|
||||
}
|
||||
if (actionPayload.actions.length === 0) {
|
||||
messageApi.warning(t("autoScore.actionRequired"))
|
||||
return
|
||||
@@ -623,6 +663,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
if (action.event === "settle_score") {
|
||||
return `${t("autoScore.actionSettleScore")}: ${t("autoScore.actionSettleScoreHint")}`
|
||||
}
|
||||
if (action.event === "reward_exchange") {
|
||||
const rewardValue = parseRewardActionValue(action.value)
|
||||
const rewardLabel = rewardValue
|
||||
? rewardLabelById.get(rewardValue.rewardId) ||
|
||||
rewardValue.rewardName ||
|
||||
`#${rewardValue.rewardId}`
|
||||
: String(action.value || "").trim() || "-"
|
||||
return `${t("rewardExchange.title")}: ${rewardLabel}`
|
||||
}
|
||||
return `${action.event}: ${String(action.value || "").trim() || "-"}`
|
||||
}
|
||||
|
||||
@@ -905,6 +954,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
<ActionEditor
|
||||
value={actionDrafts}
|
||||
tagOptions={tagOptions}
|
||||
rewardOptions={rewardOptions}
|
||||
canEdit={canEdit}
|
||||
onChange={(nextDrafts) => setActionDrafts(normalizeActionDrafts(nextDrafts))}
|
||||
/>
|
||||
|
||||
@@ -614,10 +614,12 @@ export function ContentArea({
|
||||
</div>
|
||||
|
||||
<Content
|
||||
className="ss-content-scroll-container"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: isBoardPage ? "hidden" : "auto",
|
||||
overflowX: "hidden",
|
||||
background: "var(--ss-bg-color)",
|
||||
paddingBottom: bottomInset ? `${bottomInset}px` : 0,
|
||||
}}
|
||||
>
|
||||
|
||||
+12
-4
@@ -613,6 +613,12 @@ export const Home: React.FC<HomeProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const getScrollViewport = useCallback(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return null
|
||||
return container.closest(".ss-content-scroll-container") as HTMLElement | null
|
||||
}, [])
|
||||
|
||||
const openOperation = (student: student, sourceEl?: HTMLElement | null) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
@@ -2423,9 +2429,11 @@ export const Home: React.FC<HomeProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const scrollViewport = getScrollViewport()
|
||||
const refreshActiveByScroll = () => {
|
||||
let currentKey = groupedStudents[0]?.key || null
|
||||
const anchorY = 140
|
||||
const viewportTop = scrollViewport?.getBoundingClientRect().top ?? 0
|
||||
const anchorY = viewportTop + 96
|
||||
|
||||
groupedStudents.forEach((group) => {
|
||||
const el = groupRefs.current[group.key]
|
||||
@@ -2446,13 +2454,13 @@ export const Home: React.FC<HomeProps> = ({
|
||||
}
|
||||
|
||||
refreshActiveByScroll()
|
||||
window.addEventListener("scroll", refreshActiveByScroll, { passive: true })
|
||||
scrollViewport?.addEventListener("scroll", refreshActiveByScroll, { passive: true })
|
||||
window.addEventListener("resize", refreshActiveByScroll)
|
||||
return () => {
|
||||
window.removeEventListener("scroll", refreshActiveByScroll)
|
||||
scrollViewport?.removeEventListener("scroll", refreshActiveByScroll)
|
||||
window.removeEventListener("resize", refreshActiveByScroll)
|
||||
}
|
||||
}, [groupedStudents, quickNavLayout.itemSize, quickNavLayout.paddingY])
|
||||
}, [getScrollViewport, groupedStudents, quickNavLayout.itemSize, quickNavLayout.paddingY])
|
||||
|
||||
const shouldShowQuickNav =
|
||||
groupedStudents.length > 1 &&
|
||||
|
||||
@@ -831,6 +831,7 @@
|
||||
"actionAddTag": "Add Tag",
|
||||
"actionSettleScore": "Settle Score",
|
||||
"actionSettleScoreHint": "No parameters required",
|
||||
"rewardRequired": "Please select a reward",
|
||||
"startAt": "Start Time",
|
||||
"startAtPlaceholder": "Select start time (empty means start now)",
|
||||
"startAtHint": "Empty means start immediately; when set, interval will use this as base time",
|
||||
|
||||
@@ -642,7 +642,8 @@
|
||||
"recordStudent": "学生",
|
||||
"recordReward": "奖励",
|
||||
"recordCost": "消耗积分",
|
||||
"recordTime": "兑换时间"
|
||||
"recordTime": "兑换时间",
|
||||
"rewards": "选择奖励"
|
||||
},
|
||||
"rewardSettings": {
|
||||
"title": "奖励设置",
|
||||
@@ -829,6 +830,7 @@
|
||||
"actionAddTag": "添加标签",
|
||||
"actionSettleScore": "结算分数",
|
||||
"actionSettleScoreHint": "无需参数",
|
||||
"rewardRequired": "请选择奖励",
|
||||
"startAt": "开始时间",
|
||||
"startAtPlaceholder": "请选择开始时间(留空立即开始)",
|
||||
"startAtHint": "留空表示立即开始;设置后将以该时间为基准按间隔触发",
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface autoScoreExecutionBatch {
|
||||
affectedStudentNames: string[]
|
||||
createdEventIds: number[]
|
||||
addedStudentTagIds: number[]
|
||||
rewardRedemptionIds?: number[]
|
||||
scoreDeltaTotal: number
|
||||
settled: boolean
|
||||
rolledBack: boolean
|
||||
|
||||
Reference in New Issue
Block a user