This commit is contained in:
Yukino_fox
2026-04-25 14:58:48 +08:00
14 changed files with 542 additions and 53 deletions
+58
View File
@@ -707,6 +707,63 @@ pub struct OnlineStatusResponse {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthStorageUsageResponse {
pub used_storage: i64,
pub used_storage_formatted: String,
pub total_storage: i64,
pub total_storage_formatted: String,
pub available_storage: i64,
pub available_storage_formatted: String,
pub percentage: f64,
pub file_count: i64,
}
#[tauri::command]
pub async fn oauth_get_storage_usage(
access_token: String,
platform_id: String,
user_id: String,
state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<OAuthStorageUsageResponse>, String> {
let state_guard = state.read();
let client = &state_guard.http_client;
let response = client
.get("https://appwrite.sectl.top/api/cloud/storage/usage")
.query(&[("client_id", platform_id), ("user_id", user_id)])
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
let error_message =
if let Ok(json_err) = serde_json::from_str::<serde_json::Value>(&error_text) {
json_err
.get("error_description")
.or_else(|| json_err.get("error"))
.and_then(|v| v.as_str())
.unwrap_or(&error_text)
.to_string()
} else {
error_text
};
return Ok(IpcResponse::error(&error_message));
}
let storage_usage: OAuthStorageUsageResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(IpcResponse::success(storage_usage))
}
#[tauri::command]
pub async fn oauth_report_online(
platform_id: String,
@@ -897,3 +954,4 @@ pub async fn oauth_refresh_access_token(
Ok(IpcResponse::success(token_response))
}
+2
View File
@@ -86,6 +86,7 @@ pub fn run() {
oauth_stop_callback_server,
oauth_report_online,
oauth_get_device_uuid,
oauth_get_storage_usage,
oauth_save_login_state,
oauth_load_login_state,
oauth_clear_login_state,
@@ -449,3 +450,4 @@ fn setup_window_events(app: &mut App) -> Result<(), Box<dyn std::error::Error>>
fn setup_window_events(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
+167 -1
View File
@@ -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);
}
+5 -5
View File
@@ -1107,15 +1107,15 @@ function MainContent(): React.JSX.Element {
display: "flex",
bottom: "2px",
left: "20px",
opacity: 0.6,
opacity: 0.75,
zIndex: 9999,
pointerEvents: "none",
}}
>
<p
style={{
color: "#df0000",
fontWeight: "bold",
color: "#ff000d",
fontWeight: "bolder",
fontSize: "14px",
pointerEvents: "none",
}}
@@ -1124,8 +1124,8 @@ function MainContent(): React.JSX.Element {
</p>
<p
style={{
color: currentTheme?.mode === "dark" ? "#fff" : "#44474b",
fontWeight: "bold",
color: currentTheme?.mode === "dark" ? "#fff" : "#3e3e3e",
fontWeight: "bolder",
fontSize: "13px",
paddingLeft: "5px",
}}
+25
View File
@@ -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);
}
+63 -3
View File
@@ -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")}
+117 -4
View File
@@ -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" }
@@ -1,5 +1,5 @@
import React, { useMemo } from "react"
import { Alert, Card } from "antd"
import { Card } from "antd"
import {
Builder,
Query,
@@ -46,12 +46,6 @@ export const TriggerRuleBuilder: React.FC<TriggerRuleBuilderProps> = ({
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
title={t("autoScore.whenTriggered")}
>
<Alert
type="info"
showIcon
style={{ marginBottom: "16px" }}
title={t("autoScore.triggerScheduleHint")}
/>
<div
className="query-builder-container"
style={{
+50
View File
@@ -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))}
/>
+18 -28
View File
@@ -206,7 +206,7 @@ export function ContentArea({
try {
const api = (window as any).api
if (!api?.oauthLoadLoginState) {
if (!api?.oauthLoadLoginState || !api?.oauthGetStorageUsage) {
setStorageUsage(null)
setStorageUsageError("当前环境不支持云用量查询")
return
@@ -229,37 +229,24 @@ export function ContentArea({
return
}
const params = new URLSearchParams({
client_id: platformId,
user_id: oauthState.user_id,
})
const response = await fetch(
`https://appwrite.sectl.top/api/cloud/storage/usage?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${oauthState.access_token}`,
},
}
const usageRes = await api.oauthGetStorageUsage(
oauthState.access_token,
platformId,
oauthState.user_id
)
const responseText = await response.text()
let payload: any = {}
try {
payload = responseText ? JSON.parse(responseText) : {}
} catch {
payload = {}
}
if (!response.ok) {
throw new Error(payload?.error_description || payload?.message || "获取云空间用量失败")
if (!usageRes?.success || !usageRes.data) {
throw new Error(usageRes?.message || "获取云空间用量失败")
}
setStorageUsage({
used_storage_formatted: String(payload?.used_storage_formatted || "0 B"),
total_storage_formatted: String(payload?.total_storage_formatted || "0 B"),
percentage: Number.isFinite(payload?.percentage) ? Number(payload.percentage) : 0,
file_count: Number.isFinite(payload?.file_count) ? Number(payload.file_count) : 0,
used_storage_formatted: String(usageRes.data.used_storage_formatted || "0 B"),
total_storage_formatted: String(usageRes.data.total_storage_formatted || "0 B"),
percentage: Number.isFinite(usageRes.data.percentage)
? Number(usageRes.data.percentage)
: 0,
file_count: Number.isFinite(usageRes.data.file_count)
? Number(usageRes.data.file_count)
: 0,
})
} catch (error: any) {
setStorageUsage(null)
@@ -627,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,
}}
>
@@ -697,3 +686,4 @@ export function ContentArea({
</Layout>
)
}
+12 -4
View File
@@ -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 &&
+1
View File
@@ -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",
+3 -1
View File
@@ -642,7 +642,8 @@
"recordStudent": "学生",
"recordReward": "奖励",
"recordCost": "消耗积分",
"recordTime": "兑换时间"
"recordTime": "兑换时间",
"rewards": "选择奖励"
},
"rewardSettings": {
"title": "奖励设置",
@@ -829,6 +830,7 @@
"actionAddTag": "添加标签",
"actionSettleScore": "结算分数",
"actionSettleScoreHint": "无需参数",
"rewardRequired": "请选择奖励",
"startAt": "开始时间",
"startAtPlaceholder": "请选择开始时间(留空立即开始)",
"startAtHint": "留空表示立即开始;设置后将以该时间为基准按间隔触发",
+20
View File
@@ -48,6 +48,7 @@ export interface autoScoreExecutionBatch {
affectedStudentNames: string[]
createdEventIds: number[]
addedStudentTagIds: number[]
rewardRedemptionIds?: number[]
scoreDeltaTotal: number
settled: boolean
rolledBack: boolean
@@ -565,6 +566,24 @@ const api = {
data: string
message?: string
}> => invoke("oauth_get_device_uuid"),
oauthGetStorageUsage: (
accessToken: string,
platformId: string,
userId: string
): Promise<{
success: boolean
data: {
used_storage: number
used_storage_formatted: string
total_storage: number
total_storage_formatted: string
available_storage: number
available_storage_formatted: string
percentage: number
file_count: number
}
message?: string
}> => invoke("oauth_get_storage_usage", { accessToken, platformId, userId }),
oauthSaveLoginState: (state: {
access_token: string
refresh_token: string
@@ -844,3 +863,4 @@ const api = {
export default api
export { api }