From 388d935241eb169704b284afde51407314074349 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Thu, 23 Apr 2026 22:41:29 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BA=91=E7=A9=BA?= =?UTF-8?q?=E9=97=B4=E7=94=A8=E9=87=8FFailed=20to=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/auth.rs | 58 ++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 ++ src/components/ContentArea.tsx | 44 ++++++++++---------------- src/preload/types.ts | 19 +++++++++++ 4 files changed, 95 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 4ec851c..60c3d6b 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -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>>, +) -> Result, 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::(&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, @@ -898,3 +955,4 @@ pub async fn oauth_refresh_access_token( Ok(IpcResponse::success(token_response)) } + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4f4234..fd524a5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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> fn setup_window_events(_app: &mut App) -> Result<(), Box> { Ok(()) } + diff --git a/src/components/ContentArea.tsx b/src/components/ContentArea.tsx index 2f34d36..d111ce0 100644 --- a/src/components/ContentArea.tsx +++ b/src/components/ContentArea.tsx @@ -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) @@ -697,3 +684,4 @@ export function ContentArea({ ) } + diff --git a/src/preload/types.ts b/src/preload/types.ts index 8e6dac0..f8ca7fa 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -565,6 +565,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 @@ -846,3 +864,4 @@ const api = { export default api export { api } + From 0b39a1fffdbe0dd41503efa47301d9c6ab9f6302 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Fri, 24 Apr 2026 18:16:59 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E5=8C=96?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A5=96=E5=8A=B1=E5=85=91=E6=8D=A2=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=BB=E9=A1=B5=E6=BB=9A=E5=8A=A8=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/auto_score.rs | 168 ++++++++++++++++++++- src/assets/main.css | 25 +++ src/components/AutoScore/ActionEditor.tsx | 66 +++++++- src/components/AutoScore/AutoScoreUtils.ts | 121 ++++++++++++++- src/components/AutoScoreManager.tsx | 50 ++++++ src/components/ContentArea.tsx | 2 + src/components/Home.tsx | 16 +- src/i18n/locales/en-US.json | 1 + src/i18n/locales/zh-CN.json | 4 +- src/preload/types.ts | 1 + 10 files changed, 441 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index a78fa5b..cecd4af 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -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, } +#[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, +} + #[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, pub created_event_ids: Vec, pub added_student_tag_ids: Vec, + #[serde(default)] + pub reward_redemption_ids: Vec, 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), + RewardExchange(RewardExchangeActionValue), SettleScore, } @@ -147,6 +165,7 @@ struct RuleExecutionStats { score_delta_total: i64, created_event_ids: Vec, added_student_tag_ids: Vec, + reward_redemption_ids: Vec, 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 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) -> Option { + 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 { + 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::(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::().ok().filter(|value| *value > 0)?; + Some(RewardExchangeActionValue { + reward_id, + reward_name: None, + }) +} + +fn serialize_reward_exchange_action_value( + value: &RewardExchangeActionValue, +) -> Result { + 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 { 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, 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); } diff --git a/src/assets/main.css b/src/assets/main.css index fba91c1..d85344d 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -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); } diff --git a/src/components/AutoScore/ActionEditor.tsx b/src/components/AutoScore/ActionEditor.tsx index c1dd700..d563f2e 100644 --- a/src/components/AutoScore/ActionEditor.tsx +++ b/src/components/AutoScore/ActionEditor.tsx @@ -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 = ({ value, tagOptions, + rewardOptions, canEdit, onChange, }) => { @@ -33,6 +44,7 @@ export const ActionEditor: React.FC = ({ { 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 = ({ 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>) => { onChange(safeValue.map((item) => (item.id === id ? { ...item, ...patch } : item))) } @@ -87,7 +124,14 @@ export const ActionEditor: React.FC = ({ 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 = ({ options={mergedTagOptions} onChange={(nextValue) => updateAction(action.id, { value: nextValue })} /> + ) : action.event === "reward_exchange" ? ( +