From 21c1183b1d8db8b21c024a0a5c46803ec0d4fb7a Mon Sep 17 00:00:00 2001 From: Yukino_fox Date: Sun, 5 Jul 2026 10:28:36 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=AE=8C=E6=88=90=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=A4=9A=E7=BB=B4=E5=BA=A6=E4=BC=98=E5=8C=96=E4=B8=8E=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E8=BF=AD=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交包含多项改进: 1. 新增Vitest测试配置与相关测试用例 2. 完善Deep Link监听与OAuth回调支持 3. 修复并优化多个React组件的依赖与性能 4. 更新后端API域名与端点地址 5. 优化KV存储服务实现与类型定义 6. 调整用户信息展示与权限相关逻辑 7. 重构部分业务逻辑,使用useCallback优化性能 8. 更新依赖包与Cargo.lock依赖版本 --- .eslintcache | 2 +- package.json | 8 +- pnpm-lock.yaml | 301 ++++++++ public/about-content.json | 2 +- src-tauri/Cargo.lock | 4 +- src-tauri/src/commands/auth.rs | 18 +- src/App.tsx | 61 +- src/components/AutoScore/ActionEditor.tsx | 5 +- src/components/AutoScoreManager.tsx | 33 +- src/components/BoardManager.tsx | 115 +-- src/components/Home.tsx | 25 +- src/components/OAuth/OAuthCallback.tsx | 66 +- src/components/OAuth/OAuthLogin.tsx | 301 ++------ src/components/OOBE/OOBE.tsx | 27 +- src/components/ScoreSyncPanel.tsx | 8 +- src/components/SectlCloudStorageManager.tsx | 26 +- src/components/SectlKVStorageManager.tsx | 59 +- src/components/SectlSettingsPanel.tsx | 18 +- src/components/Settings.tsx | 128 ++-- src/components/SettlementHistory.tsx | 4 +- src/components/StudentManager.tsx | 30 +- src/components/ThemeQuickSettings.tsx | 2 +- src/hooks/useResponsive.ts | 7 +- src/preload/types.ts | 5 + src/services/sectl.ts | 7 +- src/services/sectlAuth.ts | 495 ++++++++----- src/services/sectlCloudStorage.test.ts | 167 +++++ src/services/sectlCloudStorage.ts | 783 ++++++++------------ src/services/sectlKVStorage.test.ts | 117 +++ src/services/sectlKVStorage.ts | 214 +++--- src/services/sectlNotification.test.ts | 122 +++ vitest.config.ts | 9 + 32 files changed, 1889 insertions(+), 1280 deletions(-) create mode 100644 src/services/sectlCloudStorage.test.ts create mode 100644 src/services/sectlKVStorage.test.ts create mode 100644 src/services/sectlNotification.test.ts create mode 100644 vitest.config.ts diff --git a/.eslintcache b/.eslintcache index 7dfb08d..ff87f54 100644 --- a/.eslintcache +++ b/.eslintcache @@ -1 +1 @@ -[{"E:\\Document\\Coding\\SecScore\\eslint.config.mjs":"1","E:\\Document\\Coding\\SecScore\\src\\App.tsx":"2","E:\\Document\\Coding\\SecScore\\src\\ClientContext.ts":"3","E:\\Document\\Coding\\SecScore\\src\\components\\BoardManager.tsx":"4","E:\\Document\\Coding\\SecScore\\src\\components\\ContentArea.tsx":"5","E:\\Document\\Coding\\SecScore\\src\\components\\EventHistory.tsx":"6","E:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx":"7","E:\\Document\\Coding\\SecScore\\src\\components\\Leaderboard.tsx":"8","E:\\Document\\Coding\\SecScore\\src\\components\\OOBE\\OOBE.tsx":"9","E:\\Document\\Coding\\SecScore\\src\\components\\OOBE\\OOBEBackground.tsx":"10","E:\\Document\\Coding\\SecScore\\src\\components\\ReasonManager.tsx":"11","E:\\Document\\Coding\\SecScore\\src\\components\\RewardExchange.tsx":"12","E:\\Document\\Coding\\SecScore\\src\\components\\RewardSettings.tsx":"13","E:\\Document\\Coding\\SecScore\\src\\components\\ScoreManager.tsx":"14","E:\\Document\\Coding\\SecScore\\src\\components\\Settings.tsx":"15","E:\\Document\\Coding\\SecScore\\src\\components\\SettlementHistory.tsx":"16","E:\\Document\\Coding\\SecScore\\src\\components\\Sidebar.tsx":"17","E:\\Document\\Coding\\SecScore\\src\\components\\StudentManager.tsx":"18","E:\\Document\\Coding\\SecScore\\src\\components\\TagEditorDialog.tsx":"19","E:\\Document\\Coding\\SecScore\\src\\components\\ThemeEditor.tsx":"20","E:\\Document\\Coding\\SecScore\\src\\components\\ThemeQuickSettings.tsx":"21","E:\\Document\\Coding\\SecScore\\src\\components\\TitleBar.tsx":"22","E:\\Document\\Coding\\SecScore\\src\\components\\Versions.tsx":"23","E:\\Document\\Coding\\SecScore\\src\\components\\WindowControls.tsx":"24","E:\\Document\\Coding\\SecScore\\src\\components\\Wizard.tsx":"25","E:\\Document\\Coding\\SecScore\\src\\contexts\\ServiceContext.tsx":"26","E:\\Document\\Coding\\SecScore\\src\\contexts\\ThemeContext.tsx":"27","E:\\Document\\Coding\\SecScore\\src\\contexts\\ThemeEditorContext.tsx":"28","E:\\Document\\Coding\\SecScore\\src\\env.d.ts":"29","E:\\Document\\Coding\\SecScore\\src\\hooks\\useResponsive.ts":"30","E:\\Document\\Coding\\SecScore\\src\\i18n\\index.ts":"31","E:\\Document\\Coding\\SecScore\\src\\main.tsx":"32","E:\\Document\\Coding\\SecScore\\src\\preload\\types.ts":"33","E:\\Document\\Coding\\SecScore\\src\\react-19-patch.ts":"34","E:\\Document\\Coding\\SecScore\\src\\services\\StudentService.ts":"35","E:\\Document\\Coding\\SecScore\\src\\shared\\kernel.ts":"36","E:\\Document\\Coding\\SecScore\\src\\shared\\mobileNavigation.ts":"37","E:\\Document\\Coding\\SecScore\\src\\utils\\color.ts":"38","E:\\Document\\Coding\\SecScore\\src\\utils\\studentAvatar.ts":"39","E:\\Document\\Coding\\SecScore\\src\\workers\\xlsxWorker.ts":"40","E:\\Document\\Coding\\SecScore\\vite.config.ts":"41","E:\\Document\\Coding\\SecScore\\src\\components\\AutoScore\\ActionEditor.tsx":"42","E:\\Document\\Coding\\SecScore\\src\\components\\AutoScore\\AutoScoreUtils.ts":"43","E:\\Document\\Coding\\SecScore\\src\\components\\AutoScore\\IntervalValueCodec.ts":"44","E:\\Document\\Coding\\SecScore\\src\\components\\AutoScore\\IntervalValueWidget.tsx":"45","E:\\Document\\Coding\\SecScore\\src\\components\\AutoScore\\TriggerRuleBuilder.tsx":"46","E:\\Document\\Coding\\SecScore\\src\\components\\AutoScoreManager.tsx":"47","E:\\Document\\Coding\\SecScore\\src\\components\\OAuth\\OAuthCallback.tsx":"48","E:\\Document\\Coding\\SecScore\\src\\components\\OAuth\\OAuthLogin.tsx":"49","E:\\Document\\Coding\\SecScore\\src\\components\\SectlCloudStorageManager.tsx":"50","E:\\Document\\Coding\\SecScore\\src\\components\\SectlLoginButton.tsx":"51","E:\\Document\\Coding\\SecScore\\src\\components\\SectlSettingsPanel.tsx":"52","E:\\Document\\Coding\\SecScore\\src\\contexts\\SectlContext.tsx":"53","E:\\Document\\Coding\\SecScore\\src\\services\\sectl.ts":"54","E:\\Document\\Coding\\SecScore\\src\\services\\sectlAuth.ts":"55","E:\\Document\\Coding\\SecScore\\src\\services\\sectlCloudStorage.ts":"56","E:\\Document\\Coding\\SecScore\\src\\services\\sectlKVStorage.ts":"57","E:\\Document\\Coding\\SecScore\\src\\services\\sectlNotification.ts":"58","E:\\Document\\Coding\\SecScore\\src\\views\\SectlCloudView.tsx":"59","E:\\Document\\Coding\\SecScore\\src\\components\\ScoreSyncPanel.tsx":"60","E:\\Document\\Coding\\SecScore\\src\\services\\scoreSyncService.ts":"61","E:\\Document\\Coding\\SecScore\\src\\shared\\fontFamily.ts":"62","E:\\Document\\Coding\\SecScore\\src\\components\\PluginManager.tsx":"63","E:\\Document\\Coding\\SecScore\\src\\components\\SectlKVStorageManager.tsx":"64","E:\\Document\\Coding\\SecScore\\src\\hooks\\useOAuthPersist.ts":"65","E:\\Document\\Coding\\SecScore\\src\\plugins\\runtime.ts":"66","E:\\Document\\Coding\\SecScore\\src\\components\\SectlCloudSyncPanel.tsx":"67","E:\\Document\\Coding\\SecScore\\src\\services\\sectlCloudSync.ts":"68"},{"size":1413,"mtime":1774084765386,"results":"69","hashOfConfig":"70"},{"size":43176,"mtime":1777704342104,"results":"71","hashOfConfig":"72"},{"size":124,"mtime":1777702123958,"results":"73","hashOfConfig":"72"},{"size":52571,"mtime":1777702308145,"results":"74","hashOfConfig":"72"},{"size":23220,"mtime":1777705100125,"results":"75","hashOfConfig":"72"},{"size":2044,"mtime":1777702308187,"results":"76","hashOfConfig":"72"},{"size":119865,"mtime":1777702308216,"results":"77","hashOfConfig":"72"},{"size":7976,"mtime":1777702308224,"results":"78","hashOfConfig":"72"},{"size":36271,"mtime":1777702308330,"results":"79","hashOfConfig":"72"},{"size":4203,"mtime":1777702308347,"results":"80","hashOfConfig":"72"},{"size":5983,"mtime":1777702308352,"results":"81","hashOfConfig":"72"},{"size":11972,"mtime":1777702308355,"results":"82","hashOfConfig":"72"},{"size":6151,"mtime":1777702308363,"results":"83","hashOfConfig":"72"},{"size":13165,"mtime":1777702308385,"results":"84","hashOfConfig":"72"},{"size":76475,"mtime":1777785687566,"results":"85","hashOfConfig":"72"},{"size":5950,"mtime":1777702308438,"results":"86","hashOfConfig":"72"},{"size":10638,"mtime":1777702123994,"results":"87","hashOfConfig":"72"},{"size":80155,"mtime":1777702308449,"results":"88","hashOfConfig":"72"},{"size":6331,"mtime":1777702308453,"results":"89","hashOfConfig":"72"},{"size":8079,"mtime":1777702308456,"results":"90","hashOfConfig":"72"},{"size":10954,"mtime":1777702308460,"results":"91","hashOfConfig":"72"},{"size":3858,"mtime":1777702308471,"results":"92","hashOfConfig":"72"},{"size":431,"mtime":1777702308475,"results":"93","hashOfConfig":"72"},{"size":2367,"mtime":1777702308481,"results":"94","hashOfConfig":"72"},{"size":1560,"mtime":1777702308484,"results":"95","hashOfConfig":"72"},{"size":362,"mtime":1777702308492,"results":"96","hashOfConfig":"72"},{"size":5985,"mtime":1777702308505,"results":"97","hashOfConfig":"72"},{"size":2824,"mtime":1777702308512,"results":"98","hashOfConfig":"72"},{"size":38,"mtime":1777702308522,"results":"99","hashOfConfig":"72"},{"size":2097,"mtime":1777702308538,"results":"100","hashOfConfig":"72"},{"size":2771,"mtime":1777702308548,"results":"101","hashOfConfig":"72"},{"size":6919,"mtime":1777702124001,"results":"102","hashOfConfig":"72"},{"size":27739,"mtime":1777794839138,"results":"103","hashOfConfig":"72"},{"size":1150,"mtime":1777702308667,"results":"104","hashOfConfig":"72"},{"size":667,"mtime":1777702308678,"results":"105","hashOfConfig":"72"},{"size":3061,"mtime":1777702308733,"results":"106","hashOfConfig":"72"},{"size":1909,"mtime":1777702308763,"results":"107","hashOfConfig":"72"},{"size":3256,"mtime":1777702308776,"results":"108","hashOfConfig":"72"},{"size":1115,"mtime":1777702308782,"results":"109","hashOfConfig":"72"},{"size":947,"mtime":1777702308795,"results":"110","hashOfConfig":"72"},{"size":709,"mtime":1777702124174,"results":"111","hashOfConfig":"72"},{"size":6870,"mtime":1777702307924,"results":"112","hashOfConfig":"72"},{"size":20459,"mtime":1777705095081,"results":"113","hashOfConfig":"72"},{"size":3739,"mtime":1777702307930,"results":"114","hashOfConfig":"72"},{"size":3033,"mtime":1777702307946,"results":"115","hashOfConfig":"72"},{"size":1749,"mtime":1777702307956,"results":"116","hashOfConfig":"72"},{"size":31549,"mtime":1777702307991,"results":"117","hashOfConfig":"72"},{"size":1047,"mtime":1777702308232,"results":"118","hashOfConfig":"72"},{"size":10072,"mtime":1777794803195,"results":"119","hashOfConfig":"72"},{"size":8410,"mtime":1777702308404,"results":"120","hashOfConfig":"72"},{"size":1661,"mtime":1777702308420,"results":"121","hashOfConfig":"72"},{"size":5070,"mtime":1777702308429,"results":"122","hashOfConfig":"72"},{"size":3773,"mtime":1777702308490,"results":"123","hashOfConfig":"72"},{"size":683,"mtime":1777702308692,"results":"124","hashOfConfig":"72"},{"size":9500,"mtime":1777702308704,"results":"125","hashOfConfig":"72"},{"size":14984,"mtime":1777702308710,"results":"126","hashOfConfig":"72"},{"size":3980,"mtime":1777707095408,"results":"127","hashOfConfig":"72"},{"size":5437,"mtime":1777702308720,"results":"128","hashOfConfig":"72"},{"size":429,"mtime":1777702308789,"results":"129","hashOfConfig":"72"},{"size":8415,"mtime":1777702308392,"results":"130","hashOfConfig":"72"},{"size":7065,"mtime":1777706944006,"results":"131","hashOfConfig":"72"},{"size":1359,"mtime":1777702308724,"results":"132","hashOfConfig":"72"},{"size":9135,"mtime":1777702123981,"results":"133","hashOfConfig":"72"},{"size":12119,"mtime":1777702308414,"results":"134","hashOfConfig":"72"},{"size":3533,"mtime":1777702308531,"results":"135","hashOfConfig":"72"},{"size":6871,"mtime":1777702308658,"results":"136","hashOfConfig":"72"},{"size":12679,"mtime":1777705108862,"results":"137","hashOfConfig":"72"},{"size":14288,"mtime":1777705131157,"results":"138","hashOfConfig":"72"},{"filePath":"139","messages":"140","suppressedMessages":"141","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"oyxevq",{"filePath":"142","messages":"143","suppressedMessages":"144","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"f6a32r",{"filePath":"145","messages":"146","suppressedMessages":"147","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"148","messages":"149","suppressedMessages":"150","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"151","messages":"152","suppressedMessages":"153","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"154","messages":"155","suppressedMessages":"156","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"157","messages":"158","suppressedMessages":"159","errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"160","messages":"161","suppressedMessages":"162","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"163","messages":"164","suppressedMessages":"165","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"166","messages":"167","suppressedMessages":"168","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"169","messages":"170","suppressedMessages":"171","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"172","messages":"173","suppressedMessages":"174","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"175","messages":"176","suppressedMessages":"177","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"178","messages":"179","suppressedMessages":"180","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"181","messages":"182","suppressedMessages":"183","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"184","messages":"185","suppressedMessages":"186","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"187","messages":"188","suppressedMessages":"189","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"190","messages":"191","suppressedMessages":"192","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"193","messages":"194","suppressedMessages":"195","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"196","messages":"197","suppressedMessages":"198","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"199","messages":"200","suppressedMessages":"201","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"202","messages":"203","suppressedMessages":"204","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"205","messages":"206","suppressedMessages":"207","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"208","messages":"209","suppressedMessages":"210","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"211","messages":"212","suppressedMessages":"213","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"214","messages":"215","suppressedMessages":"216","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"217","messages":"218","suppressedMessages":"219","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"220","messages":"221","suppressedMessages":"222","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"223","messages":"224","suppressedMessages":"225","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"226","messages":"227","suppressedMessages":"228","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"229","messages":"230","suppressedMessages":"231","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"232","messages":"233","suppressedMessages":"234","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"235","messages":"236","suppressedMessages":"237","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"238","messages":"239","suppressedMessages":"240","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"241","messages":"242","suppressedMessages":"243","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"244","messages":"245","suppressedMessages":"246","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"247","messages":"248","suppressedMessages":"249","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"250","messages":"251","suppressedMessages":"252","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"253","messages":"254","suppressedMessages":"255","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"256","messages":"257","suppressedMessages":"258","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"259","messages":"260","suppressedMessages":"261","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"262","messages":"263","suppressedMessages":"264","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"265","messages":"266","suppressedMessages":"267","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"268","messages":"269","suppressedMessages":"270","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"271","messages":"272","suppressedMessages":"273","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"274","messages":"275","suppressedMessages":"276","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"277","messages":"278","suppressedMessages":"279","errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"280","messages":"281","suppressedMessages":"282","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"283","messages":"284","suppressedMessages":"285","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"286","messages":"287","suppressedMessages":"288","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"289","messages":"290","suppressedMessages":"291","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"292","messages":"293","suppressedMessages":"294","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"295","messages":"296","suppressedMessages":"297","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"298","messages":"299","suppressedMessages":"300","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"301","messages":"302","suppressedMessages":"303","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"304","messages":"305","suppressedMessages":"306","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"307","messages":"308","suppressedMessages":"309","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"310","messages":"311","suppressedMessages":"312","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"313","messages":"314","suppressedMessages":"315","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"316","messages":"317","suppressedMessages":"318","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"319","messages":"320","suppressedMessages":"321","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"322","messages":"323","suppressedMessages":"324","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"325","messages":"326","suppressedMessages":"327","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"328","messages":"329","suppressedMessages":"330","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"331","messages":"332","suppressedMessages":"333","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"334","messages":"335","suppressedMessages":"336","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"337","messages":"338","suppressedMessages":"339","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"340","messages":"341","suppressedMessages":"342","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"E:\\Document\\Coding\\SecScore\\eslint.config.mjs",[],[],"E:\\Document\\Coding\\SecScore\\src\\App.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\ClientContext.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\BoardManager.tsx",["343","344"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ContentArea.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\EventHistory.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx",["345","346","347"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Leaderboard.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OOBE\\OOBE.tsx",["348","349"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OOBE\\OOBEBackground.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ReasonManager.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\RewardExchange.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\RewardSettings.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ScoreManager.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Settings.tsx",["350"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\SettlementHistory.tsx",["351","352"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Sidebar.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\StudentManager.tsx",["353"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\TagEditorDialog.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ThemeEditor.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ThemeQuickSettings.tsx",["354"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\TitleBar.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Versions.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\WindowControls.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Wizard.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\contexts\\ServiceContext.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\contexts\\ThemeContext.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\contexts\\ThemeEditorContext.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\env.d.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\hooks\\useResponsive.ts",["355"],[],"E:\\Document\\Coding\\SecScore\\src\\i18n\\index.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\main.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\preload\\types.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\react-19-patch.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\services\\StudentService.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\shared\\kernel.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\shared\\mobileNavigation.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\utils\\color.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\utils\\studentAvatar.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\workers\\xlsxWorker.ts",[],[],"E:\\Document\\Coding\\SecScore\\vite.config.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\AutoScore\\ActionEditor.tsx",["356","357"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\AutoScore\\AutoScoreUtils.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\AutoScore\\IntervalValueCodec.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\AutoScore\\IntervalValueWidget.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\AutoScore\\TriggerRuleBuilder.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\AutoScoreManager.tsx",["358","359","360"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OAuth\\OAuthCallback.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OAuth\\OAuthLogin.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\SectlCloudStorageManager.tsx",["361"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\SectlLoginButton.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\SectlSettingsPanel.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\contexts\\SectlContext.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\services\\sectl.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\services\\sectlAuth.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\services\\sectlCloudStorage.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\services\\sectlKVStorage.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\services\\sectlNotification.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\views\\SectlCloudView.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ScoreSyncPanel.tsx",["362"],[],"E:\\Document\\Coding\\SecScore\\src\\services\\scoreSyncService.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\shared\\fontFamily.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\PluginManager.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\SectlKVStorageManager.tsx",["363"],[],"E:\\Document\\Coding\\SecScore\\src\\hooks\\useOAuthPersist.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\plugins\\runtime.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\SectlCloudSyncPanel.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\services\\sectlCloudSync.ts",[],[],{"ruleId":"364","severity":1,"message":"365","line":635,"column":6,"nodeType":"366","endLine":635,"endColumn":44,"suggestions":"367"},{"ruleId":"364","severity":1,"message":"365","line":643,"column":6,"nodeType":"366","endLine":643,"endColumn":59,"suggestions":"368"},{"ruleId":"364","severity":1,"message":"369","line":556,"column":6,"nodeType":"366","endLine":556,"endColumn":76,"suggestions":"370"},{"ruleId":"364","severity":1,"message":"371","line":580,"column":6,"nodeType":"366","endLine":580,"endColumn":15,"suggestions":"372"},{"ruleId":"364","severity":1,"message":"373","line":2424,"column":6,"nodeType":"366","endLine":2424,"endColumn":8,"suggestions":"374"},{"ruleId":"364","severity":1,"message":"375","line":160,"column":6,"nodeType":"366","endLine":160,"endColumn":20,"suggestions":"376"},{"ruleId":"364","severity":1,"message":"377","line":334,"column":5,"nodeType":"366","endLine":334,"endColumn":27,"suggestions":"378"},{"ruleId":"364","severity":1,"message":"379","line":567,"column":6,"nodeType":"366","endLine":567,"endColumn":8,"suggestions":"380"},{"ruleId":"364","severity":1,"message":"381","line":54,"column":6,"nodeType":"366","endLine":54,"endColumn":18,"suggestions":"382"},{"ruleId":"364","severity":1,"message":"371","line":106,"column":5,"nodeType":"366","endLine":106,"endColumn":7,"suggestions":"383"},{"ruleId":"364","severity":1,"message":"384","line":1373,"column":6,"nodeType":"366","endLine":1373,"endColumn":50,"suggestions":"385"},{"ruleId":"364","severity":1,"message":"375","line":100,"column":6,"nodeType":"366","endLine":100,"endColumn":20,"suggestions":"386"},{"ruleId":"364","severity":1,"message":"387","line":25,"column":9,"nodeType":"388","endLine":25,"endColumn":70},{"ruleId":"364","severity":1,"message":"389","line":34,"column":9,"nodeType":"388","endLine":34,"endColumn":63},{"ruleId":"364","severity":1,"message":"390","line":34,"column":9,"nodeType":"388","endLine":34,"endColumn":63},{"ruleId":"364","severity":1,"message":"371","line":188,"column":5,"nodeType":"366","endLine":188,"endColumn":55,"suggestions":"391"},{"ruleId":"364","severity":1,"message":"392","line":365,"column":6,"nodeType":"366","endLine":365,"endColumn":15,"suggestions":"393"},{"ruleId":"364","severity":1,"message":"394","line":429,"column":6,"nodeType":"366","endLine":429,"endColumn":55,"suggestions":"395"},{"ruleId":"364","severity":1,"message":"396","line":79,"column":6,"nodeType":"366","endLine":79,"endColumn":23,"suggestions":"397"},{"ruleId":"364","severity":1,"message":"398","line":69,"column":6,"nodeType":"366","endLine":69,"endColumn":23,"suggestions":"399"},{"ruleId":"364","severity":1,"message":"400","line":99,"column":6,"nodeType":"366","endLine":99,"endColumn":31,"suggestions":"401"},"react-hooks/exhaustive-deps","React Hook useEffect has a missing dependency: 'activeBoard'. Either include it or remove the dependency array.","ArrayExpression",["402"],["403"],"React Hook useMemo has an unnecessary dependency: 'layoutType'. Either exclude it or remove the dependency array.",["404"],"React Hook useMemo has a missing dependency: 't'. Either include it or remove the dependency array.",["405"],"React Hook useEffect has missing dependencies: 'onGlobalMouseMove' and 'onGlobalMouseUp'. Either include them or remove the dependency array.",["406"],"React Hook useEffect has a missing dependency: 't'. Either include it or remove the dependency array.",["407"],"React Hook useCallback has missing dependencies: 'showOobeMessage' and 't'. Either include them or remove the dependency array.",["408"],"React Hook useEffect has a missing dependency: 'loadAll'. Either include it or remove the dependency array.",["409"],"React Hook useCallback has a missing dependency: 't'. Either include it or remove the dependency array.",["410"],["411"],"React Hook useMemo has missing dependencies: 'handleDelete', 'handleOpenAvatarEditor', 'handleOpenGroupEditor', and 'handleOpenTagEditor'. Either include them or remove the dependency array.",["412"],["413"],"The 'breakpoints' object makes the dependencies of useEffect Hook (at line 49) change on every render. Move it inside the useEffect callback. Alternatively, wrap the initialization of 'breakpoints' in its own useMemo() Hook.","VariableDeclarator","The 'safeValue' conditional could make the dependencies of useMemo Hook (at line 71) change on every render. To fix this, wrap the initialization of 'safeValue' in its own useMemo() Hook.","The 'safeValue' conditional could make the dependencies of useMemo Hook (at line 91) change on every render. To fix this, wrap the initialization of 'safeValue' in its own useMemo() Hook.",["414"],"React Hook useEffect has missing dependencies: 'fetchBatches', 'fetchRewards', 'fetchRules', 'fetchStudents', and 'fetchTags'. Either include them or remove the dependency array.",["415"],"React Hook useEffect has missing dependencies: 'fetchBatches' and 'fetchRules'. Either include them or remove the dependency array.",["416"],"React Hook useEffect has missing dependencies: 'loadFiles' and 'loadStorageUsage'. Either include them or remove the dependency array.",["417"],"React Hook useEffect has a missing dependency: 'checkCloudStatus'. Either include it or remove the dependency array.",["418"],"React Hook useEffect has a missing dependency: 'loadKVList'. Either include it or remove the dependency array.",["419"],{"desc":"420","fix":"421"},{"desc":"422","fix":"423"},{"desc":"424","fix":"425"},{"desc":"426","fix":"427"},{"desc":"428","fix":"429"},{"desc":"430","fix":"431"},{"desc":"432","fix":"433"},{"desc":"434","fix":"435"},{"desc":"436","fix":"437"},{"desc":"438","fix":"439"},{"desc":"440","fix":"441"},{"desc":"430","fix":"442"},{"desc":"443","fix":"444"},{"desc":"445","fix":"446"},{"desc":"447","fix":"448"},{"desc":"449","fix":"450"},{"desc":"451","fix":"452"},{"desc":"453","fix":"454"},"Update the dependencies array to be: [activeBoard, activeBoard.id, activeBoard.layout]",{"range":"455","text":"456"},"Update the dependencies array to be: [activeBoard, activeBoard.id, listConfigSignature, runAllInBoard]",{"range":"457","text":"458"},"Update the dependencies array to be: [sortedStudents, sortType, searchKeyword, getGroupName, t]",{"range":"459","text":"460"},"Update the dependencies array to be: [reasons, t]",{"range":"461","text":"462"},"Update the dependencies array to be: [onGlobalMouseMove, onGlobalMouseUp]",{"range":"463","text":"464"},"Update the dependencies array to be: [currentTheme, t]",{"range":"465","text":"466"},"Update the dependencies array to be: [students, showOobeMessage, t]",{"range":"467","text":"468"},"Update the dependencies array to be: [loadAll]",{"range":"469","text":"470"},"Update the dependencies array to be: [messageApi, t]",{"range":"471","text":"472"},"Update the dependencies array to be: [t]",{"range":"473","text":"474"},"Update the dependencies array to be: [t, isMobile, canEdit, handleOpenTagEditor, handleOpenGroupEditor, handleOpenAvatarEditor, handleDelete]",{"range":"475","text":"476"},{"range":"477","text":"466"},"Update the dependencies array to be: [t, tagOptions]",{"range":"478","text":"479"},"Update the dependencies array to be: [canEdit, fetchBatches, fetchRewards, fetchRules, fetchStudents, fetchTags]",{"range":"480","text":"481"},"Update the dependencies array to be: [backfillPrompted, canEdit, fetchBatches, fetchRules, messageApi, rules, t]",{"range":"482","text":"483"},"Update the dependencies array to be: [isAuthenticated, loadFiles, loadStorageUsage]",{"range":"484","text":"485"},"Update the dependencies array to be: [checkCloudStatus, isAuthenticated]",{"range":"486","text":"487"},"Update the dependencies array to be: [isAuthenticated, loadKVList, prefix]",{"range":"488","text":"489"},[18439,18477],"[activeBoard, activeBoard.id, activeBoard.layout]",[18687,18740],"[activeBoard, activeBoard.id, listConfigSignature, runAllInBoard]",[17285,17355],"[sortedStudents, sortType, searchKeyword, getGroupName, t]",[18130,18139],"[reasons, t]",[77920,77922],"[onGlobalMouseMove, onGlobalMouseUp]",[4674,4688],"[currentTheme, t]",[10389,10411],"[students, showOobeMessage, t]",[17999,18001],"[loadAll]",[1697,1709],"[messageApi, t]",[3343,3345],"[t]",[46442,46486],"[t, isMobile, canEdit, handleOpenTagEditor, handleOpenGroupEditor, handleOpenAvatarEditor, handleDelete]",[3185,3199],[4755,4805],"[t, tagOptions]",[10028,10037],"[canEdit, fetchBatches, fetchRewards, fetchRules, fetchStudents, fetchTags]",[12200,12249],"[backfillPrompted, canEdit, fetchBatches, fetchRules, messageApi, rules, t]",[1826,1843],"[isAuthenticated, loadFiles, loadStorageUsage]",[1783,1800],"[checkCloudStatus, isAuthenticated]",[2216,2241],"[isAuthenticated, loadKVList, prefix]"] \ No newline at end of file +[{"E:\\Documents\\Coding\\SecScore\\eslint.config.mjs":"1","E:\\Documents\\Coding\\SecScore\\src\\App.tsx":"2","E:\\Documents\\Coding\\SecScore\\src\\ClientContext.ts":"3","E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScore\\ActionEditor.tsx":"4","E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScore\\AutoScoreUtils.ts":"5","E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScore\\IntervalValueCodec.ts":"6","E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScore\\IntervalValueWidget.tsx":"7","E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScore\\TriggerRuleBuilder.tsx":"8","E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScoreManager.tsx":"9","E:\\Documents\\Coding\\SecScore\\src\\components\\BoardManager.tsx":"10","E:\\Documents\\Coding\\SecScore\\src\\components\\ContentArea.tsx":"11","E:\\Documents\\Coding\\SecScore\\src\\components\\EventHistory.tsx":"12","E:\\Documents\\Coding\\SecScore\\src\\components\\Home.tsx":"13","E:\\Documents\\Coding\\SecScore\\src\\components\\Leaderboard.tsx":"14","E:\\Documents\\Coding\\SecScore\\src\\components\\OAuth\\OAuthCallback.tsx":"15","E:\\Documents\\Coding\\SecScore\\src\\components\\OAuth\\OAuthLogin.tsx":"16","E:\\Documents\\Coding\\SecScore\\src\\components\\OOBE\\OOBE.tsx":"17","E:\\Documents\\Coding\\SecScore\\src\\components\\OOBE\\OOBEBackground.tsx":"18","E:\\Documents\\Coding\\SecScore\\src\\components\\PluginManager.tsx":"19","E:\\Documents\\Coding\\SecScore\\src\\components\\ReasonManager.tsx":"20","E:\\Documents\\Coding\\SecScore\\src\\components\\RewardExchange.tsx":"21","E:\\Documents\\Coding\\SecScore\\src\\components\\RewardSettings.tsx":"22","E:\\Documents\\Coding\\SecScore\\src\\components\\ScoreManager.tsx":"23","E:\\Documents\\Coding\\SecScore\\src\\components\\ScoreSyncPanel.tsx":"24","E:\\Documents\\Coding\\SecScore\\src\\components\\SectlCloudStorageManager.tsx":"25","E:\\Documents\\Coding\\SecScore\\src\\components\\SectlCloudSyncPanel.tsx":"26","E:\\Documents\\Coding\\SecScore\\src\\components\\SectlKVStorageManager.tsx":"27","E:\\Documents\\Coding\\SecScore\\src\\components\\SectlLoginButton.tsx":"28","E:\\Documents\\Coding\\SecScore\\src\\components\\SectlSettingsPanel.tsx":"29","E:\\Documents\\Coding\\SecScore\\src\\components\\Settings.tsx":"30","E:\\Documents\\Coding\\SecScore\\src\\components\\SettlementHistory.tsx":"31","E:\\Documents\\Coding\\SecScore\\src\\components\\Sidebar.tsx":"32","E:\\Documents\\Coding\\SecScore\\src\\components\\StudentManager.tsx":"33","E:\\Documents\\Coding\\SecScore\\src\\components\\TagEditorDialog.tsx":"34","E:\\Documents\\Coding\\SecScore\\src\\components\\ThemeEditor.tsx":"35","E:\\Documents\\Coding\\SecScore\\src\\components\\ThemeQuickSettings.tsx":"36","E:\\Documents\\Coding\\SecScore\\src\\components\\TitleBar.tsx":"37","E:\\Documents\\Coding\\SecScore\\src\\components\\Versions.tsx":"38","E:\\Documents\\Coding\\SecScore\\src\\components\\WindowControls.tsx":"39","E:\\Documents\\Coding\\SecScore\\src\\components\\Wizard.tsx":"40","E:\\Documents\\Coding\\SecScore\\src\\contexts\\SectlContext.tsx":"41","E:\\Documents\\Coding\\SecScore\\src\\contexts\\ServiceContext.tsx":"42","E:\\Documents\\Coding\\SecScore\\src\\contexts\\ThemeContext.tsx":"43","E:\\Documents\\Coding\\SecScore\\src\\contexts\\ThemeEditorContext.tsx":"44","E:\\Documents\\Coding\\SecScore\\src\\env.d.ts":"45","E:\\Documents\\Coding\\SecScore\\src\\hooks\\useOAuthPersist.ts":"46","E:\\Documents\\Coding\\SecScore\\src\\hooks\\useResponsive.ts":"47","E:\\Documents\\Coding\\SecScore\\src\\i18n\\index.ts":"48","E:\\Documents\\Coding\\SecScore\\src\\main.tsx":"49","E:\\Documents\\Coding\\SecScore\\src\\plugins\\runtime.ts":"50","E:\\Documents\\Coding\\SecScore\\src\\preload\\types.ts":"51","E:\\Documents\\Coding\\SecScore\\src\\react-19-patch.ts":"52","E:\\Documents\\Coding\\SecScore\\src\\services\\scoreSyncService.ts":"53","E:\\Documents\\Coding\\SecScore\\src\\services\\sectl.ts":"54","E:\\Documents\\Coding\\SecScore\\src\\services\\sectlAuth.ts":"55","E:\\Documents\\Coding\\SecScore\\src\\services\\sectlCloudStorage.test.ts":"56","E:\\Documents\\Coding\\SecScore\\src\\services\\sectlCloudStorage.ts":"57","E:\\Documents\\Coding\\SecScore\\src\\services\\sectlCloudSync.ts":"58","E:\\Documents\\Coding\\SecScore\\src\\services\\sectlKVStorage.test.ts":"59","E:\\Documents\\Coding\\SecScore\\src\\services\\sectlKVStorage.ts":"60","E:\\Documents\\Coding\\SecScore\\src\\services\\sectlNotification.test.ts":"61","E:\\Documents\\Coding\\SecScore\\src\\services\\sectlNotification.ts":"62","E:\\Documents\\Coding\\SecScore\\src\\services\\StudentService.ts":"63","E:\\Documents\\Coding\\SecScore\\src\\shared\\fontFamily.ts":"64","E:\\Documents\\Coding\\SecScore\\src\\shared\\kernel.ts":"65","E:\\Documents\\Coding\\SecScore\\src\\shared\\mobileNavigation.ts":"66","E:\\Documents\\Coding\\SecScore\\src\\utils\\color.ts":"67","E:\\Documents\\Coding\\SecScore\\src\\utils\\studentAvatar.ts":"68","E:\\Documents\\Coding\\SecScore\\src\\views\\SectlCloudView.tsx":"69","E:\\Documents\\Coding\\SecScore\\src\\workers\\xlsxWorker.ts":"70","E:\\Documents\\Coding\\SecScore\\vite.config.ts":"71","E:\\Documents\\Coding\\SecScore\\vitest.config.ts":"72"},{"size":1413,"mtime":1774084765386,"results":"73","hashOfConfig":"74"},{"size":44637,"mtime":1777979218263,"results":"75","hashOfConfig":"76"},{"size":124,"mtime":1777702123958,"results":"77","hashOfConfig":"76"},{"size":6923,"mtime":1782031028232,"results":"78","hashOfConfig":"76"},{"size":20459,"mtime":1777705095081,"results":"79","hashOfConfig":"76"},{"size":3739,"mtime":1777702307930,"results":"80","hashOfConfig":"76"},{"size":3033,"mtime":1777702307946,"results":"81","hashOfConfig":"76"},{"size":1749,"mtime":1777702307956,"results":"82","hashOfConfig":"76"},{"size":31736,"mtime":1782031104162,"results":"83","hashOfConfig":"76"},{"size":80545,"mtime":1782030541776,"results":"84","hashOfConfig":"76"},{"size":23220,"mtime":1777705100125,"results":"85","hashOfConfig":"76"},{"size":2044,"mtime":1777702308187,"results":"86","hashOfConfig":"76"},{"size":119999,"mtime":1782031039888,"results":"87","hashOfConfig":"76"},{"size":7976,"mtime":1777702308224,"results":"88","hashOfConfig":"76"},{"size":2112,"mtime":1777965094584,"results":"89","hashOfConfig":"76"},{"size":3869,"mtime":1777979416761,"results":"90","hashOfConfig":"76"},{"size":36339,"mtime":1782031042069,"results":"91","hashOfConfig":"76"},{"size":4203,"mtime":1777702308347,"results":"92","hashOfConfig":"76"},{"size":9135,"mtime":1777702123981,"results":"93","hashOfConfig":"76"},{"size":5983,"mtime":1777702308352,"results":"94","hashOfConfig":"76"},{"size":11972,"mtime":1777702308355,"results":"95","hashOfConfig":"76"},{"size":6151,"mtime":1777702308363,"results":"96","hashOfConfig":"76"},{"size":13165,"mtime":1777702308385,"results":"97","hashOfConfig":"76"},{"size":8478,"mtime":1782031106715,"results":"98","hashOfConfig":"76"},{"size":8685,"mtime":1782031107719,"results":"99","hashOfConfig":"76"},{"size":12679,"mtime":1777705108862,"results":"100","hashOfConfig":"76"},{"size":12264,"mtime":1782031109851,"results":"101","hashOfConfig":"76"},{"size":1661,"mtime":1777702308420,"results":"102","hashOfConfig":"76"},{"size":5321,"mtime":1777963784601,"results":"103","hashOfConfig":"76"},{"size":76498,"mtime":1782031181340,"results":"104","hashOfConfig":"76"},{"size":5954,"mtime":1782030856899,"results":"105","hashOfConfig":"76"},{"size":10638,"mtime":1777702123994,"results":"106","hashOfConfig":"76"},{"size":80332,"mtime":1782031287965,"results":"107","hashOfConfig":"76"},{"size":6331,"mtime":1777702308453,"results":"108","hashOfConfig":"76"},{"size":8079,"mtime":1777702308456,"results":"109","hashOfConfig":"76"},{"size":10957,"mtime":1782030598928,"results":"110","hashOfConfig":"76"},{"size":3858,"mtime":1777702308471,"results":"111","hashOfConfig":"76"},{"size":431,"mtime":1777702308475,"results":"112","hashOfConfig":"76"},{"size":2367,"mtime":1777702308481,"results":"113","hashOfConfig":"76"},{"size":1560,"mtime":1777702308484,"results":"114","hashOfConfig":"76"},{"size":3773,"mtime":1777702308490,"results":"115","hashOfConfig":"76"},{"size":362,"mtime":1777702308492,"results":"116","hashOfConfig":"76"},{"size":5985,"mtime":1777702308505,"results":"117","hashOfConfig":"76"},{"size":2824,"mtime":1777702308512,"results":"118","hashOfConfig":"76"},{"size":38,"mtime":1777702308522,"results":"119","hashOfConfig":"76"},{"size":3533,"mtime":1777702308531,"results":"120","hashOfConfig":"76"},{"size":2156,"mtime":1782031115738,"results":"121","hashOfConfig":"76"},{"size":2771,"mtime":1777702308548,"results":"122","hashOfConfig":"76"},{"size":6919,"mtime":1777702124001,"results":"123","hashOfConfig":"76"},{"size":6871,"mtime":1777702308658,"results":"124","hashOfConfig":"76"},{"size":27913,"mtime":1777979252370,"results":"125","hashOfConfig":"76"},{"size":1150,"mtime":1777702308667,"results":"126","hashOfConfig":"76"},{"size":7065,"mtime":1777706944006,"results":"127","hashOfConfig":"76"},{"size":695,"mtime":1777963813983,"results":"128","hashOfConfig":"76"},{"size":14036,"mtime":1777979564854,"results":"129","hashOfConfig":"76"},{"size":4835,"mtime":1782028569944,"results":"130","hashOfConfig":"76"},{"size":12753,"mtime":1782028847881,"results":"131","hashOfConfig":"76"},{"size":14288,"mtime":1777705131157,"results":"132","hashOfConfig":"76"},{"size":3363,"mtime":1782028571223,"results":"133","hashOfConfig":"76"},{"size":5429,"mtime":1782027566698,"results":"134","hashOfConfig":"76"},{"size":3715,"mtime":1782028571723,"results":"135","hashOfConfig":"76"},{"size":5437,"mtime":1782027781044,"results":"136","hashOfConfig":"76"},{"size":667,"mtime":1777702308678,"results":"137","hashOfConfig":"76"},{"size":1359,"mtime":1777702308724,"results":"138","hashOfConfig":"76"},{"size":3061,"mtime":1777702308733,"results":"139","hashOfConfig":"76"},{"size":1909,"mtime":1777702308763,"results":"140","hashOfConfig":"76"},{"size":3256,"mtime":1777702308776,"results":"141","hashOfConfig":"76"},{"size":1115,"mtime":1777702308782,"results":"142","hashOfConfig":"76"},{"size":429,"mtime":1777702308789,"results":"143","hashOfConfig":"76"},{"size":947,"mtime":1777702308795,"results":"144","hashOfConfig":"76"},{"size":709,"mtime":1777702124174,"results":"145","hashOfConfig":"76"},{"size":182,"mtime":1782027979900,"results":"146","hashOfConfig":"76"},{"filePath":"147","messages":"148","suppressedMessages":"149","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"oyxevq",{"filePath":"150","messages":"151","suppressedMessages":"152","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"f6a32r",{"filePath":"153","messages":"154","suppressedMessages":"155","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"156","messages":"157","suppressedMessages":"158","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"159","messages":"160","suppressedMessages":"161","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"162","messages":"163","suppressedMessages":"164","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"165","messages":"166","suppressedMessages":"167","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"168","messages":"169","suppressedMessages":"170","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"171","messages":"172","suppressedMessages":"173","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"174","messages":"175","suppressedMessages":"176","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"177","messages":"178","suppressedMessages":"179","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"180","messages":"181","suppressedMessages":"182","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"183","messages":"184","suppressedMessages":"185","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"186","messages":"187","suppressedMessages":"188","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"189","messages":"190","suppressedMessages":"191","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"192","messages":"193","suppressedMessages":"194","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"195","messages":"196","suppressedMessages":"197","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"198","messages":"199","suppressedMessages":"200","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"201","messages":"202","suppressedMessages":"203","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"204","messages":"205","suppressedMessages":"206","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"207","messages":"208","suppressedMessages":"209","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"210","messages":"211","suppressedMessages":"212","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"213","messages":"214","suppressedMessages":"215","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"216","messages":"217","suppressedMessages":"218","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"219","messages":"220","suppressedMessages":"221","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"222","messages":"223","suppressedMessages":"224","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"225","messages":"226","suppressedMessages":"227","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"228","messages":"229","suppressedMessages":"230","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"231","messages":"232","suppressedMessages":"233","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"234","messages":"235","suppressedMessages":"236","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"237","messages":"238","suppressedMessages":"239","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"240","messages":"241","suppressedMessages":"242","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"243","messages":"244","suppressedMessages":"245","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"246","messages":"247","suppressedMessages":"248","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"249","messages":"250","suppressedMessages":"251","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"252","messages":"253","suppressedMessages":"254","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"255","messages":"256","suppressedMessages":"257","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"258","messages":"259","suppressedMessages":"260","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"261","messages":"262","suppressedMessages":"263","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"264","messages":"265","suppressedMessages":"266","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"267","messages":"268","suppressedMessages":"269","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"270","messages":"271","suppressedMessages":"272","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"273","messages":"274","suppressedMessages":"275","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"276","messages":"277","suppressedMessages":"278","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"279","messages":"280","suppressedMessages":"281","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"282","messages":"283","suppressedMessages":"284","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"285","messages":"286","suppressedMessages":"287","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"288","messages":"289","suppressedMessages":"290","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"291","messages":"292","suppressedMessages":"293","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"294","messages":"295","suppressedMessages":"296","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"297","messages":"298","suppressedMessages":"299","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"300","messages":"301","suppressedMessages":"302","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"303","messages":"304","suppressedMessages":"305","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"306","messages":"307","suppressedMessages":"308","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"309","messages":"310","suppressedMessages":"311","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"312","messages":"313","suppressedMessages":"314","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"315","messages":"316","suppressedMessages":"317","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"318","messages":"319","suppressedMessages":"320","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"321","messages":"322","suppressedMessages":"323","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"324","messages":"325","suppressedMessages":"326","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"327","messages":"328","suppressedMessages":"329","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"330","messages":"331","suppressedMessages":"332","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"333","messages":"334","suppressedMessages":"335","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"336","messages":"337","suppressedMessages":"338","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"339","messages":"340","suppressedMessages":"341","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"342","messages":"343","suppressedMessages":"344","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"345","messages":"346","suppressedMessages":"347","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"348","messages":"349","suppressedMessages":"350","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"351","messages":"352","suppressedMessages":"353","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"354","messages":"355","suppressedMessages":"356","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"357","messages":"358","suppressedMessages":"359","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"360","messages":"361","suppressedMessages":"362","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"E:\\Documents\\Coding\\SecScore\\eslint.config.mjs",[],[],"E:\\Documents\\Coding\\SecScore\\src\\App.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\ClientContext.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScore\\ActionEditor.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScore\\AutoScoreUtils.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScore\\IntervalValueCodec.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScore\\IntervalValueWidget.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScore\\TriggerRuleBuilder.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\AutoScoreManager.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\BoardManager.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\ContentArea.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\EventHistory.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\Home.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\Leaderboard.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\OAuth\\OAuthCallback.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\OAuth\\OAuthLogin.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\OOBE\\OOBE.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\OOBE\\OOBEBackground.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\PluginManager.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\ReasonManager.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\RewardExchange.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\RewardSettings.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\ScoreManager.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\ScoreSyncPanel.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\SectlCloudStorageManager.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\SectlCloudSyncPanel.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\SectlKVStorageManager.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\SectlLoginButton.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\SectlSettingsPanel.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\Settings.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\SettlementHistory.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\Sidebar.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\StudentManager.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\TagEditorDialog.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\ThemeEditor.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\ThemeQuickSettings.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\TitleBar.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\Versions.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\WindowControls.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\components\\Wizard.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\contexts\\SectlContext.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\contexts\\ServiceContext.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\contexts\\ThemeContext.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\contexts\\ThemeEditorContext.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\env.d.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\hooks\\useOAuthPersist.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\hooks\\useResponsive.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\i18n\\index.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\main.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\plugins\\runtime.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\preload\\types.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\react-19-patch.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\services\\scoreSyncService.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\services\\sectl.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\services\\sectlAuth.ts",[],["363"],"E:\\Documents\\Coding\\SecScore\\src\\services\\sectlCloudStorage.test.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\services\\sectlCloudStorage.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\services\\sectlCloudSync.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\services\\sectlKVStorage.test.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\services\\sectlKVStorage.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\services\\sectlNotification.test.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\services\\sectlNotification.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\services\\StudentService.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\shared\\fontFamily.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\shared\\kernel.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\shared\\mobileNavigation.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\utils\\color.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\utils\\studentAvatar.ts",[],[],"E:\\Documents\\Coding\\SecScore\\src\\views\\SectlCloudView.tsx",[],[],"E:\\Documents\\Coding\\SecScore\\src\\workers\\xlsxWorker.ts",[],[],"E:\\Documents\\Coding\\SecScore\\vite.config.ts",[],[],"E:\\Documents\\Coding\\SecScore\\vitest.config.ts",[],[],{"ruleId":"364","severity":2,"message":"365","line":220,"column":36,"nodeType":"366","messageId":"367","endLine":220,"endColumn":42,"suppressions":"368"},"@typescript-eslint/no-unused-vars","'_state' is defined but never used.","Identifier","unusedVar",["369"],{"kind":"370","justification":"371"},"directive",""] \ No newline at end of file diff --git a/package.json b/package.json index 5b366b8..ba3b585 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "format": "prettier --write .", "lint": "eslint --cache .", "lint:fix": "eslint --cache . --fix", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@ant-design/icons": "^6.1.0", @@ -41,6 +43,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/ui": "^4.1.9", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.5", "eslint-plugin-react": "^7.37.5", @@ -49,6 +52,7 @@ "prettier": "^3.7.4", "typescript": "^5.9.3", "typescript-eslint": "^8.32.0", - "vite": "^7.2.6" + "vite": "^7.2.6", + "vitest": "^4.1.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bb70a8..c0c714d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.1 version: 5.2.0(vite@7.3.1(@types/node@22.19.15)) + '@vitest/ui': + specifier: ^4.1.9 + version: 4.1.9(vitest@4.1.9) eslint: specifier: ^9.39.1 version: 9.39.4 @@ -102,6 +105,9 @@ importers: vite: specifier: ^7.2.6 version: 7.3.1(@types/node@22.19.15) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@22.19.15)(@vitest/ui@4.1.9)(vite@7.3.1(@types/node@22.19.15)) packages: @@ -459,6 +465,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rc-component/async-validator@5.1.0': resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==} engines: {node: '>=14.x'} @@ -1052,9 +1061,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/crypto-js@4.2.2': resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1148,6 +1163,40 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/ui@4.1.9': + resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==} + peerDependencies: + vitest: 4.1.9 + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1209,6 +1258,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1267,6 +1320,10 @@ packages: resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} engines: {node: '>=0.8'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1382,6 +1439,9 @@ packages: resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1476,10 +1536,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1498,6 +1565,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1513,6 +1583,9 @@ packages: flatted@3.4.1: resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -1820,6 +1893,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1834,6 +1910,10 @@ packages: moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1884,6 +1964,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1915,6 +1999,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2187,6 +2274,13 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2203,6 +2297,12 @@ packages: resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} engines: {node: '>=0.8'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2248,10 +2348,25 @@ packages: resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} engines: {node: '>=12.22'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -2351,6 +2466,47 @@ packages: yaml: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -2376,6 +2532,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wmf@1.0.2: resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} engines: {node: '>=0.8'} @@ -2729,6 +2890,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@polka/url@1.0.0-next.29': {} + '@rc-component/async-validator@5.1.0': dependencies: '@babel/runtime': 7.28.6 @@ -3334,8 +3497,15 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/crypto-js@4.2.2': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14)': @@ -3464,6 +3634,58 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@7.3.1(@types/node@22.19.15))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.15) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/ui@4.1.9(vitest@4.1.9)': + dependencies: + '@vitest/utils': 4.1.9 + fflate: 0.8.3 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@22.19.15)(@vitest/ui@4.1.9)(vite@7.3.1(@types/node@22.19.15)) + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -3603,6 +3825,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -3660,6 +3884,8 @@ snapshots: adler-32: 1.3.1 crc-32: 1.2.2 + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3830,6 +4056,8 @@ snapshots: math-intrinsics: 1.1.0 safe-array-concat: 1.1.3 + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3991,8 +4219,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -4003,6 +4237,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -4019,6 +4255,8 @@ snapshots: flatted@3.4.1: {} + flatted@3.4.2: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -4328,6 +4566,10 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} minimatch@10.2.4: @@ -4340,6 +4582,8 @@ snapshots: moment@2.30.1: {} + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -4393,6 +4637,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4426,6 +4672,8 @@ snapshots: path-parse@1.0.7: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -4739,6 +4987,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + source-map-js@1.2.1: {} spel2js@0.2.9: {} @@ -4749,6 +5005,10 @@ snapshots: dependencies: frac: 1.1.2 + stackback@0.0.2: {} + + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -4812,11 +5072,19 @@ snapshots: throttle-debounce@5.0.2: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + + totalist@3.0.1: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4906,6 +5174,34 @@ snapshots: '@types/node': 22.19.15 fsevents: 2.3.3 + vitest@4.1.9(@types/node@22.19.15)(@vitest/ui@4.1.9)(vite@7.3.1(@types/node@22.19.15)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.3.1(@types/node@22.19.15)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@22.19.15) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + '@vitest/ui': 4.1.9(vitest@4.1.9) + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} which-boxed-primitive@1.1.1: @@ -4953,6 +5249,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wmf@1.0.2: {} word-wrap@1.2.5: {} diff --git a/public/about-content.json b/public/about-content.json index ff606a4..1a68bd0 100644 --- a/public/about-content.json +++ b/public/about-content.json @@ -4,4 +4,4 @@ "version": "v1.0.0", "content": "

SecScore





\"GitHub
\"Downloads\"
\"License\"
\"GitHub
\"GitHub
\"Electron\"
\"React\"
\"TypeScript\"
\"Vite\"



SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。

主要功能



- 添加/删除学生
- 通过 xlsx 批量导入名单(支持导入前预览与选择“姓名列”)
- 选择学生并提交加分/扣分
- 支持“预设理由”一键填充理由与分值
- 支持撤销最近的积分记录(撤销后学生积分会回滚)
- 维护“预设理由”(分类、理由内容、预设分值)
- 支持按“今天 / 本周 / 本月”查看积分变化
- 支持导出排行榜为 XLSX
- 支持查看单个学生的操作记录(文本列表)
- “结算并重新开始”:把当前未结算记录归档为一个阶段,并将所有学生当前积分清零
- 在“结算历史”查看每个阶段的排行榜
- 主题切换
- 日志查看/导出/清空
- 数据导入/导出(JSON)
- 密码保护(管理密码 / 积分密码)与找回字符串

使用方法



1. 权限与解锁



- 管理密码:全功能(学生管理、理由管理、结算、数据管理等)
- 积分密码:仅允许积分相关操作

2. 学生管理(导入名单)



入口:左侧菜单 → 学生管理 → 导入名单

1. 选择一个 .xlsx 文件(默认读取第一个工作表)
2. 在预览表格里点击表头,选择“姓名列”
3. 点击“导入”
4. 导入完成后会提示“新增 / 跳过”数量

建议:姓名列尽量只包含纯姓名文本;同名学生会被视为重复并跳过。

3. 积分管理(加分/扣分)



入口:左侧菜单 → 积分管理

1. 在“姓名”选择一个学生(支持搜索)
2. 选择“加分/扣分”,并输入分值
3. 在“理由内容”填写原因(可手动输入)
4. 点击“确认提交”

快捷理由:


撤销最近记录:


4. 排行榜与导出



入口:左侧菜单 → 排行榜


5. 结算与数据备份



入口:左侧菜单 → 系统设置 → 数据管理

- 会把当前未结算的积分记录归档为一个阶段
- 会将所有学生当前积分清零
- 学生名单不变;结算后的历史在“结算历史”查看
- 导入会覆盖现有学生/理由/积分记录/设置
- 安全相关设置(密码等)不会随导入写入

开发与运行(面向贡献者)



MCP 文档




环境要求




安装依赖



``bash
pnpm install
`

开发模式



`bash
pnpm dev
`

构建



`bash
pnpm build
`

(可选)打包:

`bash
pnpm build:win
pnpm build:unpack
`

常用检查



`bash
pnpm lint
pnpm typecheck
``
", "rawMarkdown": "# SecScore\n\n\n

\n \"GitHub\n \"Downloads\"\n \"License\"\n \"GitHub\n \"GitHub\n \"Electron\"\n \"React\"\n \"TypeScript\"\n \"Vite\"\n

\n\nSecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。\n\n## 主要功能\n\n- 学生管理\n - 添加/删除学生\n - 通过 xlsx 批量导入名单(支持导入前预览与选择“姓名列”)\n- 积分管理\n - 选择学生并提交加分/扣分\n - 支持“预设理由”一键填充理由与分值\n - 支持撤销最近的积分记录(撤销后学生积分会回滚)\n- 理由管理\n - 维护“预设理由”(分类、理由内容、预设分值)\n- 排行榜\n - 支持按“今天 / 本周 / 本月”查看积分变化\n - 支持导出排行榜为 XLSX\n - 支持查看单个学生的操作记录(文本列表)\n- 结算与历史\n - “结算并重新开始”:把当前未结算记录归档为一个阶段,并将所有学生当前积分清零\n - 在“结算历史”查看每个阶段的排行榜\n- 系统设置\n - 主题切换\n - 日志查看/导出/清空\n - 数据导入/导出(JSON)\n - 密码保护(管理密码 / 积分密码)与找回字符串\n\n## 使用方法\n\n### 1. 权限与解锁\n\n- 右上角会显示当前权限:管理权限 / 积分权限 / 只读\n- 若设置了密码,可通过右上角“输入密码”进行解锁\n - 管理密码:全功能(学生管理、理由管理、结算、数据管理等)\n - 积分密码:仅允许积分相关操作\n- 点击“锁定”可切回只读状态\n- 无密码时默认视为管理权限\n\n### 2. 学生管理(导入名单)\n\n入口:左侧菜单 → 学生管理 → 导入名单\n\n- 通过 xlsx 导入\n 1. 选择一个 `.xlsx` 文件(默认读取第一个工作表)\n 2. 在预览表格里点击表头,选择“姓名列”\n 3. 点击“导入”\n 4. 导入完成后会提示“新增 / 跳过”数量\n\n建议:姓名列尽量只包含纯姓名文本;同名学生会被视为重复并跳过。\n\n### 3. 积分管理(加分/扣分)\n\n入口:左侧菜单 → 积分管理\n\n1. 在“姓名”选择一个学生(支持搜索)\n2. 选择“加分/扣分”,并输入分值\n3. 在“理由内容”填写原因(可手动输入)\n4. 点击“确认提交”\n\n快捷理由:\n\n- 可在“快捷理由”下拉框中选择预设理由,一键填充理由内容/分值(优先尊重你当前是否已手动输入分值)\n\n撤销最近记录:\n\n- “最近记录”默认折叠,展开后可对记录点击“撤销”\n- 撤销会回滚该条记录对学生积分的影响\n\n### 4. 排行榜与导出\n\n入口:左侧菜单 → 排行榜\n\n- 可切换统计范围:今天 / 本周 / 本月\n- 点击“导出 XLSX”可导出当前排行榜\n- 点击某个学生的“查看”可打开该学生的操作记录\n\n### 5. 结算与数据备份\n\n入口:左侧菜单 → 系统设置 → 数据管理\n\n- 结算并重新开始\n - 会把当前未结算的积分记录归档为一个阶段\n - 会将所有学生当前积分清零\n - 学生名单不变;结算后的历史在“结算历史”查看\n- 导出 JSON(强烈建议定期备份)\n - 导入会覆盖现有学生/理由/积分记录/设置\n - 安全相关设置(密码等)不会随导入写入\n\n## 开发与运行(面向贡献者)\n\n## MCP 文档\n\n- [MCP-使用说明](./MCP-使用说明.md)\n\n### 环境要求\n\n- Node.js(建议使用 LTS 版本)\n- pnpm\n\n### 安装依赖\n\n```bash\npnpm install\n```\n\n### 开发模式\n\n```bash\npnpm dev\n```\n\n### 构建\n\n```bash\npnpm build\n```\n\n(可选)打包:\n\n```bash\npnpm build:win\npnpm build:unpack\n```\n\n### 常用检查\n\n```bash\npnpm lint\npnpm typecheck\n```\n" -} \ No newline at end of file +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index de6b642..c2f027a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2128,9 +2128,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index c00a1aa..357811a 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -444,7 +444,7 @@ pub async fn oauth_get_authorization_url( let code_challenge = generate_code_challenge(&code_verifier); let url = format!( - "https://sectl.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&state={}&code_challenge={}&code_challenge_method=S256", + "https://sectl.cn/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&state={}&code_challenge={}&code_challenge_method=S256", platform_id, urlencoding::encode(&callback_url), urlencoding::encode(&state), @@ -490,7 +490,7 @@ pub async fn oauth_exchange_code( println!("[OAuth] 请求参数:{:?}", payload); let response = client - .post("https://appwrite.sectl.top/api/oauth/token") + .post("https://appwrite.sectl.cn/api/oauth/token") .json(&payload) .send() .await @@ -547,7 +547,7 @@ pub async fn oauth_revoke_token( } let response = client - .post("https://appwrite.sectl.top/api/oauth/revoke") + .post("https://appwrite.sectl.cn/api/oauth/revoke") .json(&payload) .send() .await @@ -574,7 +574,7 @@ pub async fn oauth_introspect_token( let client = &state_guard.http_client; let response = client - .post("https://appwrite.sectl.top/api/oauth/introspect") + .post("https://appwrite.sectl.cn/api/oauth/introspect") .json(&serde_json::json!({ "token": token, "client_id": platform_id, @@ -613,7 +613,7 @@ pub async fn oauth_get_user_info( let client = &state_guard.http_client; let response = client - .get("https://appwrite.sectl.top/api/oauth/userinfo") + .get("https://appwrite.sectl.cn/api/oauth/userinfo") .header("Authorization", format!("Bearer {}", access_token)) .send() .await @@ -664,7 +664,7 @@ pub async fn oauth_refresh_token( let client = &state_guard.http_client; let response = client - .post("https://appwrite.sectl.top/api/oauth/token") + .post("https://appwrite.sectl.cn/api/oauth/refresh") .json(&serde_json::json!({ "grant_type": "refresh_token", "refresh_token": refresh_token, @@ -719,7 +719,7 @@ pub async fn oauth_get_storage_usage( let client = &state_guard.http_client; let response = client - .get("https://appwrite.sectl.top/api/cloud/storage/usage") + .get("https://appwrite.sectl.cn/api/cloud-storage/usage") .query(&[("client_id", platform_id), ("user_id", user_id)]) .header("Authorization", format!("Bearer {}", access_token)) .send() @@ -787,7 +787,7 @@ pub async fn oauth_report_online( } let response = client - .post("https://appwrite.sectl.top/api/stats/online") + .post("https://appwrite.sectl.cn/api/stats/online") .json(&payload) .send() .await @@ -911,7 +911,7 @@ pub async fn oauth_refresh_access_token( println!("[OAuth] 刷新 token 请求参数: {:?}", payload); let response = client - .post("https://appwrite.sectl.top/api/oauth/token") + .post("https://appwrite.sectl.cn/api/oauth/refresh") .json(&payload) .send() .await diff --git a/src/App.tsx b/src/App.tsx index 3bf223d..6385251 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -256,6 +256,57 @@ function MainContent(): React.JSX.Element { } }, [refreshPermissionFromAuth]) + // 监听 Deep Link 事件(用于 OAuth 回调) + useEffect(() => { + const api = (window as any).api + if (!api || typeof api.onDeepLink !== "function") return + + let disposed = false + let unlisten: (() => void) | null = null + + api + .onDeepLink((url: string) => { + console.log("[DeepLink] Received URL:", url) + if (url.startsWith("secscore://oauth")) { + // 解析 OAuth 回调参数 + const urlObj = new URL(url) + const code = urlObj.searchParams.get("code") + const state = urlObj.searchParams.get("state") + const error = urlObj.searchParams.get("error") + + if (error) { + console.error("[DeepLink] OAuth error:", error) + messageApi.error("登录失败: " + error) + return + } + + if (code && state) { + // 发送自定义事件,让 OAuthCallback 组件处理 + window.dispatchEvent( + new CustomEvent("ss:oauth-deep-link", { + detail: { code, state }, + }) + ) + } + } + }) + .then((fn: () => void) => { + if (disposed) { + fn() + return + } + unlisten = fn + }) + .catch((err: any) => { + console.error("[DeepLink] Failed to listen:", err) + }) + + return () => { + disposed = true + if (unlisten) unlisten() + } + }, [messageApi]) + useEffect(() => { const api = (window as any).api if (!api || typeof api.onSettingChanged !== "function") return @@ -531,10 +582,12 @@ function MainContent(): React.JSX.Element { }, [messageApi, refreshPermissionFromAuth, t]) const handleOAuthSuccess = (userInfo: { - user_id: string - email: string - name: string - github_username?: string + user_id?: string + id?: string + email?: string + name?: string + avatar?: string + avatar_url?: string }) => { // OAuth 登录仅用于云服务,不修改本地权限 setOAuthUserName(userInfo.name || null) diff --git a/src/components/AutoScore/ActionEditor.tsx b/src/components/AutoScore/ActionEditor.tsx index d563f2e..33632ce 100644 --- a/src/components/AutoScore/ActionEditor.tsx +++ b/src/components/AutoScore/ActionEditor.tsx @@ -31,7 +31,10 @@ export const ActionEditor: React.FC = ({ }) => { const { t } = useTranslation() const fallbackDraft = useMemo(() => createDefaultActionDraft(), []) - const safeValue = value.length > 0 ? value : [fallbackDraft] + const safeValue = useMemo( + () => (value.length > 0 ? value : [fallbackDraft]), + [value, fallbackDraft] + ) useEffect(() => { if (value.length === 0) { diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx index 4f74d42..e1bb038 100644 --- a/src/components/AutoScoreManager.tsx +++ b/src/components/AutoScoreManager.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react" +import React, { useCallback, useEffect, useMemo, useState } from "react" import { Alert, Button, @@ -156,7 +156,7 @@ const buildOfflineBackfillPlan = ( } function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { - const { t, i18n } = useTranslation() + const { t } = useTranslation() const [form] = Form.useForm() const [messageApi, contextHolder] = message.useMessage() const [tags, setTags] = useState([]) @@ -183,10 +183,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element [rewards] ) - const triggerConfig = useMemo( - () => createTriggerQueryConfig(t, tagOptions), - [i18n.resolvedLanguage, i18n.language, tagOptions] - ) + const triggerConfig = useMemo(() => createTriggerQueryConfig(t, tagOptions), [t, tagOptions]) const [triggerTree, setTriggerTree] = useState(() => createEmptyTriggerTree(triggerConfig) ) @@ -282,7 +279,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } })) } - const fetchStudents = async () => { + const fetchStudents = useCallback(async () => { const api = (window as any).api if (!api || !canEdit) return @@ -294,9 +291,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } catch { void 0 } - } + }, [canEdit]) - const fetchTags = async () => { + const fetchTags = useCallback(async () => { if (!canEdit) return try { @@ -307,9 +304,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } catch { void 0 } - } + }, [canEdit]) - const fetchRewards = async () => { + const fetchRewards = useCallback(async () => { const api = (window as any).api if (!api || !canEdit) return @@ -321,9 +318,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } catch { void 0 } - } + }, [canEdit]) - const fetchRules = async () => { + const fetchRules = useCallback(async () => { const api = (window as any).api if (!api || !canEdit) return @@ -340,9 +337,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } finally { setLoading(false) } - } + }, [canEdit, messageApi, t]) - const fetchBatches = async () => { + const fetchBatches = useCallback(async () => { const api = (window as any).api if (!api || !canEdit) return try { @@ -353,7 +350,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } catch { void 0 } - } + }, [canEdit]) useEffect(() => { if (!canEdit) return @@ -362,7 +359,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element fetchStudents().catch(() => void 0) fetchRules().catch(() => void 0) fetchBatches().catch(() => void 0) - }, [canEdit]) + }, [canEdit, fetchTags, fetchRewards, fetchStudents, fetchRules, fetchBatches]) useEffect(() => { const api = (window as any).api @@ -426,7 +423,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } }, }) - }, [backfillPrompted, canEdit, messageApi, rules, t]) + }, [backfillPrompted, canEdit, messageApi, rules, t, fetchRules, fetchBatches]) const handleSubmit = async () => { const api = (window as any).api diff --git a/src/components/BoardManager.tsx b/src/components/BoardManager.tsx index 88f2c9d..592794f 100644 --- a/src/components/BoardManager.tsx +++ b/src/components/BoardManager.tsx @@ -25,7 +25,14 @@ import { useTranslation } from "react-i18next" type BoardStudentViewMode = "list" | "card" | "grid" | "largeAvatar" type BoardScoreDisplayMode = "total" | "split" type SplitDirection = "horizontal" | "vertical" -type BoardTimeRange = "last7d" | "last30d" | "thisWeek" | "lastWeek" | "thisMonth" | "lastMonth" | "custom" +type BoardTimeRange = + | "last7d" + | "last30d" + | "thisWeek" + | "lastWeek" + | "thisMonth" + | "lastMonth" + | "custom" type BoardReasonMode = "all" | "selected" | "keyword" type BoardScoreDirection = "all" | "add" | "deduct" type BoardMetric = "addScore" | "deductScore" | "netChange" | "addCount" | "eventCount" @@ -177,9 +184,7 @@ const normalizeRule = (input: unknown): BoardQueryRuleConfig => { const fallback = createDefaultRule() const raw = typeof input === "object" && input ? (input as Record) : {} const reasonValues = Array.isArray(raw.reasonValues) - ? raw.reasonValues - .map((item) => (typeof item === "string" ? item.trim() : "")) - .filter(Boolean) + ? raw.reasonValues.map((item) => (typeof item === "string" ? item.trim() : "")).filter(Boolean) : [] const allowedTimeRange = new Set([ @@ -216,7 +221,9 @@ const normalizeRule = (input: unknown): BoardQueryRuleConfig => { scoreDirection: allowedScoreDirection.has(raw.scoreDirection as BoardScoreDirection) ? (raw.scoreDirection as BoardScoreDirection) : fallback.scoreDirection, - metric: allowedMetric.has(raw.metric as BoardMetric) ? (raw.metric as BoardMetric) : fallback.metric, + metric: allowedMetric.has(raw.metric as BoardMetric) + ? (raw.metric as BoardMetric) + : fallback.metric, sortDirection: allowedSortDirection.has(raw.sortDirection as BoardSortDirection) ? (raw.sortDirection as BoardSortDirection) : fallback.sortDirection, @@ -324,7 +331,11 @@ const buildSqlFromRule = (ruleInput: BoardQueryRuleConfig): string | null => { const currentStartMs = new Date(currentStart).getTime() const currentEndMs = new Date(currentEnd).getTime() - if (!Number.isFinite(currentStartMs) || !Number.isFinite(currentEndMs) || currentEndMs <= currentStartMs) { + if ( + !Number.isFinite(currentStartMs) || + !Number.isFinite(currentEndMs) || + currentEndMs <= currentStartMs + ) { return null } @@ -358,8 +369,7 @@ const buildSqlFromRule = (ruleInput: BoardQueryRuleConfig): string | null => { ) } - const baseWhereSql = - baseWhereClauses.length > 0 ? `AND ${baseWhereClauses.join(" AND ")}` : "" + const baseWhereSql = baseWhereClauses.length > 0 ? `AND ${baseWhereClauses.join(" AND ")}` : "" const metricExpression = getMetricExpression(rule.metric) const topN = clampTopN(rule.topN) const filterClauses: string[] = [] @@ -560,24 +570,23 @@ const normalizeBoards = (input: unknown): BoardConfig[] => { const boards = input .map((board: any) => { const lists = Array.isArray(board?.lists) - ? board.lists - .map((list: any) => ({ - id: typeof list?.id === "string" && list.id.trim() ? list.id : makeId(), - name: - typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表", - rule: normalizeRule(list?.rule), - viewMode: - list?.viewMode === "list" || - list?.viewMode === "card" || - list?.viewMode === "grid" || - list?.viewMode === "largeAvatar" - ? list.viewMode - : "card", - scoreDisplayMode: - list?.scoreDisplayMode === "total" || list?.scoreDisplayMode === "split" - ? list.scoreDisplayMode - : "total", - })) + ? board.lists.map((list: any) => ({ + id: typeof list?.id === "string" && list.id.trim() ? list.id : makeId(), + name: + typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表", + rule: normalizeRule(list?.rule), + viewMode: + list?.viewMode === "list" || + list?.viewMode === "card" || + list?.viewMode === "grid" || + list?.viewMode === "largeAvatar" + ? list.viewMode + : "card", + scoreDisplayMode: + list?.scoreDisplayMode === "total" || list?.scoreDisplayMode === "split" + ? list.scoreDisplayMode + : "total", + })) : [] const normalizedLists = lists.length > 0 ? lists : [createDefaultList()] @@ -965,7 +974,7 @@ export const BoardManager: React.FC = ({ canManage }) => { } const firstLeafId = getFirstLeafId(activeBoard.layout) setSelectedLeafNodeId((prev) => prev || firstLeafId) - }, [activeBoard?.id, activeBoard?.layout]) + }, [activeBoard?.id, activeBoard?.layout, activeBoard]) useEffect(() => { if (!activeBoard) return @@ -973,7 +982,7 @@ export const BoardManager: React.FC = ({ canManage }) => { runAllInBoard(activeBoard).catch(() => void 0) }, 260) return () => window.clearTimeout(timer) - }, [activeBoard?.id, listConfigSignature, runAllInBoard]) + }, [activeBoard?.id, listConfigSignature, runAllInBoard, activeBoard]) useEffect(() => { const onDataUpdated = (e: Event) => { @@ -1235,27 +1244,27 @@ export const BoardManager: React.FC = ({ canManage }) => { ? item.metricValue !== undefined ? { label: metricTitle, value: item.metricValue } : item.addScore !== undefined && item.addScore !== 0 - ? { label: t("board.metrics.addScore"), value: item.addScore } - : item.deductScore !== undefined && item.deductScore !== 0 - ? { label: t("board.metrics.deductScore"), value: item.deductScore } - : item.addScore !== undefined - ? { label: t("board.metrics.addScore"), value: item.addScore } - : item.deductScore !== undefined - ? { label: t("board.metrics.deductScore"), value: item.deductScore } - : item.score !== undefined - ? { label: t("board.metrics.totalScore"), value: item.score } - : item.rewardPoints !== undefined - ? { label: t("board.metrics.rewardPoints"), value: item.rewardPoints } - : item.weekChange !== undefined - ? { label: t("board.metrics.weekChange"), value: item.weekChange } - : item.weekDeducted !== undefined - ? { label: t("board.metrics.weekDeducted"), value: item.weekDeducted } - : item.answeredCount !== undefined - ? { - label: t("board.metrics.todayAnswered"), - value: item.answeredCount, - } - : null + ? { label: t("board.metrics.addScore"), value: item.addScore } + : item.deductScore !== undefined && item.deductScore !== 0 + ? { label: t("board.metrics.deductScore"), value: item.deductScore } + : item.addScore !== undefined + ? { label: t("board.metrics.addScore"), value: item.addScore } + : item.deductScore !== undefined + ? { label: t("board.metrics.deductScore"), value: item.deductScore } + : item.score !== undefined + ? { label: t("board.metrics.totalScore"), value: item.score } + : item.rewardPoints !== undefined + ? { label: t("board.metrics.rewardPoints"), value: item.rewardPoints } + : item.weekChange !== undefined + ? { label: t("board.metrics.weekChange"), value: item.weekChange } + : item.weekDeducted !== undefined + ? { label: t("board.metrics.weekDeducted"), value: item.weekDeducted } + : item.answeredCount !== undefined + ? { + label: t("board.metrics.todayAnswered"), + value: item.answeredCount, + } + : null : item.score !== undefined ? item.metricValue !== undefined ? { label: metricTitle, value: item.metricValue } @@ -1594,8 +1603,12 @@ export const BoardManager: React.FC = ({ canManage }) => { )} {item.metricDelta !== undefined && ( - = 0 ? "success" : "error"} style={{ margin: 0 }}> - 较上期: {item.metricDelta > 0 ? `+${item.metricDelta}` : item.metricDelta} + = 0 ? "success" : "error"} + style={{ margin: 0 }} + > + 较上期:{" "} + {item.metricDelta > 0 ? `+${item.metricDelta}` : item.metricDelta} )} {rankChangeText && ( diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 1c4504d..e752238 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -553,7 +553,7 @@ export const Home: React.FC = ({ return a.localeCompare(b, "zh-CN") }) .map(([key, students]) => ({ key, students })) - }, [sortedStudents, sortType, searchKeyword, layoutType, getGroupName, t]) + }, [sortedStudents, sortType, searchKeyword, getGroupName, t]) const firstStudentIdByGroup = useMemo(() => { const result = new Map() @@ -577,7 +577,7 @@ export const Home: React.FC = ({ if (b === t("home.category.others")) return -1 return a.localeCompare(b, "zh-CN") }) - }, [reasons]) + }, [reasons, t]) const selectedStudents = useMemo(() => { if (selectedStudentIds.length === 0) return [] @@ -2374,18 +2374,21 @@ export const Home: React.FC = ({ document.addEventListener("mouseup", onGlobalMouseUp) } - const onGlobalMouseMove = (e: MouseEvent) => { - if (isNavDragging.current) { - if (e.cancelable) e.preventDefault() - handleNavAction(e.clientY) - } - } + const onGlobalMouseMove = useCallback( + (e: MouseEvent) => { + if (isNavDragging.current) { + if (e.cancelable) e.preventDefault() + handleNavAction(e.clientY) + } + }, + [handleNavAction] + ) - const onGlobalMouseUp = () => { + const onGlobalMouseUp = useCallback(() => { setNavDraggingState(false) document.removeEventListener("mousemove", onGlobalMouseMove) document.removeEventListener("mouseup", onGlobalMouseUp) - } + }, [onGlobalMouseMove, setNavDraggingState]) const onNavTouchStart = (e: React.TouchEvent) => { navHapticIndexRef.current = null @@ -2421,7 +2424,7 @@ export const Home: React.FC = ({ document.body.style.userSelect = bodyUserSelectRef.current document.body.style.webkitUserSelect = bodyWebkitUserSelectRef.current } - }, []) + }, [onGlobalMouseMove, onGlobalMouseUp]) useEffect(() => { if (!groupedStudents.length) { diff --git a/src/components/OAuth/OAuthCallback.tsx b/src/components/OAuth/OAuthCallback.tsx index 8e05b8c..32ecf1e 100644 --- a/src/components/OAuth/OAuthCallback.tsx +++ b/src/components/OAuth/OAuthCallback.tsx @@ -1,35 +1,44 @@ -import { useEffect } from "react" +import { useEffect, useState } from "react" import { useSearchParams, useNavigate } from "react-router-dom" +import { sectlAuth } from "../../services/sectlAuth" export function OAuthCallback() { const [searchParams] = useSearchParams() const navigate = useNavigate() + const [status, setStatus] = useState<"processing" | "success" | "error">("processing") + const [errorMessage, setErrorMessage] = useState("") useEffect(() => { - const code = searchParams.get("code") - const error = searchParams.get("error") - const errorDescription = searchParams.get("error_description") + const handleCallback = async () => { + const code = searchParams.get("code") + const error = searchParams.get("error") + const errorDescription = searchParams.get("error_description") - if (error) { - window.postMessage( - { - error: errorDescription || error, - }, - window.location.origin - ) - navigate("/") - return + if (error) { + setStatus("error") + setErrorMessage(errorDescription || error) + setTimeout(() => navigate("/"), 3000) + return + } + + if (code) { + try { + await sectlAuth.exchangeCodeForToken(code) + setStatus("success") + setTimeout(() => navigate("/"), 1500) + } catch (err: any) { + setStatus("error") + setErrorMessage(err.message || "Token 交换失败") + setTimeout(() => navigate("/"), 3000) + } + } else { + setStatus("error") + setErrorMessage("未收到授权码") + setTimeout(() => navigate("/"), 3000) + } } - if (code) { - window.postMessage( - { - code, - }, - window.location.origin - ) - navigate("/") - } + handleCallback() }, [searchParams, navigate]) return ( @@ -43,7 +52,18 @@ export function OAuthCallback() { color: "var(--ss-text-main)", }} > -
正在处理登录...
+
+ {status === "processing" &&
正在处理登录...
} + {status === "success" &&
登录成功,正在跳转...
} + {status === "error" && ( +
+
登录失败
+
+ {errorMessage} +
+
+ )} +
) } diff --git a/src/components/OAuth/OAuthLogin.tsx b/src/components/OAuth/OAuthLogin.tsx index 15bd9dc..4ffdcc0 100644 --- a/src/components/OAuth/OAuthLogin.tsx +++ b/src/components/OAuth/OAuthLogin.tsx @@ -1,243 +1,71 @@ import { Button, message, Modal, Space, Spin } from "antd" -import { useCallback, useEffect, useRef, useState } from "react" +import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { open } from "@tauri-apps/plugin-shell" -import { listen, UnlistenFn } from "@tauri-apps/api/event" +import { sectlAuth, SECTL_CONFIG } from "../../services/sectlAuth" interface OAuthLoginProps { visible: boolean onClose: () => void onSuccess: (userInfo: { - user_id: string - email: string - name: string - github_username?: string + user_id?: string + id?: string + email?: string + name?: string + avatar?: string + avatar_url?: string }) => void } -interface OAuthConfig { - platform_id: string -} - -interface OAuthCallbackResult { - code?: string - state?: string - error?: string - error_description?: string -} - -const OAUTH_EXPECTED_STATE_KEY = "oauth_expected_state" -const OAUTH_CODE_VERIFIER_KEY = "oauth_code_verifier" -const OAUTH_CALLBACK_URL_KEY = "oauth_callback_url" -const DEFAULT_CALLBACK_URL = "http://127.0.0.1:16888/oauth/callback" - -const getExpectedState = () => sessionStorage.getItem(OAUTH_EXPECTED_STATE_KEY) -const setExpectedState = (state: string) => sessionStorage.setItem(OAUTH_EXPECTED_STATE_KEY, state) -const clearExpectedState = () => sessionStorage.removeItem(OAUTH_EXPECTED_STATE_KEY) - -const getCodeVerifier = () => sessionStorage.getItem(OAUTH_CODE_VERIFIER_KEY) -const setCodeVerifier = (codeVerifier: string) => - sessionStorage.setItem(OAUTH_CODE_VERIFIER_KEY, codeVerifier) -const clearCodeVerifier = () => sessionStorage.removeItem(OAUTH_CODE_VERIFIER_KEY) - -const getCallbackUrl = () => sessionStorage.getItem(OAUTH_CALLBACK_URL_KEY) || DEFAULT_CALLBACK_URL -const setCallbackUrl = (callbackUrl: string) => - sessionStorage.setItem(OAUTH_CALLBACK_URL_KEY, callbackUrl) -const clearCallbackUrl = () => sessionStorage.removeItem(OAUTH_CALLBACK_URL_KEY) - export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { const { t } = useTranslation() const [loading, setLoading] = useState(false) - const isProcessingCallbackRef = useRef(false) - - const getOAuthConfig = (): OAuthConfig | null => { - const api = (window as any).api - if (!api) return null - - const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID - - if (!platformId) { - return null - } - - return { - platform_id: platformId, - } - } - - const stopCallbackServer = useCallback(async () => { - const api = (window as any).api - if (!api?.oauthStopCallbackServer) return - try { - await api.oauthStopCallbackServer() - } catch (error) { - console.error("[OAuth] 停止回调服务器失败:", error) - } - }, []) - - const clearOAuthTempState = useCallback(() => { - clearExpectedState() - clearCodeVerifier() - clearCallbackUrl() - }, []) - - const handleOAuthCallback = useCallback( - async (result: OAuthCallbackResult) => { - if (isProcessingCallbackRef.current) { - console.log("[OAuth] 回调正在处理中,跳过") - return - } - - console.log("[OAuth] 收到回调:", result) - const config = getOAuthConfig() - if (!config) { - console.error("[OAuth] 配置不存在") - message.error("OAuth 配置未设置") - setLoading(false) - return - } - - const api = (window as any).api - if (!api) { - message.error("API 不可用") - setLoading(false) - return - } - - try { - isProcessingCallbackRef.current = true - - if (result.error) { - console.error("[OAuth] 错误:", result.error, result.error_description) - message.error(result.error_description || result.error || "授权失败") - return - } - - if (!result.code) return - - console.log("[OAuth] 授权码:", result.code) - console.log("[OAuth] State:", result.state, "期望:", getExpectedState()) - - const expectedState = getExpectedState() - if (expectedState && result.state !== expectedState) { - message.error("安全验证失败:state 不匹配") - return - } - - const codeVerifier = getCodeVerifier() - if (!codeVerifier) { - message.error("安全验证失败:缺少 code_verifier") - return - } - - const callbackUrl = getCallbackUrl() - console.log("[OAuth] 开始换取 token...") - const tokenRes = await api.oauthExchangeCode( - result.code, - config.platform_id, - callbackUrl, - codeVerifier - ) - console.log("[OAuth] token 响应:", tokenRes) - - if (!tokenRes.success) { - message.error(tokenRes.message || "获取访问令牌失败") - return - } - - console.log("[OAuth] 开始获取用户信息...") - const userRes = await api.oauthGetUserInfo(tokenRes.data.access_token) - console.log("[OAuth] 用户信息响应:", userRes) - - if (!userRes.success) { - message.error(userRes.message || "获取用户信息失败") - return - } - - console.log("[OAuth] 登录成功,保存登录状态...") - - const loginState = { - access_token: tokenRes.data.access_token, - refresh_token: tokenRes.data.refresh_token, - token_type: tokenRes.data.token_type, - expires_in: tokenRes.data.expires_in, - user_id: userRes.data.user_id, - email: userRes.data.email, - name: userRes.data.name, - github_username: userRes.data.github_username, - login_time: new Date().toISOString(), - } - - try { - await api.oauthSaveLoginState(loginState) - console.log("[OAuth] 登录状态已保存") - } catch (saveError) { - console.error("[OAuth] 保存登录状态失败:", saveError) - } - - window.dispatchEvent( - new CustomEvent("ss:oauth-user-updated", { - detail: { user: userRes.data }, - }) - ) - console.log("[OAuth] 调用 onSuccess...") - onSuccess(userRes.data) - console.log("[OAuth] 调用 onClose...") - onClose() - } catch (error: any) { - console.error("[OAuth] 处理回调时发生错误:", error) - message.error(error.message || "登录失败") - } finally { - await stopCallbackServer() - clearOAuthTempState() - setLoading(false) - window.setTimeout(() => { - isProcessingCallbackRef.current = false - console.log("[OAuth] 局部锁已释放") - }, 300) - } - }, - [clearOAuthTempState, onClose, onSuccess, stopCallbackServer] - ) useEffect(() => { if (!visible) return - let unlisten: UnlistenFn | null = null - let disposed = false + const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID + if (platformId) { + // 使用 deep link 回调地址 + sectlAuth.initialize(platformId, "secscore://oauth") + SECTL_CONFIG.platformId = platformId + SECTL_CONFIG.callbackUrl = "secscore://oauth" + } + }, [visible]) + + // 监听 Deep Link 回调 + useEffect(() => { + if (!visible || !loading) return + + const handleDeepLink = async (event: Event) => { + const customEvent = event as CustomEvent<{ code: string; state: string }> + const { code, state } = customEvent.detail + + console.log("[OAuthLogin] Received deep link callback:", { code, state }) - const setupListener = async () => { try { - unlisten = await listen("oauth-callback", async (event) => { - console.log("[OAuth] Event listener 收到 payload:", event.payload) - if (event.payload) { - await handleOAuthCallback(event.payload) - } - }) - - if (disposed) { - unlisten?.() - unlisten = null + // 使用 code 交换 token + const result = await sectlAuth.exchangeCode(code, state) + if (result) { + const userInfo = await sectlAuth.getUserInfo() + onSuccess(userInfo) + onClose() } - } catch (error) { - console.error("Failed to setup OAuth callback listener:", error) + } catch (error: any) { + message.error(error.message || "登录失败") + } finally { + setLoading(false) } } - void setupListener() - + window.addEventListener("ss:oauth-deep-link", handleDeepLink) return () => { - disposed = true - if (unlisten) { - unlisten() - unlisten = null - } + window.removeEventListener("ss:oauth-deep-link", handleDeepLink) } - }, [handleOAuthCallback, visible]) + }, [visible, loading, onClose, onSuccess]) const handleOAuthLogin = async () => { - const config = getOAuthConfig() - if (!config) { + if (!SECTL_CONFIG.platformId) { message.error("OAuth 配置未设置") return } @@ -245,35 +73,17 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { setLoading(true) try { - const api = (window as any).api + const authUrl = await sectlAuth.getAuthorizationUrl() + // 使用 Tauri shell 打开系统浏览器 + await open(authUrl) - const serverRes = await api.oauthStartCallbackServer() - if (!serverRes.success) { - message.error(serverRes.message || "启动回调服务器失败") - setLoading(false) - return - } - - const callbackUrl = serverRes.data.url - setCallbackUrl(callbackUrl) - - const state = generateRandomState() - console.log("[OAuth] 生成 state:", state) - setExpectedState(state) - console.log("[OAuth] state 已设置:", getExpectedState()) - - const urlRes = await api.oauthGetAuthorizationUrl(config.platform_id, callbackUrl, state) - - if (!urlRes.success) { - message.error(urlRes.message || "获取授权链接失败") - setLoading(false) - return - } - - setCodeVerifier(urlRes.data.code_verifier) - console.log("[OAuth] code_verifier 已存储") - - await open(urlRes.data.url) + // 设置超时 + setTimeout(() => { + if (loading) { + setLoading(false) + message.warning("登录超时,请重试") + } + }, 300000) } catch (error: any) { message.error(error.message || "登录失败") setLoading(false) @@ -282,20 +92,9 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { const handleModalClose = () => { setLoading(false) - clearOAuthTempState() - void stopCallbackServer() onClose() } - const generateRandomState = (): string => { - const array = new Uint8Array(32) - window.crypto.getRandomValues(array) - return btoa(String.fromCharCode(...array)) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, "") - } - return ( = ({ visible, onComplete }) => { const primaryColor = workingTheme?.config?.tdesign?.brandColor || "#1677FF" const isDark = workingTheme?.mode === "dark" - const showOobeMessage = (type: "success" | "error" | "warning" | "info", content: string) => { - messageApi.open({ - type, - content, - style: { - marginTop: 8, - zIndex: 12000, - }, - }) - } + const showOobeMessage = useCallback( + (type: "success" | "error" | "warning" | "info", content: string) => { + messageApi.open({ + type, + content, + style: { + marginTop: 8, + zIndex: 12000, + }, + }) + }, + [messageApi] + ) useEffect(() => { if (!currentTheme) return @@ -157,7 +160,7 @@ export const OOBE: React.FC = ({ visible, onComplete }) => { : { ...base, id: "custom-default", name: t("theme.myTheme") } setWorkingTheme(editable) setPrimaryInput(editable.config?.tdesign?.brandColor || "") - }, [currentTheme]) + }, [currentTheme, t]) useEffect(() => { if (!workingTheme) return @@ -331,7 +334,7 @@ export const OOBE: React.FC = ({ visible, onComplete }) => { showOobeMessage("error", t("oobe.steps.students.unsupportedFormat")) } }, - [students, messageApi] + [students, showOobeMessage, t] ) const handleDrop = useCallback( diff --git a/src/components/ScoreSyncPanel.tsx b/src/components/ScoreSyncPanel.tsx index 2ac7687..82995bb 100644 --- a/src/components/ScoreSyncPanel.tsx +++ b/src/components/ScoreSyncPanel.tsx @@ -3,7 +3,7 @@ * 提供云端数据同步功能 */ -import React, { useState, useEffect } from "react" +import React, { useState, useEffect, useCallback } from "react" import { Card, Button, Space, Alert, Descriptions, message, Modal, Typography, Divider } from "antd" import { CloudSyncOutlined, @@ -43,7 +43,7 @@ export const ScoreSyncPanel: React.FC = ({ onGetLocalData, onRes const [hasData, setHasData] = useState(false) // 检查云端数据状态 - const checkCloudStatus = async () => { + const checkCloudStatus = useCallback(async () => { if (!isAuthenticated) return try { @@ -60,13 +60,13 @@ export const ScoreSyncPanel: React.FC = ({ onGetLocalData, onRes } finally { setLoading(false) } - } + }, [isAuthenticated]) useEffect(() => { if (isAuthenticated) { checkCloudStatus() } - }, [isAuthenticated]) + }, [isAuthenticated, checkCloudStatus]) // 上传数据到云端 const handleSyncToCloud = async () => { diff --git a/src/components/SectlCloudStorageManager.tsx b/src/components/SectlCloudStorageManager.tsx index 6fa3d57..2a9a97a 100644 --- a/src/components/SectlCloudStorageManager.tsx +++ b/src/components/SectlCloudStorageManager.tsx @@ -2,7 +2,7 @@ * SECTL 云存储管理组件 */ -import React, { useState, useEffect } from "react" +import React, { useState, useEffect, useCallback } from "react" import { Card, Table, @@ -45,7 +45,7 @@ export const SectlCloudStorageManager: React.FC = () => { const [newFilename, setNewFilename] = useState("") // 加载文件列表 - const loadFiles = async () => { + const loadFiles = useCallback(async () => { if (!isAuthenticated) return try { @@ -57,10 +57,10 @@ export const SectlCloudStorageManager: React.FC = () => { } finally { setLoading(false) } - } + }, [isAuthenticated]) // 加载存储使用情况 - const loadStorageUsage = async () => { + const loadStorageUsage = useCallback(async () => { if (!isAuthenticated) return try { @@ -69,14 +69,14 @@ export const SectlCloudStorageManager: React.FC = () => { } catch (error: any) { console.error("加载存储使用情况失败:", error) } - } + }, [isAuthenticated]) useEffect(() => { if (isAuthenticated) { loadFiles() loadStorageUsage() } - }, [isAuthenticated]) + }, [isAuthenticated, loadFiles, loadStorageUsage]) // 处理文件上传 const handleUpload = async (file: UploadFile): Promise => { @@ -95,11 +95,17 @@ export const SectlCloudStorageManager: React.FC = () => { } } - // 处理文件下载 const handleDownload = async (file: CloudFile) => { try { - const { download_url } = await sectlCloudStorage.downloadFile(file.file_id) - window.open(download_url, "_blank") + const blob = await sectlCloudStorage.downloadFile(file.file_id) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = file.filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) message.success("开始下载") } catch (error: any) { message.error(`下载失败:${error.message}`) @@ -137,7 +143,7 @@ export const SectlCloudStorageManager: React.FC = () => { const handleCreateShare = async (file: CloudFile) => { try { const share = await sectlCloudStorage.createShare(file.file_id, { - expires_in: 86400, // 1 天 + expiresIn: 86400, }) // 复制到剪贴板 diff --git a/src/components/SectlKVStorageManager.tsx b/src/components/SectlKVStorageManager.tsx index 6a44b4c..8031bd5 100644 --- a/src/components/SectlKVStorageManager.tsx +++ b/src/components/SectlKVStorageManager.tsx @@ -3,7 +3,7 @@ * 基于 SECTL-One-Stop SDK 规范实现 */ -import React, { useState, useEffect } from "react" +import React, { useState, useEffect, useCallback } from "react" import { Card, Table, @@ -61,42 +61,45 @@ export const SectlKVStorageManager: React.FC = () => { const [form] = Form.useForm() // 加载 KV 列表 - const loadKVList = async (resetOffset = false) => { - if (!isAuthenticated) return + const loadKVList = useCallback( + async (resetOffset = false) => { + if (!isAuthenticated) return - try { - setLoading(true) - const currentOffset = resetOffset ? 0 : offset - const options: ListKVOptions = { - limit: 20, - offset: currentOffset, - } - if (prefix) { - options.prefix = prefix - } + try { + setLoading(true) + const currentOffset = resetOffset ? 0 : offset + const options: ListKVOptions = { + limit: 20, + offset: currentOffset, + } + if (prefix) { + options.prefix = prefix + } - const result = await sectlKVStorage.listKV(options) - setKvList(resetOffset ? result.kv_list : [...kvList, ...result.kv_list]) - setTotal(result.total) - setHasMore(result.has_more) - if (resetOffset) { - setOffset(20) - } else { - setOffset(currentOffset + 20) + const result = await sectlKVStorage.listKV(options) + setKvList(resetOffset ? result.kv_list : [...kvList, ...result.kv_list]) + setTotal(result.total) + setHasMore(result.has_more) + if (resetOffset) { + setOffset(20) + } else { + setOffset(currentOffset + 20) + } + } catch (error: any) { + message.error(`加载键值对列表失败:${error.message}`) + } finally { + setLoading(false) } - } catch (error: any) { - message.error(`加载键值对列表失败:${error.message}`) - } finally { - setLoading(false) - } - } + }, + [isAuthenticated, offset, prefix, kvList] + ) // 初始加载 useEffect(() => { if (isAuthenticated) { loadKVList(true) } - }, [isAuthenticated, prefix]) + }, [isAuthenticated, prefix, loadKVList]) // 处理创建/更新 const handleSave = async (values: { diff --git a/src/components/SectlSettingsPanel.tsx b/src/components/SectlSettingsPanel.tsx index a331367..20a67f1 100644 --- a/src/components/SectlSettingsPanel.tsx +++ b/src/components/SectlSettingsPanel.tsx @@ -82,13 +82,21 @@ export const SectlSettingsPanel: React.FC = () => { {isAuthenticated && userInfo && ( - {userInfo.user_id} + + {userInfo.user_id || userInfo.id} + {userInfo.name} {userInfo.email} - {userInfo.permission} - {userInfo.role} - {userInfo.github_username && ( - {userInfo.github_username} + {userInfo.status && ( + {userInfo.status} + )} + {userInfo.created_at && ( + {userInfo.created_at} + )} + {userInfo.email_verified !== undefined && ( + + {userInfo.email_verified ? "已验证" : "未验证"} + )} )} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 94c88b2..b8826db 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Tabs, Card, @@ -221,10 +221,12 @@ export const Settings: React.FC<{ const [oauthLoginVisible, setOAuthLoginVisible] = useState(false) const [oauthUserInfo, setOAuthUserInfo] = useState<{ - user_id: string - email: string - name: string - github_username?: string + user_id?: string + id?: string + email?: string + name?: string + avatar?: string + avatar_url?: string } | null>(null) const permissionTag = useMemo(() => { @@ -264,58 +266,61 @@ export const Settings: React.FC<{ } } - const loadSystemFonts = async (selectedFontValue?: string) => { - setIsLoadingFonts(true) + const loadSystemFonts = useCallback( + async (selectedFontValue?: string) => { + setIsLoadingFonts(true) - const applySelectedFont = (options: FontOption[]) => { - const current = findFontOption(options, selectedFontValue || settings.font_family) - if (current) applyFontFamily(current.fontFamily) - } + const applySelectedFont = (options: FontOption[]) => { + const current = findFontOption(options, selectedFontValue || settings.font_family) + if (current) applyFontFamily(current.fontFamily) + } - const api = (window as any).api - if (!api?.getSystemFonts) { - setFontOptions(defaultFontOptions) - applySelectedFont(defaultFontOptions) - setIsLoadingFonts(false) - return - } - - try { - const res = await api.getSystemFonts() - if (res.success && Array.isArray(res.data)) { - const remoteOptions = res.data - .map((name: string) => String(name || "").trim()) - .filter((name: string) => Boolean(name)) - .map((name: string) => { - const label = formatFontDisplayName(name) - return { - value: buildSystemFontValue(name), - label, - fontFamily: buildSystemFontFamily(name), - searchText: buildFontSearchText(name, label), - } satisfies FontOption - }) - .sort((a: FontOption, b: FontOption) => - a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin") - ) - - const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions]) - setFontOptions(mergedOptions) - applySelectedFont(mergedOptions) - } else { + const api = (window as any).api + if (!api?.getSystemFonts) { setFontOptions(defaultFontOptions) applySelectedFont(defaultFontOptions) + setIsLoadingFonts(false) + return } - } catch (error) { - console.error("Failed to load system fonts:", error) - setFontOptions(defaultFontOptions) - applySelectedFont(defaultFontOptions) - } finally { - setIsLoadingFonts(false) - } - } - const loadAll = async () => { + try { + const res = await api.getSystemFonts() + if (res.success && Array.isArray(res.data)) { + const remoteOptions = res.data + .map((name: string) => String(name || "").trim()) + .filter((name: string) => Boolean(name)) + .map((name: string) => { + const label = formatFontDisplayName(name) + return { + value: buildSystemFontValue(name), + label, + fontFamily: buildSystemFontFamily(name), + searchText: buildFontSearchText(name, label), + } satisfies FontOption + }) + .sort((a: FontOption, b: FontOption) => + a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin") + ) + + const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions]) + setFontOptions(mergedOptions) + applySelectedFont(mergedOptions) + } else { + setFontOptions(defaultFontOptions) + applySelectedFont(defaultFontOptions) + } + } catch (error) { + console.error("Failed to load system fonts:", error) + setFontOptions(defaultFontOptions) + applySelectedFont(defaultFontOptions) + } finally { + setIsLoadingFonts(false) + } + }, + [settings.font_family] + ) + + const loadAll = useCallback(async () => { const api = (window as any).api if (!api) { setFontOptions(defaultFontOptions) @@ -345,7 +350,6 @@ export const Settings: React.FC<{ user_id: oauthStateRes.data.user_id, email: oauthStateRes.data.email, name: oauthStateRes.data.name, - github_username: oauthStateRes.data.github_username, }) } else { setOAuthUserInfo(null) @@ -375,7 +379,7 @@ export const Settings: React.FC<{ // ignore } } - } + }, [settings, loadSystemFonts]) const handleOAuthLogout = async () => { const api = (window as any).api @@ -564,7 +568,7 @@ export const Settings: React.FC<{ disposed = true if (unlisten) unlisten() } - }, []) + }, [loadAll]) useEffect(() => { const handleOAuthUserUpdated = (event: Event) => { @@ -582,7 +586,6 @@ export const Settings: React.FC<{ user_id: user.user_id, email: user.email, name: user.name, - github_username: user.github_username, }) } else { setOAuthUserInfo(null) @@ -1289,19 +1292,18 @@ export const Settings: React.FC<{ {oauthUserInfo ? (
- - {oauthUserInfo.name.charAt(0).toUpperCase()} + + {(oauthUserInfo.name || "U").charAt(0).toUpperCase()}
{oauthUserInfo.name}
{oauthUserInfo.email}
- {oauthUserInfo.github_username && ( -
- GitHub: @{oauthUserInfo.github_username} -
- )}
@@ -1310,7 +1312,7 @@ export const Settings: React.FC<{
{t("settings.account.userId")}: - {oauthUserInfo.user_id} + {oauthUserInfo.user_id || oauthUserInfo.id}
diff --git a/src/components/SettlementHistory.tsx b/src/components/SettlementHistory.tsx index df6936f..8834375 100644 --- a/src/components/SettlementHistory.tsx +++ b/src/components/SettlementHistory.tsx @@ -51,7 +51,7 @@ export const SettlementHistory: React.FC = () => { } finally { setLoading(false) } - }, [messageApi]) + }, [messageApi, t]) useEffect(() => { fetchSettlements() @@ -103,7 +103,7 @@ export const SettlementHistory: React.FC = () => { render: (score: number) => {score}, }, ], - [] + [t] ) if (selectedId !== null && selectedSettlement) { diff --git a/src/components/StudentManager.tsx b/src/components/StudentManager.tsx index 99fe008..4da31ab 100644 --- a/src/components/StudentManager.tsx +++ b/src/components/StudentManager.tsx @@ -18,7 +18,7 @@ import { UploadOutlined, MoreOutlined, PlusOutlined, CopyOutlined } from "@ant-d import { useTranslation } from "react-i18next" import { TagEditorDialog } from "./TagEditorDialog" import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar" -import { useResponsive, useIsMobile, useIsTablet } from "../hooks/useResponsive" +import { useIsMobile } from "../hooks/useResponsive" const createXlsxWorker = () => { return new Worker(new URL("../workers/xlsxWorker.ts", import.meta.url), { @@ -157,8 +157,6 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const addFormGroupName = Form.useWatch("group_name", form) const editFormGroupName = Form.useWatch("group_name", groupForm) const isMobile = useIsMobile() - const isTablet = useIsTablet() - const breakpoint = useResponsive() useEffect(() => { xlsxWorkerRef.current = createXlsxWorker() @@ -291,7 +289,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } } - const handleDelete = async (id: number) => { + const handleDelete = useCallback(async (id: number) => { if (!(window as any).api) return if (!canEdit) { messageApi.error(t("common.readOnly")) @@ -305,18 +303,18 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } else { messageApi.error(res.message || t("students.deleteFailed")) } - } + }, [canEdit, messageApi, t, fetchStudents]) - const handleOpenTagEditor = (student: student) => { + const handleOpenTagEditor = useCallback((student: student) => { if (!canEdit) { messageApi.error(t("common.readOnly")) return } setEditingStudent(student) setTagEditVisible(true) - } + }, [canEdit, messageApi, t]) - const handleOpenAvatarEditor = (student: student) => { + const handleOpenAvatarEditor = useCallback((student: student) => { if (!canEdit) { messageApi.error(t("common.readOnly")) return @@ -324,9 +322,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { setAvatarStudent(student) setAvatarValue(student.avatarUrl || null) setAvatarVisible(true) - } + }, [canEdit, messageApi, t]) - const handleOpenGroupEditor = (student: student) => { + const handleOpenGroupEditor = useCallback((student: student) => { if (!canEdit) { messageApi.error(t("common.readOnly")) return @@ -334,7 +332,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { setGroupEditStudent(student) groupForm.setFieldsValue({ group_name: student.group_name || "" }) setGroupEditVisible(true) - } + }, [canEdit, messageApi, t, groupForm]) const handleSaveGroup = async () => { if (!(window as any).api || !groupEditStudent) return @@ -1370,7 +1368,15 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } return baseColumns - }, [t, canEdit, isMobile, isTablet, breakpoint]) + }, [ + t, + canEdit, + isMobile, + handleDelete, + handleOpenAvatarEditor, + handleOpenGroupEditor, + handleOpenTagEditor, + ]) const existingGroupNames = useMemo(() => { const groups = new Set() diff --git a/src/components/ThemeQuickSettings.tsx b/src/components/ThemeQuickSettings.tsx index d6bac94..c434653 100644 --- a/src/components/ThemeQuickSettings.tsx +++ b/src/components/ThemeQuickSettings.tsx @@ -97,7 +97,7 @@ export const ThemeQuickSettings: React.FC = ({ compact }) => { : { ...base, id: "custom-default", name: t("theme.myTheme") } setWorkingTheme(editable) setPrimaryInput(editable.config?.tdesign?.brandColor || "") - }, [currentTheme]) + }, [currentTheme, t]) // Real-time theme preview useEffect(() => { diff --git a/src/hooks/useResponsive.ts b/src/hooks/useResponsive.ts index 02f78aa..a4fde4d 100644 --- a/src/hooks/useResponsive.ts +++ b/src/hooks/useResponsive.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react" +import { useState, useEffect, useMemo } from "react" export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl" @@ -22,7 +22,10 @@ const defaultBreakpoints: Breakpoints = { export function useResponsive(customBreakpoints?: Partial): Breakpoint { const [breakpoint, setBreakpoint] = useState("lg") - const breakpoints = { ...defaultBreakpoints, ...customBreakpoints } + const breakpoints = useMemo( + () => ({ ...defaultBreakpoints, ...customBreakpoints }), + [customBreakpoints] + ) useEffect(() => { const updateBreakpoint = () => { diff --git a/src/preload/types.ts b/src/preload/types.ts index 74c0ccf..cdf24a0 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -642,6 +642,11 @@ const api = { callback(event.payload || {}) }) }, + onDeepLink: (callback: (url: string) => void): Promise => { + return listen("deep-link://new-url", (event) => { + callback(event.payload) + }) + }, // Logger queryLogs: ( diff --git a/src/services/sectl.ts b/src/services/sectl.ts index f6c9ff1..e69f214 100644 --- a/src/services/sectl.ts +++ b/src/services/sectl.ts @@ -4,21 +4,20 @@ export { sectlAuth, SECTL_CONFIG } from "./sectlAuth" -export type { TokenData, UserInfo } from "./sectlAuth" +export type { TokenData, TokenIntrospection, UserInfo } from "./sectlAuth" export { sectlCloudStorage } from "./sectlCloudStorage" -export type { CloudFile, ShareLink, StorageUsage } from "./sectlCloudStorage" +export type { CloudFile, ShareLink, KVData, StorageUsage } from "./sectlCloudStorage" export { sectlKVStorage } from "./sectlKVStorage" -export type { KVData } from "./sectlCloudStorage" +export type { KVItem, ListKVOptions } from "./sectlKVStorage" export { sectlNotification } from "./sectlNotification" export type { Notification, SendNotificationParams } from "./sectlNotification" -// 积分数据同步服务 export { scoreSyncService } from "./scoreSyncService" export type { ScoreData, ScoreEvent, SyncedData } from "./scoreSyncService" diff --git a/src/services/sectlAuth.ts b/src/services/sectlAuth.ts index 77bc9ec..119d98c 100644 --- a/src/services/sectlAuth.ts +++ b/src/services/sectlAuth.ts @@ -1,181 +1,203 @@ /** * SECTL OAuth 2.0 认证服务 - * 基于 SECTL-One-Stop 项目的 OAuth API 实现 + * 基于 SECTL-One-Stop SDK 的 OAuth API 实现 * 支持 PKCE (Proof Key for Code Exchange) 流程 */ -import { Client, Account } from "appwrite" -import CryptoJS from "crypto-js" - // SECTL API 配置 export const SECTL_CONFIG = { - baseUrl: "https://appwrite.sectl.top", - authUrl: "https://sectl.top", - platformId: "", // 需要在设置中配置 - callbackUrl: "http://localhost:5173/auth/callback", + baseUrl: "https://appwrite.sectl.cn", + authUrl: "https://sectl.cn", + platformId: "", + callbackUrl: "secscore://oauth", callbackPort: 5173, } -// Token 数据类型 +// Token 数据类型 (与 SDK TokenData 对齐) export interface TokenData { access_token: string - refresh_token: string - token_type: string - expires_in: number - user_id: string + refresh_token?: string + token_type?: string + expires_in?: number + scope?: string + user_id?: string } -// 用户信息类型 +// Token 验证结果 (与 SDK TokenIntrospection 对齐) +export interface TokenIntrospection { + active: boolean + scope?: string + user_id?: string + client_id?: string + exp?: number + iat?: number +} + +// 用户信息类型 (与 SDK UserInfoData 对齐) export interface UserInfo { - user_id: string - email: string - name: string - github_username?: string - permission: string - role: string + user_id?: string + id?: string + email?: string + name?: string + avatar?: string avatar_url?: string - background_url?: string - bio: string - tags: string[] - platform_id?: string - login_time?: string + created_at?: string + updated_at?: string + email_verified?: boolean + status?: string + metadata?: Record } // PKCE 相关工具函数 -function generateCodeVerifier(): string { +async function generateCodeVerifier(): Promise { const array = new Uint8Array(32) crypto.getRandomValues(array) return base64URLEncode(array) } -function generateCodeChallenge(verifier: string): string { - const sha256 = CryptoJS.SHA256(verifier) - return base64URLEncode(CryptoJS.enc.Base64.parse(sha256.toString(CryptoJS.enc.Base64))) +async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(verifier) + const digest = await crypto.subtle.digest("SHA-256", data) + return base64URLEncode(new Uint8Array(digest)) } -function base64URLEncode(buffer: Uint8Array | CryptoJS.lib.WordArray): string { - let base64: string - if (buffer instanceof Uint8Array) { - base64 = btoa(String.fromCharCode(...buffer)) - } else { - base64 = buffer.toString(CryptoJS.enc.Base64) +function base64URLEncode(buffer: Uint8Array): string { + let binary = "" + for (let i = 0; i < buffer.length; i++) { + binary += String.fromCharCode(buffer[i]) + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function extractUserIdFromJwt(accessToken: string): string | null { + try { + const parts = accessToken.split(".") + if (parts.length !== 3) return null + let payload = parts[1] + const padding = 4 - (payload.length % 4) + if (padding !== 4) payload += "=".repeat(padding) + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")) + const claims = JSON.parse(decoded) + return claims.user_id || null + } catch { + return null + } +} + +function extractPlatformIdFromJwt(accessToken: string): string | null { + try { + const parts = accessToken.split(".") + if (parts.length !== 3) return null + let payload = parts[1] + const padding = 4 - (payload.length % 4) + if (padding !== 4) payload += "=".repeat(padding) + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")) + const claims = JSON.parse(decoded) + return claims.platform_id || null + } catch { + return null } - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") } class SectlAuthService { - private client: Client - private tokenData: TokenData | null = null - private codeVerifier: string = "" + private accessToken: string | null = null + private refreshToken: string | null = null + private tokenExpiresAt: number | null = null + private userId: string | null = null + private codeVerifier: string | null = null constructor() { - this.client = new Client() - new Account(this.client) this.loadToken() } - /** - * 初始化客户端配置 - */ initialize(platformId: string, callbackUrl?: string) { SECTL_CONFIG.platformId = platformId if (callbackUrl) { SECTL_CONFIG.callbackUrl = callbackUrl } - - this.client.setEndpoint(SECTL_CONFIG.baseUrl).setProject("sectl-cloud") // 项目 ID - return this } - /** - * 生成授权 URL - */ - getAuthorizationUrl(scope: string[] = ["user:read"]): string { - this.codeVerifier = generateCodeVerifier() - const codeChallenge = generateCodeChallenge(this.codeVerifier) + async getAuthorizationUrl(scope?: string[]): Promise { + const state = this.generateRandomState() + this.codeVerifier = await generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(this.codeVerifier) + + // 保存 code_verifier 到 localStorage,以便 deep link 回调时使用 + localStorage.setItem("sectl_code_verifier", this.codeVerifier) const params = new URLSearchParams({ client_id: SECTL_CONFIG.platformId, redirect_uri: SECTL_CONFIG.callbackUrl, response_type: "code", + state, code_challenge: codeChallenge, code_challenge_method: "S256", - scope: scope.join(" "), }) + if (scope && scope.length > 0) { + params.set("scope", scope.join(" ")) + } + return `${SECTL_CONFIG.authUrl}/oauth/authorize?${params.toString()}` } - /** - * 执行完整的 OAuth 授权流程 - */ - async authorize(scope: string[] = ["user:read"]): Promise { - // 生成 PKCE 参数 - this.codeVerifier = generateCodeVerifier() - // codeChallenge 在 getAuthorizationUrl 中生成 + async authorize(scope?: string[]): Promise { + this.codeVerifier = await generateCodeVerifier() - // 构建授权 URL - const authUrl = this.getAuthorizationUrl(scope) - - // 在新窗口中打开授权页面 + const authUrl = await this.getAuthorizationUrl(scope) const authWindow = window.open(authUrl, "_blank", "width=600,height=700") - // 等待回调 return new Promise((resolve, reject) => { - // 监听授权回调 - const checkCallback = async () => { - try { - // 检查是否有授权码(通过 postMessage 或 URL 参数) - window.addEventListener("message", async (event) => { - if (event.data.type === "oauth-callback" && event.data.code) { - try { - const token = await this.exchangeCodeForToken(event.data.code, scope) - authWindow?.close() - resolve(token) - } catch (error) { - reject(error) - } - } - }) - - // 轮询检查窗口关闭 - const checkInterval = setInterval(() => { - if (authWindow?.closed) { - clearInterval(checkInterval) - reject(new Error("授权窗口已关闭")) - } - }, 1000) - } catch (error) { - reject(error) + window.addEventListener("message", async (event) => { + if (event.data.type === "oauth-callback" && event.data.code) { + try { + const token = await this.exchangeCodeForToken(event.data.code, scope) + authWindow?.close() + resolve(token) + } catch (error) { + reject(error) + } } - } + }) - checkCallback() + const checkInterval = setInterval(() => { + if (authWindow?.closed) { + clearInterval(checkInterval) + reject(new Error("授权窗口已关闭")) + } + }, 1000) }) } - /** - * 使用授权码换取 Token - */ - async exchangeCodeForToken(code: string, scope: string[] = ["user:read"]): Promise { - const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token` + async exchangeCodeForToken(code: string, scope?: string[]): Promise { + if (!this.codeVerifier) { + throw new Error("缺少 code_verifier,请先调用 getAuthorizationUrl()") + } - const payload = { + const deviceUuid = this.generateDeviceUuid() + + const payload: Record = { grant_type: "authorization_code", code, client_id: SECTL_CONFIG.platformId, redirect_uri: SECTL_CONFIG.callbackUrl, code_verifier: this.codeVerifier, - scope: scope.join(" "), + device_uuid: deviceUuid, + ip_address: "127.0.0.1", } + if (scope && scope.length > 0) { + payload.scope = scope.join(" ") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token` + try { const response = await fetch(url, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) @@ -185,9 +207,55 @@ class SectlAuthService { } const data: TokenData = await response.json() - this.tokenData = data - this.saveToken() - + this.saveToken(data) + return data + } catch (error) { + console.error("Token 交换失败:", error) + throw error + } + } + + // 用于 Deep Link 回调的 code 交换 + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async exchangeCode(code: string, _state: string): Promise { + // 从 localStorage 恢复 code_verifier + const savedVerifier = localStorage.getItem("sectl_code_verifier") + if (savedVerifier) { + this.codeVerifier = savedVerifier + } + + if (!this.codeVerifier) { + throw new Error("缺少 code_verifier,请重新发起登录") + } + + const deviceUuid = this.generateDeviceUuid() + + const payload = { + grant_type: "authorization_code", + code, + client_id: SECTL_CONFIG.platformId, + redirect_uri: SECTL_CONFIG.callbackUrl, + code_verifier: this.codeVerifier, + device_uuid: deviceUuid, + ip_address: "127.0.0.1", + } + + const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token` + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error_description || "Token 交换失败") + } + + const data: TokenData = await response.json() + this.saveToken(data) return data } catch (error) { console.error("Token 交换失败:", error) @@ -195,11 +263,8 @@ class SectlAuthService { } } - /** - * 获取用户信息 - */ async getUserInfo(): Promise { - if (!this.tokenData?.access_token) { + if (!this.accessToken) { throw new Error("未授权,请先登录") } @@ -207,9 +272,7 @@ class SectlAuthService { try { const response = await fetch(url, { - headers: { - Authorization: `Bearer ${this.tokenData.access_token}`, - }, + headers: { Authorization: `Bearer ${this.accessToken}` }, }) if (!response.ok) { @@ -224,12 +287,8 @@ class SectlAuthService { } } - /** - * 验证 Token - */ - async introspectToken(token?: string): Promise<{ active: boolean; user_id?: string }> { - const tokenToCheck = token || this.tokenData?.access_token - + async introspectToken(token?: string): Promise { + const tokenToCheck = token || this.accessToken if (!tokenToCheck) { return { active: false } } @@ -239,9 +298,7 @@ class SectlAuthService { try { const response = await fetch(url, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: tokenToCheck, client_id: SECTL_CONFIG.platformId, @@ -253,34 +310,32 @@ class SectlAuthService { } return await response.json() - } catch (error) { - console.error("Token 验证失败:", error) + } catch { return { active: false } } } - /** - * 刷新 Token - */ async refreshAccessToken(): Promise { - if (!this.tokenData?.refresh_token) { - throw new Error("没有刷新令牌") + if (!this.refreshToken) { + throw new Error("没有 refresh_token,无法刷新") } - const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token` + const deviceUuid = this.generateDeviceUuid() const payload = { grant_type: "refresh_token", - refresh_token: this.tokenData.refresh_token, + refresh_token: this.refreshToken, client_id: SECTL_CONFIG.platformId, + device_uuid: deviceUuid, + ip_address: "127.0.0.1", } + const url = `${SECTL_CONFIG.baseUrl}/api/oauth/refresh` + try { const response = await fetch(url, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) @@ -290,9 +345,7 @@ class SectlAuthService { } const data: TokenData = await response.json() - this.tokenData = data - this.saveToken() - + this.saveToken(data) return data } catch (error) { console.error("Token 刷新失败:", error) @@ -300,89 +353,147 @@ class SectlAuthService { } } - /** - * 登出(撤销 Token) - */ async logout(): Promise { - if (!this.tokenData?.access_token) { - return - } - const url = `${SECTL_CONFIG.baseUrl}/api/oauth/logout` try { - await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${this.tokenData.access_token}`, - }, - }) + if (this.accessToken) { + await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ client_id: SECTL_CONFIG.platformId }), + }) + } } catch (error) { console.error("登出失败:", error) } finally { - this.tokenData = null + this.accessToken = null + this.refreshToken = null + this.tokenExpiresAt = null + this.userId = null this.clearToken() } } - /** - * 获取当前 Token - */ - getToken(): TokenData | null { - return this.tokenData - } - - /** - * 获取 Access Token - */ - getAccessToken(): string | null { - return this.tokenData?.access_token || null - } - - /** - * 检查是否已授权 - */ - isAuthenticated(): boolean { - return !!this.tokenData?.access_token - } - - /** - * 保存 Token 到本地存储 - */ - private saveToken(): void { - if (this.tokenData) { - localStorage.setItem("sectl_token", JSON.stringify(this.tokenData)) - localStorage.setItem("sectl_code_verifier", this.codeVerifier) + private saveToken(tokenData: TokenData): void { + const rawAccessToken = tokenData.access_token + if (rawAccessToken.includes("|")) { + const parts = rawAccessToken.split("|") + this.accessToken = parts[0] + this.refreshToken = tokenData.refresh_token || parts[1] || null + } else { + this.accessToken = rawAccessToken + this.refreshToken = tokenData.refresh_token || null } + + this.userId = tokenData.user_id || extractUserIdFromJwt(this.accessToken) + + const jwtPlatformId = extractPlatformIdFromJwt(this.accessToken) + if (jwtPlatformId && !SECTL_CONFIG.platformId) { + SECTL_CONFIG.platformId = jwtPlatformId + } + + if (tokenData.expires_in) { + this.tokenExpiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in + } + + const storableData: TokenData = { + access_token: rawAccessToken, + refresh_token: tokenData.refresh_token, + token_type: tokenData.token_type, + expires_in: tokenData.expires_in, + scope: tokenData.scope, + user_id: this.userId || undefined, + } + + localStorage.setItem("sectl_token", JSON.stringify(storableData)) + // 登录成功后清除 code_verifier + localStorage.removeItem("sectl_code_verifier") } - /** - * 从本地存储加载 Token - */ private loadToken(): void { try { const tokenStr = localStorage.getItem("sectl_token") - const codeVerifier = localStorage.getItem("sectl_code_verifier") - if (tokenStr) { - this.tokenData = JSON.parse(tokenStr) - this.codeVerifier = codeVerifier || "" + const tokenData: TokenData = JSON.parse(tokenStr) + + const rawAccessToken = tokenData.access_token + if (rawAccessToken.includes("|")) { + const parts = rawAccessToken.split("|") + this.accessToken = parts[0] + this.refreshToken = tokenData.refresh_token || parts[1] || null + } else { + this.accessToken = rawAccessToken + this.refreshToken = tokenData.refresh_token || null + } + + this.userId = tokenData.user_id || extractUserIdFromJwt(this.accessToken) + + if (tokenData.expires_in) { + this.tokenExpiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in + } } } catch (error) { console.error("加载 Token 失败:", error) } } - /** - * 清除 Token - */ private clearToken(): void { localStorage.removeItem("sectl_token") localStorage.removeItem("sectl_code_verifier") - this.tokenData = null - this.codeVerifier = "" + this.accessToken = null + this.refreshToken = null + this.tokenExpiresAt = null + this.userId = null + this.codeVerifier = null + } + + getToken(): TokenData | null { + if (!this.accessToken) return null + return { + access_token: this.accessToken, + refresh_token: this.refreshToken || undefined, + user_id: this.userId || undefined, + } + } + + getAccessToken(): string | null { + return this.accessToken + } + + getUserId(): string | null { + return this.userId + } + + isAuthenticated(): boolean { + return !!this.accessToken + } + + isTokenExpired(): boolean { + if (!this.tokenExpiresAt) return false + return Date.now() / 1000 >= this.tokenExpiresAt + } + + private generateRandomState(): string { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return base64URLEncode(array) + } + + private generateDeviceUuid(): string { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID() + } + const array = new Uint8Array(16) + crypto.getRandomValues(array) + array[6] = (array[6] & 0x0f) | 0x40 + array[8] = (array[8] & 0x3f) | 0x80 + const hex = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` } } -// 导出单例 export const sectlAuth = new SectlAuthService() diff --git a/src/services/sectlCloudStorage.test.ts b/src/services/sectlCloudStorage.test.ts new file mode 100644 index 0000000..dbb7c1f --- /dev/null +++ b/src/services/sectlCloudStorage.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { sectlCloudStorage } from "./sectlCloudStorage" +import { SECTL_CONFIG, sectlAuth } from "./sectlAuth" + +// Mock fetch and sectlAuth +global.fetch = vi.fn() +vi.mock("./sectlAuth", () => ({ + SECTL_CONFIG: { + baseUrl: "https://api.sectl.cn", + platformId: "test-platform-id", + }, + sectlAuth: { + getAccessToken: vi.fn(), + getUserId: vi.fn(), + }, +})) + +describe("sectlCloudStorage", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(sectlAuth.getAccessToken).mockReturnValue("test-access-token") + vi.mocked(sectlAuth.getUserId).mockReturnValue("test-user-id") + }) + + describe("uploadFile", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { success: true, file_id: "file-123" } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + const file = new File(["test"], "test.txt", { type: "text/plain" }) + await sectlCloudStorage.uploadFile(file, { filename: "test.txt" }) + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/upload`, + expect.objectContaining({ + method: "POST", + }) + ) + }) + }) + + describe("listFiles", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { files: [], total: 0 } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlCloudStorage.listFiles({ limit: 10, offset: 0 }) + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`${SECTL_CONFIG.baseUrl}/api/cloud/files`), + expect.any(Object) + ) + }) + }) + + describe("getFileInfo", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { file_id: "file-123", name: "test.txt" } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlCloudStorage.getFileInfo("file-123") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/files/file-123`, + expect.any(Object) + ) + }) + }) + + describe("downloadFile", () => { + it("should call correct API endpoint", async () => { + const mockBlob = new Blob(["test"]) + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + blob: async () => mockBlob, + } as Response) + + await sectlCloudStorage.downloadFile("file-123") + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`${SECTL_CONFIG.baseUrl}/api/cloud/files/file-123/download`), + expect.any(Object) + ) + }) + }) + + describe("renameFile", () => { + it("should call correct API endpoint with PUT method", async () => { + const mockResponse = { file_id: "file-123", filename: "new.txt" } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlCloudStorage.renameFile("file-123", "new.txt") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/files/file-123`, + expect.objectContaining({ + method: "PUT", + }) + ) + }) + }) + + describe("createShare", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { share_id: "share-123", share_url: "https://example.com" } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlCloudStorage.createShare("file-123") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/share`, + expect.objectContaining({ + method: "POST", + }) + ) + }) + }) + + describe("disableShare", () => { + it("should call correct API endpoint", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + } as Response) + + await sectlCloudStorage.disableShare("share-123") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/shares/share-123/disable`, + expect.objectContaining({ + method: "POST", + }) + ) + }) + }) + + describe("enableShare", () => { + it("should call correct API endpoint", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + } as Response) + + await sectlCloudStorage.enableShare("share-123") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/shares/share-123/enable`, + expect.objectContaining({ + method: "POST", + }) + ) + }) + }) +}) diff --git a/src/services/sectlCloudStorage.ts b/src/services/sectlCloudStorage.ts index 365711f..e5e5fdb 100644 --- a/src/services/sectlCloudStorage.ts +++ b/src/services/sectlCloudStorage.ts @@ -1,11 +1,10 @@ /** * SECTL 云存储服务 - * 提供文件上传、下载、管理、分享等功能 + * 基于 SECTL-One-Stop SDK 的云存储 API 实现 */ import { SECTL_CONFIG, sectlAuth } from "./sectlAuth" -// 文件信息类型 export interface CloudFile { file_id: string filename: string @@ -18,7 +17,6 @@ export interface CloudFile { thumbnail_url?: string } -// 分享信息类型 export interface ShareLink { share_id: string share_url: string @@ -31,18 +29,16 @@ export interface ShareLink { status: "active" | "disabled" | "expired" } -// KV 存储类型 export interface KVData { kv_id: string key: string - value: any + value: unknown is_json: boolean size: number created_at: string updated_at: string } -// 存储使用情况 export interface StorageUsage { used_storage: number used_storage_formatted: string @@ -55,542 +51,353 @@ export interface StorageUsage { } class SectlCloudStorageService { - /** - * 获取文件列表 - */ - async listFiles(options?: { - folder_id?: string - limit?: number - offset?: number - }): Promise<{ files: CloudFile[]; total: number; has_more: boolean }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } - - const params = new URLSearchParams({ - client_id: SECTL_CONFIG.platformId, - user_id: sectlAuth.getToken()?.user_id || "", - limit: String(options?.limit || 100), - offset: String(options?.offset || 0), - }) - - if (options?.folder_id) { - params.append("folder_id", options.folder_id) - } - - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files?${params.toString()}` - - try { - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "获取文件列表失败") - } - - return await response.json() - } catch (error) { - console.error("获取文件列表失败:", error) - throw error - } + private getAccessToken(): string { + const token = sectlAuth.getAccessToken() + if (!token) throw new Error("未授权,请先登录") + return token + } + + private getAuthHeaders(): Record { + return { Authorization: `Bearer ${this.getAccessToken()}` } + } + + private buildParams(extra?: Record): URLSearchParams { + const params = new URLSearchParams({ client_id: SECTL_CONFIG.platformId }) + const userId = sectlAuth.getUserId() + if (userId) params.append("user_id", userId) + if (extra) { + for (const [key, value] of Object.entries(extra)) { + params.append(key, value) + } + } + return params } - /** - * 上传文件 - */ async uploadFile( file: File | Blob, - options?: { - folder_id?: string - filename?: string - } - ): Promise<{ - success: boolean - file_id: string - filename: string - size: number - size_formatted: string - }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } - + options?: { filename?: string; description?: string; tags?: string[] } + ): Promise { const formData = new FormData() + formData.append("file", file, options?.filename) formData.append("client_id", SECTL_CONFIG.platformId) - formData.append("user_id", sectlAuth.getToken()?.user_id || "") - if (options?.folder_id) { - formData.append("folder_id", options.folder_id) - } + const userId = sectlAuth.getUserId() + if (userId) formData.append("user_id", userId) + if (options?.description) formData.append("description", options.description) + if (options?.tags) formData.append("tags", JSON.stringify(options.tags)) - // 如果是 File 对象,直接使用;否则需要指定文件名 - if (file instanceof File) { - formData.append("file", file) - } else { - const filename = options?.filename || "upload_" + Date.now() - formData.append("file", file, filename) - } - - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/upload` - - try { - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - }, - body: formData, - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "文件上传失败") - } - - return await response.json() - } catch (error) { - console.error("文件上传失败:", error) - throw error - } - } - - /** - * 上传文件(Base64 格式) - */ - async uploadFileBase64( - base64Data: string, - filename: string, - mimeType: string = "application/octet-stream", - options?: { - folder_id?: string - } - ): Promise<{ - success: boolean - file_id: string - filename: string - size: number - }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } - - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/upload` - - const payload = { - client_id: SECTL_CONFIG.platformId, - user_id: sectlAuth.getToken()?.user_id || "", - filename, - mime_type: mimeType, - file: base64Data, - folder_id: options?.folder_id, - } - - try { - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "文件上传失败") - } - - return await response.json() - } catch (error) { - console.error("文件上传失败:", error) - throw error - } - } - - /** - * 获取文件下载链接 - */ - async downloadFile(fileId: string): Promise<{ download_url: string; expires_at: string }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } - - const params = new URLSearchParams({ - client_id: SECTL_CONFIG.platformId, + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/upload`, { + method: "POST", + headers: this.getAuthHeaders(), + body: formData, }) - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/download?${params.toString()}` - - try { - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "获取下载链接失败") - } - - return await response.json() - } catch (error) { - console.error("获取下载链接失败:", error) - throw error + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "文件上传失败") } + return response.json() } - /** - * 获取文件预览链接 - */ - async previewFile(fileId: string): Promise<{ view_url: string; expires_at: string }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } + async listFiles(options?: { + folder?: string + limit?: number + offset?: number + }): Promise<{ files: CloudFile[]; total: number }> { + const params = this.buildParams() + if (options?.folder) params.append("folder", options.folder) + if (options?.limit) params.append("limit", String(options.limit)) + if (options?.offset) params.append("offset", String(options.offset)) - const params = new URLSearchParams({ - client_id: SECTL_CONFIG.platformId, + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files?${params}`, { + headers: this.getAuthHeaders(), }) - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/preview?${params.toString()}` + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "获取文件列表失败") + } + return response.json() + } - try { - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) + async getFileInfo(fileId: string): Promise { + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`, { + headers: this.getAuthHeaders(), + }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "获取预览链接失败") - } + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "获取文件信息失败") + } + return response.json() + } - return await response.json() - } catch (error) { - console.error("获取预览链接失败:", error) - throw error + async downloadFile(fileId: string): Promise { + const params = this.buildParams() + const response = await fetch( + `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/download?${params}`, + { headers: this.getAuthHeaders() } + ) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "下载文件失败") + } + return response.blob() + } + + async previewFile(fileId: string): Promise { + const params = this.buildParams() + const response = await fetch( + `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/preview?${params}`, + { headers: this.getAuthHeaders() } + ) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "预览文件失败") + } + return response.json() + } + + async deleteFile(fileId: string, userId?: string): Promise { + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`, { + method: "DELETE", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify({ + client_id: SECTL_CONFIG.platformId, + ...(userId ? { user_id: userId } : {}), + }), + }) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "删除文件失败") } } - /** - * 删除文件 - */ - async deleteFile(fileId: string): Promise<{ success: boolean; message: string }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } - - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}` - - try { - const response = await fetch(url, { - method: "DELETE", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: SECTL_CONFIG.platformId, - user_id: sectlAuth.getToken()?.user_id || "", - }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "删除文件失败") - } - - return await response.json() - } catch (error) { - console.error("删除文件失败:", error) - throw error + async renameFile(fileId: string, newFilename: string): Promise { + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`, { + method: "PUT", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify({ + client_id: SECTL_CONFIG.platformId, + filename: newFilename, + }), + }) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "重命名文件失败") } + return response.json() } - /** - * 重命名文件 - */ - async renameFile( - fileId: string, - newFilename: string - ): Promise<{ - file_id: string - filename: string - updated_at: string - }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } - - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}` - - try { - const response = await fetch(url, { - method: "PUT", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: SECTL_CONFIG.platformId, - user_id: sectlAuth.getToken()?.user_id || "", - filename: newFilename, - }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "重命名文件失败") - } - - return await response.json() - } catch (error) { - console.error("重命名文件失败:", error) - throw error - } - } - - /** - * 创建分享链接 - */ async createShare( fileId: string, - options?: { - expires_in?: number // 秒,0 表示永久 - password?: string - } - ): Promise<{ - share_id: string - share_url: string - file_id: string - filename: string - expires_at?: string - has_password: boolean - }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } - - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/share` - - try { - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: SECTL_CONFIG.platformId, - user_id: sectlAuth.getToken()?.user_id || "", - file_id: fileId, - expires_in: options?.expires_in || 86400, // 默认 1 天 - password: options?.password, - }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "创建分享失败") - } - - return await response.json() - } catch (error) { - console.error("创建分享失败:", error) - throw error + options?: { expiresIn?: number; password?: string; userId?: string } + ): Promise { + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/share`, { + method: "POST", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify({ + client_id: SECTL_CONFIG.platformId, + file_id: fileId, + ...(options?.expiresIn ? { expires_in: options.expiresIn } : {}), + ...(options?.password ? { password: options.password } : {}), + ...(options?.userId ? { user_id: options.userId } : {}), + }), + }) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "创建分享链接失败") } + return response.json() } - /** - * 获取分享列表 - */ async listShares(): Promise<{ shares: ShareLink[]; total: number }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } - - const params = new URLSearchParams({ - client_id: SECTL_CONFIG.platformId, - user_id: sectlAuth.getToken()?.user_id || "", + const params = this.buildParams() + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares?${params}`, { + headers: this.getAuthHeaders(), }) - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares?${params.toString()}` + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "获取分享列表失败") + } + return response.json() + } - try { - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) + async disableShare(shareId: string, userId?: string): Promise { + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/disable`, { + method: "POST", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify({ + client_id: SECTL_CONFIG.platformId, + ...(userId ? { user_id: userId } : {}), + }), + }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "获取分享列表失败") - } - - return await response.json() - } catch (error) { - console.error("获取分享列表失败:", error) - throw error + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "禁用分享失败") } } - /** - * 禁用分享 - */ - async disableShare(shareId: string): Promise<{ success: boolean; message: string }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } + async enableShare(shareId: string, userId?: string): Promise { + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/enable`, { + method: "POST", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify({ + client_id: SECTL_CONFIG.platformId, + ...(userId ? { user_id: userId } : {}), + }), + }) - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/disable` - - try { - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: SECTL_CONFIG.platformId, - user_id: sectlAuth.getToken()?.user_id || "", - }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "禁用分享失败") - } - - return await response.json() - } catch (error) { - console.error("禁用分享失败:", error) - throw error + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "启用分享失败") } } - /** - * 启用分享 - */ - async enableShare(shareId: string): Promise<{ success: boolean; message: string }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } + async deleteShare(shareId: string, userId?: string): Promise { + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}`, { + method: "DELETE", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify({ + client_id: SECTL_CONFIG.platformId, + ...(userId ? { user_id: userId } : {}), + }), + }) - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/enable` - - try { - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: SECTL_CONFIG.platformId, - user_id: sectlAuth.getToken()?.user_id || "", - }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "启用分享失败") - } - - return await response.json() - } catch (error) { - console.error("启用分享失败:", error) - throw error + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "删除分享失败") } } - /** - * 删除分享 - */ - async deleteShare(shareId: string): Promise<{ success: boolean; message: string }> { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } - - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}` - - try { - const response = await fetch(url, { - method: "DELETE", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: SECTL_CONFIG.platformId, - user_id: sectlAuth.getToken()?.user_id || "", - }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "删除分享失败") - } - - return await response.json() - } catch (error) { - console.error("删除分享失败:", error) - throw error - } - } - - /** - * 获取存储使用情况 - */ async getStorageUsage(): Promise { - const accessToken = sectlAuth.getAccessToken() - if (!accessToken) { - throw new Error("未授权,请先登录") - } - - const params = new URLSearchParams({ - client_id: SECTL_CONFIG.platformId, - user_id: sectlAuth.getToken()?.user_id || "", + const params = this.buildParams() + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/storage/usage?${params}`, { + headers: this.getAuthHeaders(), }) - const url = `${SECTL_CONFIG.baseUrl}/api/cloud/storage/usage?${params.toString()}` + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "获取存储使用情况失败") + } + return response.json() + } - try { - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) + async setKV( + key: string, + value: string | Record, + options?: { expiresIn?: number; userId?: string; description?: string } + ): Promise { + const isJson = typeof value === "object" + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv`, { + method: "POST", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify({ + client_id: SECTL_CONFIG.platformId, + key, + value: isJson ? JSON.stringify(value) : value, + is_json: isJson, + ...(options?.expiresIn ? { expires_in: options.expiresIn } : {}), + ...(options?.userId ? { user_id: options.userId } : {}), + ...(options?.description ? { description: options.description } : {}), + }), + }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.error_description || "获取存储使用情况失败") + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "设置 KV 失败") + } + return response.json() + } + + async getKV(key: string): Promise { + const response = await fetch( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`, + { + method: "GET", + headers: this.getAuthHeaders(), } + ) - return await response.json() - } catch (error) { - console.error("获取存储使用情况失败:", error) - throw error + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "获取 KV 失败") + } + return response.json() + } + + async updateKVField( + key: string, + field: string, + value: unknown, + userId?: string + ): Promise { + const response = await fetch( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify({ + client_id: SECTL_CONFIG.platformId, + field, + value: typeof value === "object" ? JSON.stringify(value) : value, + ...(userId ? { user_id: userId } : {}), + }), + } + ) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "更新 KV 字段失败") + } + return response.json() + } + + async listKV(options?: { + prefix?: string + limit?: number + offset?: number + }): Promise<{ data: KVData[]; total: number }> { + const params = this.buildParams() + if (options?.prefix) params.append("prefix", options.prefix) + if (options?.limit) params.append("limit", String(options.limit)) + if (options?.offset) params.append("offset", String(options.offset)) + + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv?${params}`, { + headers: this.getAuthHeaders(), + }) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "获取 KV 列表失败") + } + return response.json() + } + + async deleteKV(key: string, userId?: string): Promise { + const response = await fetch( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`, + { + method: "DELETE", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify({ + client_id: SECTL_CONFIG.platformId, + ...(userId ? { user_id: userId } : {}), + }), + } + ) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "删除 KV 失败") } } } -// 导出单例 export const sectlCloudStorage = new SectlCloudStorageService() diff --git a/src/services/sectlKVStorage.test.ts b/src/services/sectlKVStorage.test.ts new file mode 100644 index 0000000..f63f961 --- /dev/null +++ b/src/services/sectlKVStorage.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { sectlKVStorage } from "./sectlKVStorage" +import { SECTL_CONFIG, sectlAuth } from "./sectlAuth" + +// Mock fetch and sectlAuth +global.fetch = vi.fn() +vi.mock("./sectlAuth", () => ({ + SECTL_CONFIG: { + baseUrl: "https://api.sectl.cn", + platformId: "test-platform-id", + }, + sectlAuth: { + getAccessToken: vi.fn(), + getUserId: vi.fn(), + }, +})) + +describe("sectlKVStorage", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(sectlAuth.getAccessToken).mockReturnValue("test-access-token") + vi.mocked(sectlAuth.getUserId).mockReturnValue("test-user-id") + }) + + describe("setKV", () => { + it("should call correct API endpoint for string value", async () => { + const mockResponse = { success: true } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlKVStorage.setKV("test-key", "test-value") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv`, + expect.objectContaining({ + method: "POST", + body: expect.stringContaining('"key":"test-key"'), + }) + ) + }) + + it("should call correct API endpoint for object value", async () => { + const mockResponse = { success: true } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlKVStorage.setKV("test-key", { foo: "bar" }) + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv`, + expect.objectContaining({ + method: "POST", + body: expect.stringContaining('"key":"test-key"'), + }) + ) + }) + }) + + describe("getKV", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { value: "test-value" } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlKVStorage.getKV("test-key") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv/test-key?client_id=test-platform-id&user_id=test-user-id`, + expect.any(Object) + ) + }) + }) + + describe("deleteKV", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { success: true } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlKVStorage.deleteKV("test-key") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv/test-key`, + expect.objectContaining({ + method: "DELETE", + }) + ) + }) + }) + + describe("updateKVField", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { success: true } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlKVStorage.updateKVField("test-key", "field", "value") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv/test-key`, + expect.objectContaining({ + method: "PATCH", + }) + ) + }) + }) +}) diff --git a/src/services/sectlKVStorage.ts b/src/services/sectlKVStorage.ts index be71fe4..332120a 100644 --- a/src/services/sectlKVStorage.ts +++ b/src/services/sectlKVStorage.ts @@ -1,3 +1,8 @@ +/** + * SECTL KV 存储服务 + * 基于 SECTL-One-Stop SDK 的云存储 KV API 实现 + */ + import { SECTL_CONFIG, sectlAuth } from "./sectlAuth" export interface KVItem { @@ -16,58 +21,26 @@ export interface ListKVOptions { } class SectlKVStorageService { - private get platformId(): string { - return SECTL_CONFIG.platformId + private getAccessToken(): string { + const token = sectlAuth.getAccessToken() + if (!token) throw new Error("未授权,请先登录") + return token } private getAuthHeaders(): Record { - const token = sectlAuth.getAccessToken() - if (!token) throw new Error("未授权,请先登录") - return { Authorization: `Bearer ${token}` } + return { Authorization: `Bearer ${this.getAccessToken()}` } } - private async makeRequest( - method: string, - endpoint: string, - options: { jsonData?: Record; params?: Record } = {} - ): Promise { - const headers: Record = { - ...this.getAuthHeaders(), - "Content-Type": "application/json", - } - - let url = `${SECTL_CONFIG.baseUrl}${endpoint}` - if (options.params) { - const searchParams = new URLSearchParams() - for (const [k, v] of Object.entries(options.params)) { - if (v !== undefined && v !== null) searchParams.set(k, String(v)) + private buildParams(extra?: Record): URLSearchParams { + const params = new URLSearchParams({ client_id: SECTL_CONFIG.platformId }) + const userId = sectlAuth.getUserId() + if (userId) params.append("user_id", userId) + if (extra) { + for (const [key, value] of Object.entries(extra)) { + params.append(key, value) } - const qs = searchParams.toString() - if (qs) url += `?${qs}` } - - const init: RequestInit = { method, headers } - if (options.jsonData && method !== "GET") { - init.body = JSON.stringify(options.jsonData) - } - - const response = await fetch(url, init) - - if (!response.ok) { - let errorData: { error?: string; error_description?: string; message?: string } = {} - try { - errorData = await response.json() - } catch { - // ignore - } - const desc = errorData.error_description || errorData.message || `HTTP ${response.status}` - const err = new Error(desc) as Error & { status?: number; error?: string } - err.status = response.status - err.error = errorData.error - throw err - } - - return response.json() as Promise + return params } async setKV( @@ -83,67 +56,142 @@ class SectlKVStorageService { updated_at?: string message: string }> { - const jsonData: Record = { - client_id: this.platformId, + const isJson = typeof value === "object" && value !== null + const body: Record = { + client_id: SECTL_CONFIG.platformId, key, value, } - if (ttl !== undefined) jsonData.ttl = ttl + const userId = sectlAuth.getUserId() + if (userId) body.user_id = userId + if (ttl) body.ttl = ttl - return this.makeRequest("POST", "/api/cloud/kv", { jsonData }) + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv`, { + method: "POST", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "设置 KV 失败") + } + + const result = await response.json() + return { + success: result.success ?? true, + key: result.key ?? key, + size: result.size ?? 0, + is_json: result.is_json ?? isJson, + created_at: result.created_at, + updated_at: result.updated_at, + message: result.message ?? "OK", + } } - async getKV( - key: string, - field?: string - ): Promise< - | (KVItem & { key: string; value: T }) - | { key: string; field: string; value: T; is_json: boolean } - > { - const params: Record = { client_id: this.platformId } - if (field) params.field = field + async getKV(key: string): Promise { + const params = this.buildParams() + const response = await fetch( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}?${params}`, + { + headers: this.getAuthHeaders(), + } + ) - return this.makeRequest("GET", `/api/cloud/kv/${encodeURIComponent(key)}`, { params }) + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "获取 KV 失败") + } + + const result = await response.json() + return { + key: result.key ?? key, + value: result.value as T, + size: result.size ?? 0, + is_json: result.is_json ?? false, + created_at: result.created_at ?? "", + updated_at: result.updated_at ?? "", + } } async listKV( options: ListKVOptions = {} ): Promise<{ kv_list: KVItem[]; total: number; has_more: boolean }> { - const params: Record = { - client_id: this.platformId, - limit: Math.min(options.limit || 100, 1000), - offset: options.offset || 0, - } - if (options.prefix) params.prefix = options.prefix + const params = this.buildParams({ + limit: String(options.limit ?? 100), + offset: String(options.offset ?? 0), + }) + if (options.prefix) params.append("prefix", options.prefix) - return this.makeRequest("GET", "/api/cloud/kv", { params }) + const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv?${params}`, { + headers: this.getAuthHeaders(), + }) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "获取 KV 列表失败") + } + + const result = await response.json() + return { + kv_list: result.kv_list ?? [], + total: result.total ?? 0, + has_more: result.has_more ?? false, + } } async updateKVField( key: string, field: string, value: unknown - ): Promise<{ - success: boolean - key: string - field: string - size: number - updated_at: string - message: string - }> { - return this.makeRequest("PATCH", `/api/cloud/kv/${encodeURIComponent(key)}`, { - jsonData: { client_id: this.platformId, field, value }, - }) + ): Promise<{ success: boolean; message: string }> { + const body: Record = { + client_id: SECTL_CONFIG.platformId, + field, + value, + } + const userId = sectlAuth.getUserId() + if (userId) body.user_id = userId + + const response = await fetch( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify(body), + } + ) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "更新 KV 字段失败") + } + + return { success: true, message: "OK" } } async deleteKV(key: string): Promise<{ success: boolean; message: string }> { - return this.makeRequest("DELETE", `/api/cloud/kv/${encodeURIComponent(key)}`, { - jsonData: { client_id: this.platformId }, - }) - } + const body = { + client_id: SECTL_CONFIG.platformId, + } + const userId = sectlAuth.getUserId() + if (userId) (body as Record).user_id = userId - getPlatformId(): string { - return this.platformId + const response = await fetch( + `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`, + { + method: "DELETE", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify(body), + } + ) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error_description || "删除 KV 失败") + } + + return { success: true, message: "OK" } } } diff --git a/src/services/sectlNotification.test.ts b/src/services/sectlNotification.test.ts new file mode 100644 index 0000000..04f3169 --- /dev/null +++ b/src/services/sectlNotification.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { sectlNotification } from "./sectlNotification" +import { SECTL_CONFIG, sectlAuth } from "./sectlAuth" + +// Mock fetch and sectlAuth +global.fetch = vi.fn() +vi.mock("./sectlAuth", () => ({ + SECTL_CONFIG: { + baseUrl: "https://api.sectl.cn", + platformId: "test-platform-id", + }, + sectlAuth: { + getAccessToken: vi.fn(), + getUserId: vi.fn(), + }, +})) + +describe("sectlNotification", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(sectlAuth.getAccessToken).mockReturnValue("test-access-token") + vi.mocked(sectlAuth.getUserId).mockReturnValue("test-user-id") + }) + + describe("getNotifications", () => { + it("should call correct API endpoint without client_id", async () => { + const mockResponse = { notifications: [], total: 0 } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlNotification.getNotifications({ limit: 10, offset: 0 }) + + const calledUrl = vi.mocked(global.fetch).mock.calls[0][0] as string + expect(calledUrl).toContain(`${SECTL_CONFIG.baseUrl}/api/notifications?`) + expect(calledUrl).toContain("limit=10") + expect(calledUrl).toContain("offset=0") + expect(calledUrl).not.toContain("client_id") + }) + }) + + describe("markNotificationRead", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { success: true, notification: { $id: "notif-123", is_read: true } } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlNotification.markNotificationRead("notif-123") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/notifications/notif-123/read`, + expect.objectContaining({ + method: "POST", + }) + ) + }) + }) + + describe("markAllNotificationsRead", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { success: true, marked_count: 5 } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlNotification.markAllNotificationsRead() + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/notifications/read-all`, + expect.objectContaining({ + method: "POST", + }) + ) + }) + }) + + describe("deleteNotification", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { success: true, message: "deleted" } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlNotification.deleteNotification("notif-123") + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/notifications/notif-123`, + expect.objectContaining({ + method: "DELETE", + }) + ) + }) + }) + + describe("sendNotification", () => { + it("should call correct API endpoint", async () => { + const mockResponse = { success: true, notification_id: "notif-123" } + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response) + + await sectlNotification.sendNotification({ + user_id: "user-123", + title: "Test", + content: "Test content", + }) + + expect(global.fetch).toHaveBeenCalledWith( + `${SECTL_CONFIG.baseUrl}/api/notifications/send`, + expect.objectContaining({ + method: "POST", + }) + ) + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4533969 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + }, +})