From bf9cb9af41af35040557ba98e5be243d8be26eda Mon Sep 17 00:00:00 2001 From: Yukino_fox Date: Fri, 10 Apr 2026 21:47:24 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0SECTL=E4=BA=91?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=9B=86=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现SECTL云服务的全面集成,包括: 1. 添加OAuth认证服务,支持PKCE流程 2. 实现云存储管理功能 3. 添加积分数据同步服务 4. 实现通知服务和KV存储服务 5. 添加多语言支持 6. 创建相关React组件和上下文 7. 更新依赖项添加crypto-js 8. 优化HTTP客户端重用 --- .eslintcache | 2 +- package.json | 2 + pnpm-lock.yaml | 16 + src-tauri/src/commands/auth.rs | 24 +- src-tauri/src/state.rs | 8 + src/components/OAuth/OAuthLogin.tsx | 2 +- src/components/ScoreSyncPanel.tsx | 285 ++++++ src/components/SectlCloudStorageManager.tsx | 313 +++++++ src/components/SectlLoginButton.tsx | 67 ++ src/components/SectlSettingsPanel.tsx | 157 ++++ src/contexts/SectlContext.tsx | 147 ++++ src/i18n/index.ts | 44 +- src/i18n/locales/ar-SA.json | 668 +++++++++++++++ src/i18n/locales/de-DE.json | 668 +++++++++++++++ src/i18n/locales/es-ES.json | 668 +++++++++++++++ src/i18n/locales/fr-FR.json | 668 +++++++++++++++ src/i18n/locales/ja-JP.json | 905 ++++++++++++++++++++ src/i18n/locales/ko-KR.json | 668 +++++++++++++++ src/i18n/locales/pt-BR.json | 668 +++++++++++++++ src/i18n/locales/ru-RU.json | 668 +++++++++++++++ src/services/scoreSyncService.ts | 300 +++++++ src/services/sectl.ts | 24 + src/services/sectlAuth.ts | 388 +++++++++ src/services/sectlCloudStorage.ts | 596 +++++++++++++ src/services/sectlKVStorage.ts | 256 ++++++ src/services/sectlNotification.ts | 225 +++++ src/views/SectlCloudView.tsx | 18 + 27 files changed, 8447 insertions(+), 8 deletions(-) create mode 100644 src/components/ScoreSyncPanel.tsx create mode 100644 src/components/SectlCloudStorageManager.tsx create mode 100644 src/components/SectlLoginButton.tsx create mode 100644 src/components/SectlSettingsPanel.tsx create mode 100644 src/contexts/SectlContext.tsx create mode 100644 src/i18n/locales/ar-SA.json create mode 100644 src/i18n/locales/de-DE.json create mode 100644 src/i18n/locales/es-ES.json create mode 100644 src/i18n/locales/fr-FR.json create mode 100644 src/i18n/locales/ja-JP.json create mode 100644 src/i18n/locales/ko-KR.json create mode 100644 src/i18n/locales/pt-BR.json create mode 100644 src/i18n/locales/ru-RU.json create mode 100644 src/services/scoreSyncService.ts create mode 100644 src/services/sectl.ts create mode 100644 src/services/sectlAuth.ts create mode 100644 src/services/sectlCloudStorage.ts create mode 100644 src/services/sectlKVStorage.ts create mode 100644 src/services/sectlNotification.ts create mode 100644 src/views/SectlCloudView.tsx diff --git a/.eslintcache b/.eslintcache index 4ae57e2..5bb6b65 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"},{"size":1413,"mtime":1774084765386,"results":"50","hashOfConfig":"51"},{"size":39361,"mtime":1775224756063,"results":"52","hashOfConfig":"53"},{"size":124,"mtime":1774084765537,"results":"54","hashOfConfig":"53"},{"size":47714,"mtime":1774704827018,"results":"55","hashOfConfig":"53"},{"size":12989,"mtime":1774784139058,"results":"56","hashOfConfig":"53"},{"size":2044,"mtime":1774084765557,"results":"57","hashOfConfig":"53"},{"size":90881,"mtime":1774704827750,"results":"58","hashOfConfig":"53"},{"size":8101,"mtime":1774084765561,"results":"59","hashOfConfig":"53"},{"size":36271,"mtime":1774176288912,"results":"60","hashOfConfig":"53"},{"size":4203,"mtime":1774084765566,"results":"61","hashOfConfig":"53"},{"size":5983,"mtime":1774084765569,"results":"62","hashOfConfig":"53"},{"size":9360,"mtime":1774084765571,"results":"63","hashOfConfig":"53"},{"size":6151,"mtime":1774084765572,"results":"64","hashOfConfig":"53"},{"size":12301,"mtime":1774084765573,"results":"65","hashOfConfig":"53"},{"size":61661,"mtime":1775279883358,"results":"66","hashOfConfig":"53"},{"size":5950,"mtime":1774084765578,"results":"67","hashOfConfig":"53"},{"size":10480,"mtime":1774784139062,"results":"68","hashOfConfig":"53"},{"size":79058,"mtime":1774704830361,"results":"69","hashOfConfig":"53"},{"size":6331,"mtime":1774084765584,"results":"70","hashOfConfig":"53"},{"size":8079,"mtime":1774084765586,"results":"71","hashOfConfig":"53"},{"size":10954,"mtime":1774084765587,"results":"72","hashOfConfig":"53"},{"size":3858,"mtime":1774084765589,"results":"73","hashOfConfig":"53"},{"size":431,"mtime":1774084765590,"results":"74","hashOfConfig":"53"},{"size":2367,"mtime":1774084765592,"results":"75","hashOfConfig":"53"},{"size":1560,"mtime":1774084765594,"results":"76","hashOfConfig":"53"},{"size":362,"mtime":1774084765604,"results":"77","hashOfConfig":"53"},{"size":5985,"mtime":1774084765606,"results":"78","hashOfConfig":"53"},{"size":2824,"mtime":1774084765608,"results":"79","hashOfConfig":"53"},{"size":38,"mtime":1774084765611,"results":"80","hashOfConfig":"53"},{"size":2097,"mtime":1774084765613,"results":"81","hashOfConfig":"53"},{"size":1533,"mtime":1774084765616,"results":"82","hashOfConfig":"53"},{"size":6919,"mtime":1774704831300,"results":"83","hashOfConfig":"53"},{"size":21171,"mtime":1775279884708,"results":"84","hashOfConfig":"53"},{"size":1150,"mtime":1774084765630,"results":"85","hashOfConfig":"53"},{"size":667,"mtime":1774084765638,"results":"86","hashOfConfig":"53"},{"size":3061,"mtime":1774176077013,"results":"87","hashOfConfig":"53"},{"size":1810,"mtime":1774784139078,"results":"88","hashOfConfig":"53"},{"size":3256,"mtime":1774084765655,"results":"89","hashOfConfig":"53"},{"size":1115,"mtime":1774084765658,"results":"90","hashOfConfig":"53"},{"size":947,"mtime":1774084765660,"results":"91","hashOfConfig":"53"},{"size":709,"mtime":1774665241507,"results":"92","hashOfConfig":"53"},{"size":4473,"mtime":1775279880382,"results":"93","hashOfConfig":"53"},{"size":11159,"mtime":1775279880492,"results":"94","hashOfConfig":"53"},{"size":2126,"mtime":1775224756075,"results":"95","hashOfConfig":"53"},{"size":3055,"mtime":1775224756077,"results":"96","hashOfConfig":"53"},{"size":1749,"mtime":1775279880567,"results":"97","hashOfConfig":"53"},{"size":13679,"mtime":1775279880826,"results":"98","hashOfConfig":"53"},{"size":1047,"mtime":1775224756086,"results":"99","hashOfConfig":"53"},{"size":6013,"mtime":1775279164215,"results":"100","hashOfConfig":"53"},{"filePath":"101","messages":"102","suppressedMessages":"103","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"oyxevq",{"filePath":"104","messages":"105","suppressedMessages":"106","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"f6a32r",{"filePath":"107","messages":"108","suppressedMessages":"109","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"110","messages":"111","suppressedMessages":"112","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"113","messages":"114","suppressedMessages":"115","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"116","messages":"117","suppressedMessages":"118","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"119","messages":"120","suppressedMessages":"121","errorCount":3,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"122","messages":"123","suppressedMessages":"124","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"125","messages":"126","suppressedMessages":"127","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"128","messages":"129","suppressedMessages":"130","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"131","messages":"132","suppressedMessages":"133","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"134","messages":"135","suppressedMessages":"136","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"137","messages":"138","suppressedMessages":"139","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"140","messages":"141","suppressedMessages":"142","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"143","messages":"144","suppressedMessages":"145","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"146","messages":"147","suppressedMessages":"148","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"149","messages":"150","suppressedMessages":"151","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"152","messages":"153","suppressedMessages":"154","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"155","messages":"156","suppressedMessages":"157","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"158","messages":"159","suppressedMessages":"160","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"161","messages":"162","suppressedMessages":"163","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"164","messages":"165","suppressedMessages":"166","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"167","messages":"168","suppressedMessages":"169","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"170","messages":"171","suppressedMessages":"172","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"173","messages":"174","suppressedMessages":"175","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"176","messages":"177","suppressedMessages":"178","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"179","messages":"180","suppressedMessages":"181","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"182","messages":"183","suppressedMessages":"184","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"185","messages":"186","suppressedMessages":"187","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"188","messages":"189","suppressedMessages":"190","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"191","messages":"192","suppressedMessages":"193","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"194","messages":"195","suppressedMessages":"196","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"197","messages":"198","suppressedMessages":"199","errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"200","messages":"201","suppressedMessages":"202","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"203","messages":"204","suppressedMessages":"205","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"206","messages":"207","suppressedMessages":"208","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"209","messages":"210","suppressedMessages":"211","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"212","messages":"213","suppressedMessages":"214","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"215","messages":"216","suppressedMessages":"217","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"218","messages":"219","suppressedMessages":"220","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"221","messages":"222","suppressedMessages":"223","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"224","messages":"225","suppressedMessages":"226","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"227","messages":"228","suppressedMessages":"229","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"230","messages":"231","suppressedMessages":"232","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"233","messages":"234","suppressedMessages":"235","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"236","messages":"237","suppressedMessages":"238","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"239","messages":"240","suppressedMessages":"241","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"242","messages":"243","suppressedMessages":"244","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"245","messages":"246","suppressedMessages":"247","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},"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",["248","249"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ContentArea.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\EventHistory.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx",["250","251","252","253","254","255"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Leaderboard.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OOBE\\OOBE.tsx",["256","257"],[],"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",["258"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\SettlementHistory.tsx",["259","260"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Sidebar.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\StudentManager.tsx",["261"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\TagEditorDialog.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ThemeEditor.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ThemeQuickSettings.tsx",["262"],[],"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",["263"],[],"E:\\Document\\Coding\\SecScore\\src\\i18n\\index.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\main.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\preload\\types.ts",["264"],[],"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",["265"],[],"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",["266","267"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OAuth\\OAuthCallback.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OAuth\\OAuthLogin.tsx",["268"],[],{"ruleId":"269","severity":1,"message":"270","line":626,"column":6,"nodeType":"271","endLine":626,"endColumn":44,"suggestions":"272"},{"ruleId":"269","severity":1,"message":"270","line":634,"column":6,"nodeType":"271","endLine":634,"endColumn":59,"suggestions":"273"},{"ruleId":"269","severity":1,"message":"274","line":532,"column":6,"nodeType":"271","endLine":532,"endColumn":76,"suggestions":"275"},{"ruleId":"276","severity":2,"message":"277","line":544,"column":34,"nodeType":null,"endLine":556,"endColumn":4},{"ruleId":"276","severity":2,"message":"277","line":544,"column":34,"nodeType":null,"endLine":556,"endColumn":4},{"ruleId":"269","severity":1,"message":"278","line":556,"column":6,"nodeType":"271","endLine":556,"endColumn":15,"suggestions":"279"},{"ruleId":"276","severity":2,"message":"280","line":1627,"column":5,"nodeType":null,"endLine":1650,"endColumn":6},{"ruleId":"269","severity":1,"message":"281","line":1710,"column":6,"nodeType":"271","endLine":1710,"endColumn":8,"suggestions":"282"},{"ruleId":"269","severity":1,"message":"283","line":160,"column":6,"nodeType":"271","endLine":160,"endColumn":20,"suggestions":"284"},{"ruleId":"269","severity":1,"message":"285","line":334,"column":5,"nodeType":"271","endLine":334,"endColumn":27,"suggestions":"286"},{"ruleId":"269","severity":1,"message":"287","line":380,"column":6,"nodeType":"271","endLine":380,"endColumn":8,"suggestions":"288"},{"ruleId":"269","severity":1,"message":"289","line":54,"column":6,"nodeType":"271","endLine":54,"endColumn":18,"suggestions":"290"},{"ruleId":"269","severity":1,"message":"278","line":106,"column":5,"nodeType":"271","endLine":106,"endColumn":7,"suggestions":"291"},{"ruleId":"269","severity":1,"message":"292","line":1351,"column":6,"nodeType":"271","endLine":1351,"endColumn":50,"suggestions":"293"},{"ruleId":"269","severity":1,"message":"283","line":100,"column":6,"nodeType":"271","endLine":100,"endColumn":20,"suggestions":"294"},{"ruleId":"269","severity":1,"message":"295","line":25,"column":9,"nodeType":"296","endLine":25,"endColumn":70},{"ruleId":"297","severity":2,"message":"298","line":627,"column":38,"nodeType":"299","messageId":"300","endLine":627,"endColumn":43},{"ruleId":"269","severity":1,"message":"301","line":23,"column":9,"nodeType":"296","endLine":23,"endColumn":63},{"ruleId":"269","severity":1,"message":"278","line":74,"column":5,"nodeType":"271","endLine":74,"endColumn":55,"suggestions":"302"},{"ruleId":"269","severity":1,"message":"303","line":157,"column":6,"nodeType":"271","endLine":157,"endColumn":15,"suggestions":"304"},{"ruleId":"269","severity":1,"message":"305","line":131,"column":6,"nodeType":"271","endLine":131,"endColumn":8,"suggestions":"306"},"react-hooks/exhaustive-deps","React Hook useEffect has a missing dependency: 'activeBoard'. Either include it or remove the dependency array.","ArrayExpression",["307"],["308"],"React Hook useMemo has an unnecessary dependency: 'layoutType'. Either exclude it or remove the dependency array.",["309"],"react-hooks/preserve-manual-memoization","Compilation Skipped: Existing memoization could not be preserved\n\nReact Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `t`, but the source dependencies were [reasons]. Inferred different dependency than source.\n\nE:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx:544:34\n 542 | }, [groupedStudents])\n 543 |\n> 544 | const groupedReasons = useMemo(() => {\n | ^^^^^^^\n> 545 | const groups: Record = {}\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 546 | reasons.forEach((r) => {\n …\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 555 | })\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 556 | }, [reasons])\n | ^^^^ Could not preserve existing manual memoization\n 557 |\n 558 | const selectedStudents = useMemo(() => {\n 559 | if (selectedStudentIds.length === 0) return []","React Hook useMemo has a missing dependency: 't'. Either include it or remove the dependency array.",["310"],"Compilation Skipped: Existing memoization could not be preserved\n\nReact Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output.\n\nE:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx:1627:5\n 1625 |\n 1626 | const handleNavAction = useCallback(\n> 1627 | (clientY: number) => {\n | ^^^^^^^^^^^^^^^^^^^^^^\n> 1628 | if (!navContainerRef.current) return\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 1629 | const rect = navContainerRef.current.getBoundingClientRect()\n …\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 1649 | }\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 1650 | },\n | ^^^^^^ Could not preserve existing memoization\n 1651 | [groupedStudents, quickNavLayout.itemSize, quickNavLayout.paddingY, triggerNavHaptic]\n 1652 | )\n 1653 |","React Hook useEffect has missing dependencies: 'onGlobalMouseMove' and 'onGlobalMouseUp'. Either include them or remove the dependency array.",["311"],"React Hook useEffect has a missing dependency: 't'. Either include it or remove the dependency array.",["312"],"React Hook useCallback has missing dependencies: 'showOobeMessage' and 't'. Either include them or remove the dependency array.",["313"],"React Hook useEffect has a missing dependency: 'loadAll'. Either include it or remove the dependency array.",["314"],"React Hook useCallback has a missing dependency: 't'. Either include it or remove the dependency array.",["315"],["316"],"React Hook useMemo has missing dependencies: 'handleDelete', 'handleOpenAvatarEditor', 'handleOpenGroupEditor', and 'handleOpenTagEditor'. Either include them or remove the dependency array.",["317"],["318"],"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","@typescript-eslint/no-unused-vars","'_args' is defined but never used.","Identifier","unusedVar","The 'safeValue' conditional could make the dependencies of useMemo Hook (at line 58) change on every render. To fix this, wrap the initialization of 'safeValue' in its own useMemo() Hook.",["319"],"React Hook useEffect has missing dependencies: 'fetchRules', 'fetchStudents', and 'fetchTags'. Either include them or remove the dependency array.",["320"],"React Hook useEffect has a missing dependency: 'handleOAuthCallback'. Either include it or remove the dependency array.",["321"],{"desc":"322","fix":"323"},{"desc":"324","fix":"325"},{"desc":"326","fix":"327"},{"desc":"328","fix":"329"},{"desc":"330","fix":"331"},{"desc":"332","fix":"333"},{"desc":"334","fix":"335"},{"desc":"336","fix":"337"},{"desc":"338","fix":"339"},{"desc":"340","fix":"341"},{"desc":"342","fix":"343"},{"desc":"332","fix":"344"},{"desc":"345","fix":"346"},{"desc":"347","fix":"348"},{"desc":"349","fix":"350"},"Update the dependencies array to be: [activeBoard, activeBoard.id, activeBoard.layout]",{"range":"351","text":"352"},"Update the dependencies array to be: [activeBoard, activeBoard.id, listConfigSignature, runAllInBoard]",{"range":"353","text":"354"},"Update the dependencies array to be: [sortedStudents, sortType, searchKeyword, getGroupName, t]",{"range":"355","text":"356"},"Update the dependencies array to be: [reasons, t]",{"range":"357","text":"358"},"Update the dependencies array to be: [onGlobalMouseMove, onGlobalMouseUp]",{"range":"359","text":"360"},"Update the dependencies array to be: [currentTheme, t]",{"range":"361","text":"362"},"Update the dependencies array to be: [students, showOobeMessage, t]",{"range":"363","text":"364"},"Update the dependencies array to be: [loadAll]",{"range":"365","text":"366"},"Update the dependencies array to be: [messageApi, t]",{"range":"367","text":"368"},"Update the dependencies array to be: [t]",{"range":"369","text":"370"},"Update the dependencies array to be: [t, isMobile, canEdit, handleOpenTagEditor, handleOpenGroupEditor, handleOpenAvatarEditor, handleDelete]",{"range":"371","text":"372"},{"range":"373","text":"362"},"Update the dependencies array to be: [t, tagOptions]",{"range":"374","text":"375"},"Update the dependencies array to be: [canEdit, fetchRules, fetchStudents, fetchTags]",{"range":"376","text":"377"},"Update the dependencies array to be: [handleOAuthCallback]",{"range":"378","text":"379"},[18048,18086],"[activeBoard, activeBoard.id, activeBoard.layout]",[18296,18349],"[activeBoard, activeBoard.id, listConfigSignature, runAllInBoard]",[16069,16139],"[sortedStudents, sortType, searchKeyword, getGroupName, t]",[16914,16923],"[reasons, t]",[53173,53175],"[onGlobalMouseMove, onGlobalMouseUp]",[4674,4688],"[currentTheme, t]",[10389,10411],"[students, showOobeMessage, t]",[12150,12152],"[loadAll]",[1697,1709],"[messageApi, t]",[3343,3345],"[t]",[45580,45624],"[t, isMobile, canEdit, handleOpenTagEditor, handleOpenGroupEditor, handleOpenAvatarEditor, handleDelete]",[3185,3199],[1661,1711],"[t, tagOptions]",[4022,4031],"[canEdit, fetchRules, fetchStudents, fetchTags]",[3410,3412],"[handleOAuthCallback]"] \ No newline at end of file +[{"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"},{"size":1413,"mtime":1774084765386,"results":"62","hashOfConfig":"63"},{"size":39361,"mtime":1775381637091,"results":"64","hashOfConfig":"65"},{"size":124,"mtime":1774084765537,"results":"66","hashOfConfig":"65"},{"size":47714,"mtime":1774704827018,"results":"67","hashOfConfig":"65"},{"size":12989,"mtime":1774784139058,"results":"68","hashOfConfig":"65"},{"size":2044,"mtime":1774084765557,"results":"69","hashOfConfig":"65"},{"size":90881,"mtime":1774704827750,"results":"70","hashOfConfig":"65"},{"size":8101,"mtime":1774084765561,"results":"71","hashOfConfig":"65"},{"size":36271,"mtime":1774176288912,"results":"72","hashOfConfig":"65"},{"size":4203,"mtime":1774084765566,"results":"73","hashOfConfig":"65"},{"size":5983,"mtime":1774084765569,"results":"74","hashOfConfig":"65"},{"size":9360,"mtime":1774084765571,"results":"75","hashOfConfig":"65"},{"size":6151,"mtime":1774084765572,"results":"76","hashOfConfig":"65"},{"size":12301,"mtime":1774084765573,"results":"77","hashOfConfig":"65"},{"size":61661,"mtime":1775381637096,"results":"78","hashOfConfig":"65"},{"size":5950,"mtime":1774084765578,"results":"79","hashOfConfig":"65"},{"size":10480,"mtime":1774784139062,"results":"80","hashOfConfig":"65"},{"size":79058,"mtime":1774704830361,"results":"81","hashOfConfig":"65"},{"size":6331,"mtime":1774084765584,"results":"82","hashOfConfig":"65"},{"size":8079,"mtime":1774084765586,"results":"83","hashOfConfig":"65"},{"size":10954,"mtime":1774084765587,"results":"84","hashOfConfig":"65"},{"size":3858,"mtime":1774084765589,"results":"85","hashOfConfig":"65"},{"size":431,"mtime":1774084765590,"results":"86","hashOfConfig":"65"},{"size":2367,"mtime":1774084765592,"results":"87","hashOfConfig":"65"},{"size":1560,"mtime":1774084765594,"results":"88","hashOfConfig":"65"},{"size":362,"mtime":1774084765604,"results":"89","hashOfConfig":"65"},{"size":5985,"mtime":1774084765606,"results":"90","hashOfConfig":"65"},{"size":2824,"mtime":1774084765608,"results":"91","hashOfConfig":"65"},{"size":38,"mtime":1774084765611,"results":"92","hashOfConfig":"65"},{"size":2097,"mtime":1774084765613,"results":"93","hashOfConfig":"65"},{"size":1533,"mtime":1774084765616,"results":"94","hashOfConfig":"65"},{"size":6919,"mtime":1774704831300,"results":"95","hashOfConfig":"65"},{"size":21171,"mtime":1775381637100,"results":"96","hashOfConfig":"65"},{"size":1150,"mtime":1774084765630,"results":"97","hashOfConfig":"65"},{"size":667,"mtime":1774084765638,"results":"98","hashOfConfig":"65"},{"size":3061,"mtime":1774176077013,"results":"99","hashOfConfig":"65"},{"size":1810,"mtime":1774784139078,"results":"100","hashOfConfig":"65"},{"size":3256,"mtime":1774084765655,"results":"101","hashOfConfig":"65"},{"size":1115,"mtime":1774084765658,"results":"102","hashOfConfig":"65"},{"size":947,"mtime":1774084765660,"results":"103","hashOfConfig":"65"},{"size":709,"mtime":1774665241507,"results":"104","hashOfConfig":"65"},{"size":4473,"mtime":1775279880382,"results":"105","hashOfConfig":"65"},{"size":11159,"mtime":1775279880492,"results":"106","hashOfConfig":"65"},{"size":2126,"mtime":1775224756075,"results":"107","hashOfConfig":"65"},{"size":3055,"mtime":1775224756077,"results":"108","hashOfConfig":"65"},{"size":1749,"mtime":1775279880567,"results":"109","hashOfConfig":"65"},{"size":13679,"mtime":1775279880826,"results":"110","hashOfConfig":"65"},{"size":1047,"mtime":1775224756086,"results":"111","hashOfConfig":"65"},{"size":7055,"mtime":1775382856947,"results":"112","hashOfConfig":"65"},{"size":8410,"mtime":1775383418692,"results":"113","hashOfConfig":"65"},{"size":1661,"mtime":1775382857634,"results":"114","hashOfConfig":"65"},{"size":4807,"mtime":1775383469000,"results":"115","hashOfConfig":"65"},{"size":3773,"mtime":1775383419736,"results":"116","hashOfConfig":"65"},{"size":683,"mtime":1775383317477,"results":"117","hashOfConfig":"65"},{"size":9500,"mtime":1775383420349,"results":"118","hashOfConfig":"65"},{"size":14984,"mtime":1775382860520,"results":"119","hashOfConfig":"65"},{"size":6315,"mtime":1775382860555,"results":"120","hashOfConfig":"65"},{"size":5437,"mtime":1775382860586,"results":"121","hashOfConfig":"65"},{"size":429,"mtime":1775383291875,"results":"122","hashOfConfig":"65"},{"size":8435,"mtime":1775383536457,"results":"123","hashOfConfig":"65"},{"size":7067,"mtime":1775383420279,"results":"124","hashOfConfig":"65"},{"filePath":"125","messages":"126","suppressedMessages":"127","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"oyxevq",{"filePath":"128","messages":"129","suppressedMessages":"130","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"f6a32r",{"filePath":"131","messages":"132","suppressedMessages":"133","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"134","messages":"135","suppressedMessages":"136","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"137","messages":"138","suppressedMessages":"139","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"140","messages":"141","suppressedMessages":"142","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"143","messages":"144","suppressedMessages":"145","errorCount":3,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"146","messages":"147","suppressedMessages":"148","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"149","messages":"150","suppressedMessages":"151","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"152","messages":"153","suppressedMessages":"154","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"155","messages":"156","suppressedMessages":"157","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"158","messages":"159","suppressedMessages":"160","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"161","messages":"162","suppressedMessages":"163","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"164","messages":"165","suppressedMessages":"166","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"167","messages":"168","suppressedMessages":"169","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"170","messages":"171","suppressedMessages":"172","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"173","messages":"174","suppressedMessages":"175","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"176","messages":"177","suppressedMessages":"178","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"179","messages":"180","suppressedMessages":"181","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"182","messages":"183","suppressedMessages":"184","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"185","messages":"186","suppressedMessages":"187","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"188","messages":"189","suppressedMessages":"190","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"191","messages":"192","suppressedMessages":"193","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"194","messages":"195","suppressedMessages":"196","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"197","messages":"198","suppressedMessages":"199","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"200","messages":"201","suppressedMessages":"202","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"203","messages":"204","suppressedMessages":"205","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"206","messages":"207","suppressedMessages":"208","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"209","messages":"210","suppressedMessages":"211","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"212","messages":"213","suppressedMessages":"214","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"215","messages":"216","suppressedMessages":"217","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"218","messages":"219","suppressedMessages":"220","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"221","messages":"222","suppressedMessages":"223","errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"224","messages":"225","suppressedMessages":"226","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"227","messages":"228","suppressedMessages":"229","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"230","messages":"231","suppressedMessages":"232","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"233","messages":"234","suppressedMessages":"235","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"236","messages":"237","suppressedMessages":"238","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"239","messages":"240","suppressedMessages":"241","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"242","messages":"243","suppressedMessages":"244","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"245","messages":"246","suppressedMessages":"247","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"248","messages":"249","suppressedMessages":"250","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"251","messages":"252","suppressedMessages":"253","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"254","messages":"255","suppressedMessages":"256","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"257","messages":"258","suppressedMessages":"259","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"260","messages":"261","suppressedMessages":"262","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"263","messages":"264","suppressedMessages":"265","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"266","messages":"267","suppressedMessages":"268","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"269","messages":"270","suppressedMessages":"271","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"272","messages":"273","suppressedMessages":"274","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"275","messages":"276","suppressedMessages":"277","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"278","messages":"279","suppressedMessages":"280","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"281","messages":"282","suppressedMessages":"283","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"284","messages":"285","suppressedMessages":"286","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"287","messages":"288","suppressedMessages":"289","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"290","messages":"291","suppressedMessages":"292","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"293","messages":"294","suppressedMessages":"295","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"296","messages":"297","suppressedMessages":"298","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"299","messages":"300","suppressedMessages":"301","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"302","messages":"303","suppressedMessages":"304","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"305","messages":"306","suppressedMessages":"307","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",["308","309"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ContentArea.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\EventHistory.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx",["310","311","312","313","314","315"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Leaderboard.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OOBE\\OOBE.tsx",["316","317"],[],"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",["318"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\SettlementHistory.tsx",["319","320"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\Sidebar.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\StudentManager.tsx",["321"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\TagEditorDialog.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ThemeEditor.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\ThemeQuickSettings.tsx",["322"],[],"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",["323"],[],"E:\\Document\\Coding\\SecScore\\src\\i18n\\index.ts",[],[],"E:\\Document\\Coding\\SecScore\\src\\main.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\preload\\types.ts",["324"],[],"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",["325"],[],"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",["326","327"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OAuth\\OAuthCallback.tsx",[],[],"E:\\Document\\Coding\\SecScore\\src\\components\\OAuth\\OAuthLogin.tsx",["328"],[],"E:\\Document\\Coding\\SecScore\\src\\components\\SectlCloudStorageManager.tsx",["329"],[],"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",["330"],[],"E:\\Document\\Coding\\SecScore\\src\\services\\scoreSyncService.ts",[],[],{"ruleId":"331","severity":1,"message":"332","line":626,"column":6,"nodeType":"333","endLine":626,"endColumn":44,"suggestions":"334"},{"ruleId":"331","severity":1,"message":"332","line":634,"column":6,"nodeType":"333","endLine":634,"endColumn":59,"suggestions":"335"},{"ruleId":"331","severity":1,"message":"336","line":532,"column":6,"nodeType":"333","endLine":532,"endColumn":76,"suggestions":"337"},{"ruleId":"338","severity":2,"message":"339","line":544,"column":34,"nodeType":null,"endLine":556,"endColumn":4},{"ruleId":"338","severity":2,"message":"339","line":544,"column":34,"nodeType":null,"endLine":556,"endColumn":4},{"ruleId":"331","severity":1,"message":"340","line":556,"column":6,"nodeType":"333","endLine":556,"endColumn":15,"suggestions":"341"},{"ruleId":"338","severity":2,"message":"342","line":1627,"column":5,"nodeType":null,"endLine":1650,"endColumn":6},{"ruleId":"331","severity":1,"message":"343","line":1710,"column":6,"nodeType":"333","endLine":1710,"endColumn":8,"suggestions":"344"},{"ruleId":"331","severity":1,"message":"345","line":160,"column":6,"nodeType":"333","endLine":160,"endColumn":20,"suggestions":"346"},{"ruleId":"331","severity":1,"message":"347","line":334,"column":5,"nodeType":"333","endLine":334,"endColumn":27,"suggestions":"348"},{"ruleId":"331","severity":1,"message":"349","line":380,"column":6,"nodeType":"333","endLine":380,"endColumn":8,"suggestions":"350"},{"ruleId":"331","severity":1,"message":"351","line":54,"column":6,"nodeType":"333","endLine":54,"endColumn":18,"suggestions":"352"},{"ruleId":"331","severity":1,"message":"340","line":106,"column":5,"nodeType":"333","endLine":106,"endColumn":7,"suggestions":"353"},{"ruleId":"331","severity":1,"message":"354","line":1351,"column":6,"nodeType":"333","endLine":1351,"endColumn":50,"suggestions":"355"},{"ruleId":"331","severity":1,"message":"345","line":100,"column":6,"nodeType":"333","endLine":100,"endColumn":20,"suggestions":"356"},{"ruleId":"331","severity":1,"message":"357","line":25,"column":9,"nodeType":"358","endLine":25,"endColumn":70},{"ruleId":"359","severity":2,"message":"360","line":627,"column":38,"nodeType":"361","messageId":"362","endLine":627,"endColumn":43},{"ruleId":"331","severity":1,"message":"363","line":23,"column":9,"nodeType":"358","endLine":23,"endColumn":63},{"ruleId":"331","severity":1,"message":"340","line":74,"column":5,"nodeType":"333","endLine":74,"endColumn":55,"suggestions":"364"},{"ruleId":"331","severity":1,"message":"365","line":157,"column":6,"nodeType":"333","endLine":157,"endColumn":15,"suggestions":"366"},{"ruleId":"331","severity":1,"message":"367","line":149,"column":6,"nodeType":"333","endLine":149,"endColumn":8,"suggestions":"368"},{"ruleId":"331","severity":1,"message":"369","line":79,"column":6,"nodeType":"333","endLine":79,"endColumn":23,"suggestions":"370"},{"ruleId":"331","severity":1,"message":"371","line":79,"column":6,"nodeType":"333","endLine":79,"endColumn":23,"suggestions":"372"},"react-hooks/exhaustive-deps","React Hook useEffect has a missing dependency: 'activeBoard'. Either include it or remove the dependency array.","ArrayExpression",["373"],["374"],"React Hook useMemo has an unnecessary dependency: 'layoutType'. Either exclude it or remove the dependency array.",["375"],"react-hooks/preserve-manual-memoization","Compilation Skipped: Existing memoization could not be preserved\n\nReact Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `t`, but the source dependencies were [reasons]. Inferred different dependency than source.\n\nE:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx:544:34\n 542 | }, [groupedStudents])\n 543 |\n> 544 | const groupedReasons = useMemo(() => {\n | ^^^^^^^\n> 545 | const groups: Record = {}\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 546 | reasons.forEach((r) => {\n …\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 555 | })\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 556 | }, [reasons])\n | ^^^^ Could not preserve existing manual memoization\n 557 |\n 558 | const selectedStudents = useMemo(() => {\n 559 | if (selectedStudentIds.length === 0) return []","React Hook useMemo has a missing dependency: 't'. Either include it or remove the dependency array.",["376"],"Compilation Skipped: Existing memoization could not be preserved\n\nReact Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output.\n\nE:\\Document\\Coding\\SecScore\\src\\components\\Home.tsx:1627:5\n 1625 |\n 1626 | const handleNavAction = useCallback(\n> 1627 | (clientY: number) => {\n | ^^^^^^^^^^^^^^^^^^^^^^\n> 1628 | if (!navContainerRef.current) return\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 1629 | const rect = navContainerRef.current.getBoundingClientRect()\n …\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 1649 | }\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n> 1650 | },\n | ^^^^^^ Could not preserve existing memoization\n 1651 | [groupedStudents, quickNavLayout.itemSize, quickNavLayout.paddingY, triggerNavHaptic]\n 1652 | )\n 1653 |","React Hook useEffect has missing dependencies: 'onGlobalMouseMove' and 'onGlobalMouseUp'. Either include them or remove the dependency array.",["377"],"React Hook useEffect has a missing dependency: 't'. Either include it or remove the dependency array.",["378"],"React Hook useCallback has missing dependencies: 'showOobeMessage' and 't'. Either include them or remove the dependency array.",["379"],"React Hook useEffect has a missing dependency: 'loadAll'. Either include it or remove the dependency array.",["380"],"React Hook useCallback has a missing dependency: 't'. Either include it or remove the dependency array.",["381"],["382"],"React Hook useMemo has missing dependencies: 'handleDelete', 'handleOpenAvatarEditor', 'handleOpenGroupEditor', and 'handleOpenTagEditor'. Either include them or remove the dependency array.",["383"],["384"],"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","@typescript-eslint/no-unused-vars","'_args' is defined but never used.","Identifier","unusedVar","The 'safeValue' conditional could make the dependencies of useMemo Hook (at line 58) change on every render. To fix this, wrap the initialization of 'safeValue' in its own useMemo() Hook.",["385"],"React Hook useEffect has missing dependencies: 'fetchRules', 'fetchStudents', and 'fetchTags'. Either include them or remove the dependency array.",["386"],"React Hook useEffect has a missing dependency: 'handleOAuthCallback'. Either include it or remove the dependency array.",["387"],"React Hook useEffect has missing dependencies: 'loadFiles' and 'loadStorageUsage'. Either include them or remove the dependency array.",["388"],"React Hook useEffect has a missing dependency: 'checkCloudStatus'. Either include it or remove the dependency array.",["389"],{"desc":"390","fix":"391"},{"desc":"392","fix":"393"},{"desc":"394","fix":"395"},{"desc":"396","fix":"397"},{"desc":"398","fix":"399"},{"desc":"400","fix":"401"},{"desc":"402","fix":"403"},{"desc":"404","fix":"405"},{"desc":"406","fix":"407"},{"desc":"408","fix":"409"},{"desc":"410","fix":"411"},{"desc":"400","fix":"412"},{"desc":"413","fix":"414"},{"desc":"415","fix":"416"},{"desc":"417","fix":"418"},{"desc":"419","fix":"420"},{"desc":"421","fix":"422"},"Update the dependencies array to be: [activeBoard, activeBoard.id, activeBoard.layout]",{"range":"423","text":"424"},"Update the dependencies array to be: [activeBoard, activeBoard.id, listConfigSignature, runAllInBoard]",{"range":"425","text":"426"},"Update the dependencies array to be: [sortedStudents, sortType, searchKeyword, getGroupName, t]",{"range":"427","text":"428"},"Update the dependencies array to be: [reasons, t]",{"range":"429","text":"430"},"Update the dependencies array to be: [onGlobalMouseMove, onGlobalMouseUp]",{"range":"431","text":"432"},"Update the dependencies array to be: [currentTheme, t]",{"range":"433","text":"434"},"Update the dependencies array to be: [students, showOobeMessage, t]",{"range":"435","text":"436"},"Update the dependencies array to be: [loadAll]",{"range":"437","text":"438"},"Update the dependencies array to be: [messageApi, t]",{"range":"439","text":"440"},"Update the dependencies array to be: [t]",{"range":"441","text":"442"},"Update the dependencies array to be: [t, isMobile, canEdit, handleOpenTagEditor, handleOpenGroupEditor, handleOpenAvatarEditor, handleDelete]",{"range":"443","text":"444"},{"range":"445","text":"434"},"Update the dependencies array to be: [t, tagOptions]",{"range":"446","text":"447"},"Update the dependencies array to be: [canEdit, fetchRules, fetchStudents, fetchTags]",{"range":"448","text":"449"},"Update the dependencies array to be: [handleOAuthCallback]",{"range":"450","text":"451"},"Update the dependencies array to be: [isAuthenticated, loadFiles, loadStorageUsage]",{"range":"452","text":"453"},"Update the dependencies array to be: [checkCloudStatus, isAuthenticated]",{"range":"454","text":"455"},[18048,18086],"[activeBoard, activeBoard.id, activeBoard.layout]",[18296,18349],"[activeBoard, activeBoard.id, listConfigSignature, runAllInBoard]",[16069,16139],"[sortedStudents, sortType, searchKeyword, getGroupName, t]",[16914,16923],"[reasons, t]",[53173,53175],"[onGlobalMouseMove, onGlobalMouseUp]",[4674,4688],"[currentTheme, t]",[10389,10411],"[students, showOobeMessage, t]",[12150,12152],"[loadAll]",[1697,1709],"[messageApi, t]",[3343,3345],"[t]",[45580,45624],"[t, isMobile, canEdit, handleOpenTagEditor, handleOpenGroupEditor, handleOpenAvatarEditor, handleDelete]",[3185,3199],[1661,1711],"[t, tagOptions]",[4022,4031],"[canEdit, fetchRules, fetchStudents, fetchTags]",[4249,4251],"[handleOAuthCallback]",[1826,1843],"[isAuthenticated, loadFiles, loadStorageUsage]",[1803,1820],"[checkCloudStatus, isAuthenticated]"] \ No newline at end of file diff --git a/package.json b/package.json index 78531b5..cffcc04 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@tauri-apps/plugin-shell": "^2.3.5", "antd": "^6.3.1", "appwrite": "^24.0.0", + "crypto-js": "^4.2.0", "dayjs": "^1.11.20", "i18next": "^25.8.14", "json-rules-engine": "^7.3.1", @@ -36,6 +37,7 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", + "@types/crypto-js": "^4.2.2", "@types/node": "^22.19.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c29935..8c9a7e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: appwrite: specifier: ^24.0.0 version: 24.0.0 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 dayjs: specifier: ^1.11.20 version: 1.11.20 @@ -60,6 +63,9 @@ importers: '@tauri-apps/cli': specifier: ^2.5.0 version: 2.10.1 + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@types/node': specifier: ^22.19.1 version: 22.19.15 @@ -1043,6 +1049,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1302,6 +1311,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3346,6 +3358,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/crypto-js@4.2.2': {} + '@types/estree@1.0.8': {} '@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14)': @@ -3705,6 +3719,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + csstype@3.2.3: {} data-view-buffer@1.0.2: diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 097a4b5..3542307 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -354,10 +354,12 @@ pub async fn oauth_exchange_code( platform_id: String, platform_secret: String, callback_url: String, + state: State<'_, Arc>>, ) -> Result, String> { println!("[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", code, platform_id, callback_url); - let client = reqwest::Client::new(); + let state_guard = state.read(); + let client = &state_guard.http_client; let params = [ ("grant_type", "authorization_code"), ("code", &code), @@ -396,8 +398,11 @@ pub async fn oauth_revoke_token( token_type_hint: Option, platform_id: String, platform_secret: String, + state: State<'_, Arc>>, ) -> Result, String> { - let client = reqwest::Client::new(); + let state_guard = state.read(); + let client = &state_guard.http_client; + let mut payload = serde_json::json!({ "token": token, "client_id": platform_id, @@ -431,8 +436,11 @@ pub async fn oauth_introspect_token( token: String, platform_id: String, platform_secret: String, + state: State<'_, Arc>>, ) -> Result, String> { - let client = reqwest::Client::new(); + let state_guard = state.read(); + let client = &state_guard.http_client; + let response = client .post("https://sectl.top/api/oauth/introspect") .json(&serde_json::json!({ @@ -463,8 +471,11 @@ pub async fn oauth_introspect_token( #[tauri::command] pub async fn oauth_get_user_info( access_token: String, + state: State<'_, Arc>>, ) -> Result, String> { - let client = reqwest::Client::new(); + let state_guard = state.read(); + let client = &state_guard.http_client; + let response = client .get("https://sectl.top/api/oauth/userinfo") .header("Authorization", format!("Bearer {}", access_token)) @@ -493,8 +504,11 @@ pub async fn oauth_refresh_token( refresh_token: String, platform_id: String, platform_secret: String, + state: State<'_, Arc>>, ) -> Result, String> { - let client = reqwest::Client::new(); + let state_guard = state.read(); + let client = &state_guard.http_client; + let response = client .post("https://sectl.top/api/oauth/token") .json(&serde_json::json!({ diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 10eaa5c..db83217 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,4 +1,5 @@ use parking_lot::RwLock; +use reqwest::Client; use sea_orm::DatabaseConnection; use std::sync::Arc; use tauri::AppHandle; @@ -19,6 +20,7 @@ pub struct AppState { pub auto_score: Arc>, pub logger: Arc>, pub data: Arc>, + pub http_client: Client, pub app_handle: AppHandle, } @@ -33,6 +35,11 @@ impl AppState { let logger = Arc::new(RwLock::new(LoggerService::new())); let data = Arc::new(RwLock::new(DataService::new())); let db = Arc::new(RwLock::new(None)); + + let http_client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"); Self { db, @@ -44,6 +51,7 @@ impl AppState { auto_score, logger, data, + http_client, app_handle, } } diff --git a/src/components/OAuth/OAuthLogin.tsx b/src/components/OAuth/OAuthLogin.tsx index 38bd12e..897769a 100644 --- a/src/components/OAuth/OAuthLogin.tsx +++ b/src/components/OAuth/OAuthLogin.tsx @@ -73,7 +73,7 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { if (result.code) { console.log("[OAuth] 授权码:", result.code) console.log("[OAuth] State:", result.state, "期望:", getExpectedState()) - + // 验证 state 防止 CSRF const expectedState = getExpectedState() if (expectedState && result.state !== expectedState) { diff --git a/src/components/ScoreSyncPanel.tsx b/src/components/ScoreSyncPanel.tsx new file mode 100644 index 0000000..2ac7687 --- /dev/null +++ b/src/components/ScoreSyncPanel.tsx @@ -0,0 +1,285 @@ +/** + * SECTL 积分数据同步组件 + * 提供云端数据同步功能 + */ + +import React, { useState, useEffect } from "react" +import { Card, Button, Space, Alert, Descriptions, message, Modal, Typography, Divider } from "antd" +import { + CloudSyncOutlined, + UploadOutlined, + DownloadOutlined, + DeleteOutlined, + CheckCircleOutlined, + ExclamationCircleOutlined, +} from "@ant-design/icons" +import { scoreSyncService } from "../services/scoreSyncService" +import { useSectl } from "../contexts/SectlContext" + +const { Title, Text } = Typography + +interface SyncPanelProps { + // 从父组件获取数据的方法 + onGetLocalData?: () => { + students: any[] + events: any[] + settings: { reasons: any[]; tags: any[] } + } + // 从云端恢复数据到本地的方法 + onRestoreData?: (data: { + students: any[] + events: any[] + settings: { reasons: any[]; tags: any[] } + }) => void +} + +export const ScoreSyncPanel: React.FC = ({ onGetLocalData, onRestoreData }) => { + const { isAuthenticated } = useSectl() + const [syncing, setSyncing] = useState(false) + const [restoring, setRestoring] = useState(false) + const [clearing, setClearing] = useState(false) + const [loading, setLoading] = useState(false) + const [metadata, setMetadata] = useState(null) + const [hasData, setHasData] = useState(false) + + // 检查云端数据状态 + const checkCloudStatus = async () => { + if (!isAuthenticated) return + + try { + setLoading(true) + const [meta, hasCloudData] = await Promise.all([ + scoreSyncService.getSyncMetadata(), + scoreSyncService.hasCloudData(), + ]) + + setMetadata(meta) + setHasData(hasCloudData) + } catch (error: any) { + console.error("检查云端状态失败:", error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (isAuthenticated) { + checkCloudStatus() + } + }, [isAuthenticated]) + + // 上传数据到云端 + const handleSyncToCloud = async () => { + if (!onGetLocalData) { + message.error("未配置数据获取方法") + return + } + + try { + setSyncing(true) + const localData = onGetLocalData() + await scoreSyncService.fullSync(localData) + await checkCloudStatus() + message.success("数据已同步到云端") + } catch (error: any) { + message.error(`同步失败:${error.message}`) + } finally { + setSyncing(false) + } + } + + // 从云端下载数据 + const handleRestoreFromCloud = async () => { + Modal.confirm({ + title: "确认恢复云端数据", + icon: , + content: "此操作将用云端数据覆盖本地数据,确定继续吗?", + okText: "确定", + cancelText: "取消", + onOk: async () => { + if (!onRestoreData) { + message.error("未配置数据恢复方法") + return + } + + try { + setRestoring(true) + const cloudData = await scoreSyncService.restoreFromCloud() + onRestoreData({ + students: cloudData.students as any[], + events: cloudData.events as any[], + settings: cloudData.settings, + }) + message.success("数据已从云端恢复") + } catch (error: any) { + message.error(`恢复失败:${error.message}`) + } finally { + setRestoring(false) + } + }, + }) + } + + // 清除云端数据 + const handleClearCloudData = async () => { + Modal.confirm({ + title: "确认清除云端数据", + icon: , + content: "此操作将永久删除云端的所有数据,且不可恢复!确定继续吗?", + okText: "确定", + cancelText: "取消", + okButtonProps: { danger: true }, + onOk: async () => { + try { + setClearing(true) + await scoreSyncService.clearCloudData() + await checkCloudStatus() + message.success("云端数据已清除") + } catch (error: any) { + message.error(`清除失败:${error.message}`) + } finally { + setClearing(false) + } + }, + }) + } + + if (!isAuthenticated) { + return ( + + + + ) + } + + return ( +
+ + + 云端数据同步 + + } + > + + {/* 云端状态 */} + + + 云端有数据 + + ) : ( + + + 云端暂无数据 + + ) + } + description={ + metadata ? ( +
+ + 最后同步时间:{new Date(metadata.lastSyncTime).toLocaleString("zh-CN")} + +
+ 数据版本:{metadata.version} +
+ ) : ( + "暂无同步记录" + ) + } + type={hasData ? "success" : "warning"} + showIcon + /> + + + + {/* 同步操作 */} +
+ 同步操作 + + +
    +
  • 上传:将本地积分数据同步到云端
  • +
  • 下载:从云端恢复数据到本地(会覆盖本地数据)
  • +
  • 清除:删除云端的所有数据(不可恢复)
  • +
+
+ } + type="info" + showIcon + style={{ marginBottom: 16 }} + /> + + + + + + + + + + + +
+ + + + {/* 同步记录 */} +
+ 同步信息 + {metadata ? ( + + + {new Date(metadata.lastSyncTime).toLocaleString("zh-CN")} + + {metadata.version} + + {metadata.platform || "SecScore"} + + + ) : ( + 暂无同步记录 + )} +
+ + + + ) +} diff --git a/src/components/SectlCloudStorageManager.tsx b/src/components/SectlCloudStorageManager.tsx new file mode 100644 index 0000000..6fa3d57 --- /dev/null +++ b/src/components/SectlCloudStorageManager.tsx @@ -0,0 +1,313 @@ +/** + * SECTL 云存储管理组件 + */ + +import React, { useState, useEffect } from "react" +import { + Card, + Table, + Button, + Upload, + Space, + Progress, + Modal, + Input, + message, + Popconfirm, + Typography, +} from "antd" +import { + CloudOutlined, + UploadOutlined, + DownloadOutlined, + DeleteOutlined, + ShareAltOutlined, + ReloadOutlined, + FileOutlined, +} from "@ant-design/icons" +import type { UploadFile } from "antd" +import { sectlCloudStorage, CloudFile, StorageUsage } from "../services/sectlCloudStorage" +import { useSectl } from "../contexts/SectlContext" + +const { Title } = Typography + +export const SectlCloudStorageManager: React.FC = () => { + const { isAuthenticated } = useSectl() + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [storageUsage, setStorageUsage] = useState(null) + const [uploading, setUploading] = useState(false) + const [renameModal, setRenameModal] = useState<{ + visible: boolean + fileId?: string + filename?: string + }>({ visible: false }) + const [newFilename, setNewFilename] = useState("") + + // 加载文件列表 + const loadFiles = async () => { + if (!isAuthenticated) return + + try { + setLoading(true) + const result = await sectlCloudStorage.listFiles({ limit: 100 }) + setFiles(result.files) + } catch (error: any) { + message.error(`加载文件列表失败:${error.message}`) + } finally { + setLoading(false) + } + } + + // 加载存储使用情况 + const loadStorageUsage = async () => { + if (!isAuthenticated) return + + try { + const usage = await sectlCloudStorage.getStorageUsage() + setStorageUsage(usage) + } catch (error: any) { + console.error("加载存储使用情况失败:", error) + } + } + + useEffect(() => { + if (isAuthenticated) { + loadFiles() + loadStorageUsage() + } + }, [isAuthenticated]) + + // 处理文件上传 + const handleUpload = async (file: UploadFile): Promise => { + if (!file.originFileObj) return + + try { + setUploading(true) + await sectlCloudStorage.uploadFile(file.originFileObj) + message.success(`文件 ${file.name} 上传成功`) + loadFiles() + loadStorageUsage() + } catch (error: any) { + message.error(`上传失败:${error.message}`) + } finally { + setUploading(false) + } + } + + // 处理文件下载 + const handleDownload = async (file: CloudFile) => { + try { + const { download_url } = await sectlCloudStorage.downloadFile(file.file_id) + window.open(download_url, "_blank") + message.success("开始下载") + } catch (error: any) { + message.error(`下载失败:${error.message}`) + } + } + + // 处理文件删除 + const handleDelete = async (file: CloudFile) => { + try { + await sectlCloudStorage.deleteFile(file.file_id) + message.success("文件已删除") + loadFiles() + loadStorageUsage() + } catch (error: any) { + message.error(`删除失败:${error.message}`) + } + } + + // 处理文件重命名 + const handleRename = async () => { + if (!renameModal.fileId || !newFilename) return + + try { + await sectlCloudStorage.renameFile(renameModal.fileId, newFilename) + message.success("重命名成功") + setRenameModal({ visible: false }) + setNewFilename("") + loadFiles() + } catch (error: any) { + message.error(`重命名失败:${error.message}`) + } + } + + // 创建分享链接 + const handleCreateShare = async (file: CloudFile) => { + try { + const share = await sectlCloudStorage.createShare(file.file_id, { + expires_in: 86400, // 1 天 + }) + + // 复制到剪贴板 + await navigator.clipboard.writeText(share.share_url) + message.success("分享链接已创建并复制到剪贴板") + } catch (error: any) { + message.error(`创建分享失败:${error.message}`) + } + } + + // 表格列定义 + const columns = [ + { + title: "文件名", + dataIndex: "filename", + key: "filename", + render: (text: string, record: CloudFile) => ( + + + {text} + + .{record.extension} + + + ), + }, + { + title: "大小", + dataIndex: "size_formatted", + key: "size_formatted", + width: 100, + }, + { + title: "上传时间", + dataIndex: "created_at", + key: "created_at", + width: 180, + render: (text: string) => new Date(text).toLocaleString("zh-CN"), + }, + { + title: "操作", + key: "action", + width: 250, + render: (_: any, record: CloudFile) => ( + + + + + handleDelete(record)} + okText="确定" + cancelText="取消" + > + + + + ), + }, + ] + + if (!isAuthenticated) { + return ( + +
+ + + 请先登录 SECTL 以使用云存储服务 + +
+
+ ) + } + + return ( +
+ + + 云存储管理 + + } + extra={ + + + + + + + } + > + {/* 存储使用情况 */} + {storageUsage && ( +
+ +
+ 已用:{storageUsage.used_storage_formatted} / {storageUsage.total_storage_formatted} +
+ 90 ? "exception" : "active"} + /> +
+ 文件数量:{storageUsage.file_count} +
+
+
+ )} + + {/* 文件列表 */} + `共 ${total} 个文件`, + }} + /> + + + {/* 重命名对话框 */} + { + setRenameModal({ visible: false }) + setNewFilename("") + }} + okText="确定" + cancelText="取消" + > + setNewFilename(e.target.value)} + onPressEnter={handleRename} + defaultValue={renameModal.filename} + /> + + + ) +} diff --git a/src/components/SectlLoginButton.tsx b/src/components/SectlLoginButton.tsx new file mode 100644 index 0000000..a142d81 --- /dev/null +++ b/src/components/SectlLoginButton.tsx @@ -0,0 +1,67 @@ +/** + * SECTL 登录按钮组件 + */ + +import React, { useState } from "react" +import { Button, message } from "antd" +import { LoginOutlined, UserOutlined } from "@ant-design/icons" +import { useSectl } from "../contexts/SectlContext" + +interface SectlLoginButtonProps { + onLoginSuccess?: () => void + onLogoutSuccess?: () => void + scope?: string[] +} + +export const SectlLoginButton: React.FC = ({ + onLoginSuccess, + onLogoutSuccess, + scope = ["user:read", "cloud:read", "cloud:write"], +}) => { + const { isAuthenticated, isLoading, userInfo, login, logout } = useSectl() + const [isLogging, setIsLogging] = useState(false) + + const handleLogin = async () => { + try { + setIsLogging(true) + await login(scope) + message.success("登录成功!") + onLoginSuccess?.() + } catch (error: any) { + message.error(`登录失败:${error.message}`) + } finally { + setIsLogging(false) + } + } + + const handleLogout = async () => { + try { + setIsLogging(true) + await logout() + message.success("已退出登录") + onLogoutSuccess?.() + } catch (error: any) { + message.error(`登出失败:${error.message}`) + } finally { + setIsLogging(false) + } + } + + if (isLoading) { + return + } + + if (isAuthenticated) { + return ( + + ) + } + + return ( + + ) +} diff --git a/src/components/SectlSettingsPanel.tsx b/src/components/SectlSettingsPanel.tsx new file mode 100644 index 0000000..7aec809 --- /dev/null +++ b/src/components/SectlSettingsPanel.tsx @@ -0,0 +1,157 @@ +/** + * SECTL 设置面板组件 + */ + +import React, { useState } from "react" +import { Card, Form, Input, Button, Space, Alert, Tag, Descriptions, message } from "antd" +import { + SettingOutlined, + UserOutlined, + CheckCircleOutlined, + CloseCircleOutlined, +} from "@ant-design/icons" +import { useSectl } from "../contexts/SectlContext" +import { SectlLoginButton } from "./SectlLoginButton" +import { ScoreSyncPanel } from "./ScoreSyncPanel" + +export const SectlSettingsPanel: React.FC = () => { + const { isAuthenticated, isLoading, userInfo, platformId, setPlatformId, refreshUserInfo } = + useSectl() + const [form] = Form.useForm() + const [isSaving, setIsSaving] = useState(false) + + // 保存平台 ID 配置 + const handleSaveConfig = async (values: any) => { + try { + setIsSaving(true) + setPlatformId(values.platformId) + + // 保存到本地存储 + localStorage.setItem("sectl_platform_id", values.platformId) + + message.success("配置已保存") + } catch (error: any) { + message.error(`保存失败:${error.message}`) + } finally { + setIsSaving(false) + } + } + + // 加载已保存的配置 + React.useEffect(() => { + const savedPlatformId = localStorage.getItem("sectl_platform_id") + if (savedPlatformId) { + form.setFieldsValue({ platformId: savedPlatformId }) + } + }, [form]) + + return ( +
+ {/* 认证状态卡片 */} + + + SECTL 认证状态 + + } + style={{ marginBottom: 24 }} + > + +
+
+ + 当前状态: + {isLoading ? ( + 加载中... + ) : isAuthenticated ? ( + } color="success"> + 已登录 + + ) : ( + } color="default"> + 未登录 + + )} + +
+ {}} /> +
+ + {isAuthenticated && userInfo && ( + + {userInfo.user_id} + {userInfo.name} + {userInfo.email} + {userInfo.permission} + {userInfo.role} + {userInfo.github_username && ( + {userInfo.github_username} + )} + + )} +
+
+ + {/* 平台配置卡片 */} + + + 平台配置 + + } + style={{ marginBottom: 24 }} + > +
+ + + + + + + + + + + + + +
+ + {/* 数据同步面板 */} + +
+ ) +} diff --git a/src/contexts/SectlContext.tsx b/src/contexts/SectlContext.tsx new file mode 100644 index 0000000..11202e3 --- /dev/null +++ b/src/contexts/SectlContext.tsx @@ -0,0 +1,147 @@ +/** + * SECTL Context + * 提供 SECTL 服务的全局状态管理 + */ + +import React, { createContext, useContext, useState, useEffect, ReactNode } from "react" +import { sectlAuth, UserInfo, TokenData } from "../services/sectlAuth" + +interface SectlContextType { + isAuthenticated: boolean + isLoading: boolean + userInfo: UserInfo | null + token: TokenData | null + platformId: string + login: (scope?: string[]) => Promise + logout: () => Promise + setPlatformId: (platformId: string) => void + refreshUserInfo: () => Promise +} + +const SectlContext = createContext(undefined) + +interface SectlProviderProps { + children: ReactNode + initialPlatformId?: string +} + +export const SectlProvider: React.FC = ({ + children, + initialPlatformId = "", +}) => { + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [userInfo, setUserInfo] = useState(null) + const [token, setToken] = useState(null) + const [platformId, setPlatformIdState] = useState(initialPlatformId) + + // 初始化时检查是否有已保存的 Token + useEffect(() => { + const initAuth = async () => { + try { + if (platformId) { + sectlAuth.initialize(platformId) + } + + const currentToken = sectlAuth.getToken() + if (currentToken) { + setToken(currentToken) + setIsAuthenticated(true) + + // 验证 Token 是否有效 + const isValid = await sectlAuth.introspectToken() + if (isValid.active) { + await loadUserInfo() + } else { + // Token 无效,尝试刷新 + try { + await sectlAuth.refreshAccessToken() + const newToken = sectlAuth.getToken() + if (newToken) { + setToken(newToken) + await loadUserInfo() + } else { + setIsAuthenticated(false) + } + } catch { + setIsAuthenticated(false) + setToken(null) + } + } + } + } catch (error) { + console.error("初始化认证失败:", error) + setIsAuthenticated(false) + } finally { + setIsLoading(false) + } + } + + initAuth() + }, [platformId]) + + const loadUserInfo = async () => { + try { + const info = await sectlAuth.getUserInfo() + setUserInfo(info) + } catch (error) { + console.error("加载用户信息失败:", error) + throw error + } + } + + const login = async (scope: string[] = ["user:read"]) => { + try { + const tokenData = await sectlAuth.authorize(scope) + setToken(tokenData) + setIsAuthenticated(true) + await loadUserInfo() + } catch (error) { + console.error("登录失败:", error) + throw error + } + } + + const logout = async () => { + try { + await sectlAuth.logout() + setToken(null) + setUserInfo(null) + setIsAuthenticated(false) + } catch (error) { + console.error("登出失败:", error) + throw error + } + } + + const setPlatformId = (id: string) => { + setPlatformIdState(id) + sectlAuth.initialize(id) + } + + const refreshUserInfo = async () => { + await loadUserInfo() + } + + const value: SectlContextType = { + isAuthenticated, + isLoading, + userInfo, + token, + platformId, + login, + logout, + setPlatformId, + refreshUserInfo, + } + + return {children} +} + +export const useSectl = (): SectlContextType => { + const context = useContext(SectlContext) + if (context === undefined) { + throw new Error("useSectl 必须在 SectlProvider 内部使用") + } + return context +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 560843e..e3926f2 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -2,23 +2,65 @@ import i18n from "i18next" import { initReactI18next } from "react-i18next" import zhCN from "./locales/zh-CN.json" import enUS from "./locales/en-US.json" +import frFR from "./locales/fr-FR.json" +import esES from "./locales/es-ES.json" +import jaJP from "./locales/ja-JP.json" +import koKR from "./locales/ko-KR.json" +import ruRU from "./locales/ru-RU.json" +import deDE from "./locales/de-DE.json" +import ptBR from "./locales/pt-BR.json" +import arSA from "./locales/ar-SA.json" export const defaultNS = "translation" export const resources = { "zh-CN": { translation: zhCN }, "en-US": { translation: enUS }, + "fr-FR": { translation: frFR }, + "es-ES": { translation: esES }, + "ja-JP": { translation: jaJP }, + "ko-KR": { translation: koKR }, + "ru-RU": { translation: ruRU }, + "de-DE": { translation: deDE }, + "pt-BR": { translation: ptBR }, + "ar-SA": { translation: arSA }, } as const -export type AppLanguage = "zh-CN" | "en-US" +export type AppLanguage = + | "zh-CN" + | "en-US" + | "fr-FR" + | "es-ES" + | "ja-JP" + | "ko-KR" + | "ru-RU" + | "de-DE" + | "pt-BR" + | "ar-SA" export const languageNames: Record = { "zh-CN": "简体中文", "en-US": "English", + "fr-FR": "Français", + "es-ES": "Español", + "ja-JP": "日本語", + "ko-KR": "한국어", + "ru-RU": "Русский", + "de-DE": "Deutsch", + "pt-BR": "Português", + "ar-SA": "العربية", } export const languageOptions: { value: AppLanguage; label: string }[] = [ { value: "zh-CN", label: "简体中文" }, { value: "en-US", label: "English" }, + { value: "fr-FR", label: "Français" }, + { value: "es-ES", label: "Español" }, + { value: "ja-JP", label: "日本語" }, + { value: "ko-KR", label: "한국어" }, + { value: "ru-RU", label: "Русский" }, + { value: "de-DE", label: "Deutsch" }, + { value: "pt-BR", label: "Português" }, + { value: "ar-SA", label: "العربية" }, ] const savedLanguage = (() => { diff --git a/src/i18n/locales/ar-SA.json b/src/i18n/locales/ar-SA.json new file mode 100644 index 0000000..2ce7d70 --- /dev/null +++ b/src/i18n/locales/ar-SA.json @@ -0,0 +1,668 @@ +{ + "common": { + "next": "التالي", + "prev": "السابق", + "finish": "إنهاء", + "cancel": "إلغاء", + "save": "حفظ", + "loading": "جاري التحميل...", + "confirm": "تأكيد", + "success": "نجاح", + "error": "خطأ", + "delete": "حذف", + "edit": "تعديل", + "add": "إضافة", + "search": "بحث", + "clear": "مسح", + "close": "إغلاق", + "yes": "نعم", + "no": "لا", + "submit": "إرسال", + "reset": "إعادة تعيين", + "import": "استيراد", + "export": "تصدير", + "create": "إنشاء", + "update": "تحديث", + "refresh": "تحديث", + "view": "عرض", + "name": "الاسم", + "status": "الحالة", + "operation": "العملية", + "description": "الوصف", + "total": "إجمالي {{count}} عناصر", + "noData": "لا توجد بيانات", + "pleaseSelect": "الرجاء الاختيار", + "pleaseEnter": "الرجاء الإدخال", + "readOnly": "وضع القراءة فقط", + "none": "لا يوجد", + "more": "المزيد" + }, + "oobe": { + "title": "مرحبًا بك في SecScore", + "subtitle": "خبير إدارة النقاط التعليمية", + "step": "الخطوة {{current}}/{{total}}", + "skip": "تخطي", + "steps": { + "entry": { + "title": "البدء", + "description": "اختر الإجراء المطلوب تنفيذه", + "enterOobe": "دخول إلى OOBE", + "connectPostgresAutoSync": "الاتصال بـ PostgreSQL مع مزامنة تلقائية", + "skipDirect": "تخطي OOBE والدخول مباشرة" + }, + "postgresql": { + "title": "الاتصال بـ PostgreSQL", + "description": "أدخل سلسلة اتصال PostgreSQL. سيقوم النظام بالاتصال والمزامنة تلقائيًا.", + "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", + "autoSyncAndEnter": "الاتصال والمزامنة التلقائية" + }, + "language": { + "title": "اختيار اللغة", + "description": "الرجاء اختيار لغتك المفضلة" + }, + "theme": { + "title": "إعداد السمة", + "description": "اختر مظهر الواجهة", + "mode": "الوضع", + "lightMode": "فاتح", + "darkMode": "داكن", + "primaryColor": "اللون الأساسي", + "backgroundGradient": "تدرج الخلفية" + }, + "password": { + "title": "إعداد كلمة المرور", + "description": "اضبط كلمة مرور لحماية بياناتك (اختياري)", + "adminPassword": "كلمة مرور المسؤول", + "adminPasswordHint": "الوصول إلى جميع الوظائف، 6 أرقام", + "pointsPassword": "كلمة مرور النقاط", + "pointsPasswordHint": "عمليات النقاط فقط، 6 أرقام", + "passwordPlaceholder": "أدخل 6 أرقام", + "hint": "اتركه فارغًا للتجاوز، قم بالإعداد لاحقًا في الإعدادات" + }, + "students": { + "title": "استيراد قائمة الطلاب", + "description": "أضف قائمة طلاب لبدء إدارة النقاط", + "import": "استيراد القائمة", + "manual": "إضافة يدويًا", + "importHint": "يدعم ملفات Excel (.xlsx) أو JSON", + "manualHint": "الصق عدة أسطر، اسم طالب واحد لكل سطر", + "dragDrop": "اسحب وأفلت الملفات هنا أو انقر للتحديد", + "supportedFormats": "التنسيقات المدعومة: .xlsx, .json", + "studentName": "اسم الطالب", + "addStudent": "إضافة طالب", + "noStudents": "لا يوجد طلاب بعد، يرجى الإضافة أو الاستيراد", + "studentCount": "تمت إضافة {{count}} طالب", + "studentExists": "الطالب موجود بالفعل", + "importSuccess": "تم استيراد {{count}} طالب بنجاح", + "noNewStudents": "لا يوجد طلاب جدد للاستيراد", + "parseFailed": "فشل تحليل الملف", + "unsupportedFormat": "تنسيق الملف غير مدعوم" + }, + "reasons": { + "title": "إعداد الأسباب", + "description": "أضف أسباب التغيير الشائعة للنقاط", + "addReason": "إضافة سبب", + "reasonName": "اسم السبب", + "points": "النقاط", + "positive": "إضافة نقاط", + "negative": "خصم نقاط", + "noReasons": "لا يوجد أسباب بعد، يرجى إضافة أسباب شائعة", + "reasonCount": "تمت إضافة {{count}} أسباب", + "reasonExists": "السبب موجود بالفعل" + }, + "start": { + "title": "جاهز للاستخدام", + "description": "كل شيء جاهز. لنبدأ استخدام SecScore!", + "features": { + "score": "إدارة نقاط الطلاب بسهولة", + "history": "سجل تغييرات النقاط الكامل", + "settlement": "يدعم التسوية والأرشفة" + }, + "startButton": "البدء" + } + } + }, + "settings": { + "title": "إعدادات النظام", + "tabs": { + "appearance": "المظهر", + "security": "الأمان", + "account": "الحساب", + "database": "اتصال قاعدة البيانات", + "dataManagement": "إدارة البيانات", + "urlProtocol": "بروتوكول URL", + "about": "حول" + }, + "language": "اللغة", + "languageHint": "اختر لغة عرض الواجهة", + "fontFamily": "الخط العام", + "fontFamilyHint": "اختر خط الواجهة (يتطلب تحديث)", + "theme": "السمة", + "password": "إعداد كلمة المرور", + "themeMode": "الوضع", + "lightMode": "فاتح", + "darkMode": "داكن", + "primaryColor": "اللون الأساسي", + "backgroundGradient": "تدرج الخلفية", + "searchKeyboard": { + "title": "لوحة المفاتيح للبحث", + "hint": "اختر تخطيط لوحة المفاتيح تحت حقل البحث", + "disableToggle": "تعطيل لوحة المفاتيح للبحث", + "disableHint": "لن تظهر لوحة المفاتيح تحت حقل البحث بعد الآن", + "options": { + "qwerty26": "26 مفتاح (QWERTY)", + "t9": "9 مفاتيح (T9)" + } + }, + "interfaceZoom": "تكبير الواجهة", + "zoomOptions": { + "small70": "صغير (70%)", + "default100": "افتراضي (100%)", + "large150": "كبير (150%)" + }, + "general": { + "saved": "تم الحفظ", + "saveFailed": "فشل الحفظ" + }, + "zoomHint": "ضبط الحجم الكلي للواجهة", + "mobile": { + "navigationTitle": "مدخلات الصفحة", + "bottomNav": { + "label": "وظائف شريط التنقل السفلي", + "hint": "اختر الوظائف التي ستظهر في الشريط السفلي للهاتف المحمول. يعرض الشريط ما يصل إلى 4 عناصر، والباقي ينتقل إلى «المزيد»." + }, + "dragHint": "اسحب لإعادة الترتيب. تظهر أول 4 عناصر في الشريط السفلي، والباقي في «المزيد».", + "dropHere": "أفلت هنا", + "bottomSection": "الشريط السفلي (الحد الأقصى 4)", + "moreSection": "المزيد", + "moveToMore": "نقل إلى المزيد", + "moveToBottom": "نقل للأسفل" + }, + "zoomUpdated": "تم تحديث تكبير الواجهة", + "updateFailed": "فشل التحديث", + "security": { + "title": "نظام حماية كلمة المرور", + "adminPassword": "كلمة مرور المسؤول", + "pointsPassword": "كلمة مرور النقاط", + "recoveryString": "نص الاسترداد", + "set": "تم الإعداد", + "notSet": "غير مُعد", + "generated": "تم الإنشاء", + "notGenerated": "غير مُنشأ", + "enterPassword": "أدخل 6 أرقام (اتركه فارغًا للتجاوز)", + "adminPasswordPlaceholder": "أدخل 6 أرقام لكلمة مرور المسؤول (اتركه فارغًا للتجاوز)", + "pointsPasswordPlaceholder": "أدخل 6 أرقام لكلمة مرور النقاط (اتركه فارغًا للتجاوز)", + "recoveryPlaceholder": "أدخل نص الاسترداد", + "savePassword": "حفظ كلمة المرور", + "savePasswords": "حفظ كلمات المرور", + "generateRecovery": "إنشاء نص الاسترداد", + "clearAllPasswords": "مسح جميع كلمات المرور", + "recoveryReset": "إعادة تعيين نص الاسترداد", + "enterRecovery": "أدخل نص الاسترداد", + "resetPassword": "إعادة تعيين كلمة المرور", + "resetHint": "ستقوم إعادة التعيين بمسح كلمات مرور المسؤول والنقاط وستنشئ نص استرداد جديد.", + "confirmClear": "تأكيد مسح جميع كلمات المرور؟", + "clearHint": "بعد المسح، سيتم تعطيل الحماية (بدون كلمة مرور = إذن المسؤول افتراضيًا).", + "saved": "تم تحديث كلمة المرور", + "saveFailed": "فشل التحديث", + "generateFailed": "فشل الإنشاء", + "resetFailed": "فشل إعادة التعيين", + "cleared": "تم المسح", + "clearFailed": "فشل المسح", + "passwordClearedNewRecovery": "تم مسح كلمة المرور، نص استرداد جديد", + "newRecoveryString": "نص استرداد جديد (الرجاء الحفظ)" + }, + "account": { + "title": "حساب SECTL Auth", + "notLoggedIn": "غير مسجل الدخول إلى SECTL Auth", + "oauthHint": "سجل الدخول بحساب SECTL Auth لاستخدام المصادقة الموحدة وتسجيل الخروج عن بُعد", + "loginButton": "تسجيل الدخول عبر SECTL Auth", + "loginSuccess": "تم تسجيل الدخول بنجاح", + "logout": "تسجيل الخروج", + "userId": "معرف المستخدم", + "permission": "مستوى الإذن" + }, + "database": { + "title": "اتصال قاعدة البيانات", + "currentStatus": "حالة قاعدة البيانات الحالية", + "sqliteLocal": "قاعدة بيانات SQLite المحلية", + "postgresqlRemote": "قاعدة بيانات PostgreSQL البعيدة", + "connected": "متصل", + "disconnected": "غير متصل", + "postgresqlConnection": "اتصال PostgreSQL البعيد", + "connectionHint": "أدخل سلسلة اتصال PostgreSQL للاتصال بقاعدة بيانات بعيدة، تدعم المزامنة عبر منصات متعددة.", + "testConnection": "اختبار الاتصال", + "switchToPostgreSQL": "التبديل إلى PostgreSQL", + "switchToSQLite": "التبديل إلى SQLite المحلي", + "connectionTestSuccess": "اختبار الاتصال ناجح", + "connectionTestFailed": "فشل اختبار الاتصال", + "switchedTo": "تم التبديل إلى قاعدة بيانات {{type}}", + "switchedToSQLite": "تم التبديل إلى قاعدة بيانات SQLite المحلية", + "switchFailed": "فشل التبديل", + "syncDescription": "تعليمات المزامنة عبر منصات متعددة", + "syncPoint1": "يسمح استخدام قاعدة بيانات PostgreSQL البعيدة بمزامنة البيانات عبر منصات متعددة.", + "syncPoint2": "آليات قائمة العمليات المدمجة لضمان اتساق البيانات أثناء العمليات المتزامنة.", + "syncPoint3": "يلزم إعادة تشغيل التطبيق بعد تبديل قاعدة البيانات.", + "syncPoint4": "خدمات قواعد البيانات السحابية الموصى بها: Neon, Supabase, AWS RDS, إلخ.", + "enterConnectionString": "الرجاء إدخال سلسلة اتصال PostgreSQL", + "connectionExample": "مثال: postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", + "uploadButton": "رفع البيانات المحلية إلى الخادم البعيد", + "uploadHint": "بعد الاتصال بقاعدة البيانات البعيدة، ارفع ودمج سجل SQLite المحلي إلى الخادم البعيد.", + "uploadNeedRemote": "الرجاء التبديل والاتصال بقاعدة بيانات PostgreSQL البعيدة أولاً", + "noNeedUpload": "البيانات المحلية والبعيدة متسقة بالفعل", + "uploadConfirmTitle": "تأكيد رفع البيانات المحلية إلى الخادم البعيد؟", + "uploadConfirmContent": "ست ưuوي المزامنة البيانات المحلية. تم الكشف عن: محلي فقط {{localOnly}}, بعيد فقط {{remoteOnly}}, تعارضات {{conflicts}}.", + "uploadSuccess": "تم رفع البيانات المحلية إلى الخادم البعيد", + "uploadFailed": "فشل الرفع" + }, + "data": { + "title": "إدارة البيانات", + "settlement": "التسوية", + "settlementAndRestart": "التسوية وإعادة التشغيل", + "settlementHint": "تصنيف السجلات غير المسوّاة في مرحلة واحدة وإعادة تعيين جميع نقاط الطلاب إلى الصفر.", + "importExport": "استيراد / تصدير", + "exportJson": "تصدير JSON", + "importJson": "استيراد JSON", + "importHint": "سيؤدي الاستيراد إلى استبدال الطلاب/الأسباب/السجلات/الإعدادات الحالية (الإعدادات الأمنية مستثناة).", + "logs": "السجلات", + "logLevel": "مستوى السجل", + "logOperation": "عملية السجلات", + "viewLogs": "عرض السجلات", + "exportLogs": "تصدير السجلات", + "clearLogs": "مسح السجلات", + "logsCleared": "تم مسح السجلات", + "systemLogs": "سجلات النظام (آخر 200)", + "noLogs": "لا توجد سجلات", + "logLevelUpdated": "تم تحديث مستوى السجل", + "readLogsFailed": "فشل قراءة السجلات", + "logsExported": "تم تصدير السجلات", + "exportFailed": "فشل التصدير", + "exportSuccess": "تم التصدير بنجاح", + "importSuccess": "تم الاستيراد بنجاح، جاري التحديث", + "importFailed": "فشل الاستيراد", + "clearFailed": "فشل المسح", + "confirmSettlement": "تأكيد التسوية وإعادة التشغيل؟", + "settlementConfirm1": "سيؤدي هذا إلى أرشفة السجلات غير المسوّاة في مرحلة واحدة وإعادة تعيين جميع نقاط الطلاب إلى الصفر.", + "settlementConfirm2": "تبقى قائمة الطلاب دون تغيير؛ سجل التسوية متاح في صفحة «سجل التسوية».", + "settlementSuccess": "تمت التسوية بنجاح، تم إعادة تشغيل النقاط", + "settlementFailed": "فشل التسوية", + "settle": "التسوية", + "logLevels": { + "debug": "DEBUG (تصحيح)", + "info": "INFO (معلومات)", + "warn": "WARN (تحذير)", + "error": "ERROR (خطأ)" + } + }, + "url": { + "title": "بروتوكول URL (secscore://)", + "protocol": "بروتوكول URL", + "description": "يمكنك استدعاء SecScore عبر URL لتنفيذ العمليات، على سبيل المثال:", + "register": "تسجيل بروتوكول URL", + "registered": "تم تسجيل بروتوكول URL", + "registerFailed": "فشل التسجيل", + "installerRequired": "يتطلب الإصدار المثبت من SecScore، قد لا يعمل في وضع التطوير." + }, + "mcp": { + "title": "خدمة MCP", + "description": "إدارة خدمة MCP HTTP المدمجة لعملاء MCP الخارجيين.", + "running": "جاري التشغيل", + "stopped": "متوقف", + "noUrl": "لا يوجد URL", + "host": "المضيف", + "port": "المنفذ", + "start": "بدء", + "stop": "إيقاف", + "refresh": "تحديث الحالة", + "hint": "يوصى بالارتباط بـ 127.0.0.1 فقط؛ بعد البدء، نقطة النهاية هي http://host:port/mcp", + "hostRequired": "الرجاء إدخال مضيف MCP", + "portInvalid": "الرجاء إدخال منفذ صالح (1-65535)", + "startSuccess": "تم بدء خادم MCP", + "startFailed": "فشل بدء خادم MCP", + "stopSuccess": "تم إيقاف خادم MCP", + "stopFailed": "فشل إيقاف خادم MCP", + "startTimeout": "انتهى وقت بدء خادم MCP، يرجى المحاولة مرة أخرى", + "stopTimeout": "انتهى وقت إيقاف خادم MCP، يرجى المحاولة مرة أخرى" + }, + "app": { + "title": "تحكمات التطبيق", + "description": "إعادة تشغيل أو إغلاق التطبيق بسرعة.", + "restart": "إعادة تشغيل التطبيق", + "quit": "إغلاق التطبيق", + "confirmRestartTitle": "تأكيد إعادة تشغيل التطبيق؟", + "confirmRestartContent": "سيتم إغلاق التطبيق وإعادة تشغيله فورًا.", + "confirmQuitTitle": "تأكيد إغلاق التطبيق؟", + "confirmQuitContent": "سيتم إغلاق التطبيق فورًا.", + "restartFailed": "فشل إعادة التشغيل", + "quitFailed": "فشل الإغلاق" + }, + "about": { + "title": "حول", + "appName": "إدارة النقاط التعليمية", + "version": "الإصدار", + "copyright": "حقوق الطبع والنشر", + "ipcStatus": "حالة IPC", + "ipcConnected": "متصل", + "ipcDisconnected": "غير متصل (فشل التحميل المسبق)", + "environment": "البيئة", + "toggleDevTools": "تبديل أدوات المطور", + "license": "SecScore تحت ترخيص GPL3.0", + "copyRightText": "CopyRight © 2025-{{year}} SECTL" + } + }, + "sidebar": { + "home": "الرئيسية", + "students": "إدارة الطلاب", + "score": "عمليات النقاط", + "boards": "اللوحات", + "leaderboard": "لوحة الصدارة", + "settlements": "سجل التسوية", + "reasons": "إدارة الأسباب", + "autoScore": "إضافة تلقائية للنقاط", + "rewardExchange": "تبادل المكافآت", + "rewardSettings": "إعدادات المكافآت", + "settings": "إعدادات النظام", + "remoteDb": "قاعدة البيانات البعيدة", + "refreshStatus": "تحديث الحالة", + "syncNow": "مزامنة الآن", + "syncSuccess": "تمت المزامنة بنجاح", + "syncFailed": "فشل المزامنة", + "getDbStatusFailed": "فشل في الحصول على حالة قاعدة البيانات", + "notRemoteMode": "وضع قاعدة البيانات البعيدة غير مفعل، يرجى إعادة تشغيل التطبيق", + "dbNotConnected": "قاعدة البيانات غير متصلة" + }, + "auth": { + "unlock": "فتح الإذن", + "unlockHint": "أدخل كلمة المرور المكوّنة من 6 أرقام: كلمة مرور المسؤول = وصول كامل، كلمة مرور النقاط = عمليات النقاط فقط.", + "unlockButton": "فتح", + "unlocked": "تم فتح الإذن", + "logout": "تم التبديل إلى وضع القراءة فقط", + "passwordPlaceholder": "مثال: 123456", + "passwordError": "كلمة المرور غير صحيحة", + "enterPassword": "إدخال كلمة المرور", + "lock": "قفل" + }, + "languages": { + "zh-CN": "简体中文", + "en-US": "English", + "ja-JP": "日本語", + "fr-FR": "Français", + "es-ES": "Español", + "ko-KR": "한국어", + "ru-RU": "Русский", + "de-DE": "Deutsch", + "pt-BR": "Português", + "ar-SA": "العربية" + }, + "theme": { + "colorAndBackground": "اللون والخلفية", + "gradientLabels": { + "blue": "أزرق منعش", + "pink": "وردي ناعم", + "cyan": "أزرق سماوي", + "purple": "بنفسجي" + }, + "gradientDirections": { + "vertical": "عمودي", + "horizontal": "أفقي", + "diagonal": "قطري" + }, + "generate": "إنشاء", + "saved": "تم الحفظ", + "saveFailed": "فشل الحفظ", + "mode": "الوضع", + "myTheme": "سمتي" + }, + "permissions": { + "admin": "إذن المسؤول", + "points": "إذن النقاط", + "view": "قراءة فقط" + }, + "home": { + "title": "الصفحة الرئيسية لنقاط الطلاب", + "subtitle": "إجمالي {{count}} طالب، انقر على البطاقة لتنفيذ العمليات", + "searchPlaceholder": "البحث بالاسم/البينين...", + "sortBy": { + "alphabet": "الترتيب حسب الاسم", + "surname": "المجموعة حسب الاسم العائلي", + "group": "الترتيب حسب المجموعة", + "score": "الترتيب حسب النقاط" + }, + "layoutBy": { + "grouped": "قائمة مجمعة", + "squareGrid": "شبكة مربعة" + }, + "noStudents": "لا توجد بيانات طلاب، يرجى الإضافة في إدارة الطلاب", + "noMatch": "لم يتم العثور على طلاب مطابقين", + "clearSearch": "مسح البحث", + "points": "نقاط", + "currentScore": "النقاط الحالية", + "quickOptions": "خيارات سريعة", + "adjustPoints": "ضبط النقاط", + "customPoints": "نقاط مخصصة", + "customPointsHint": "أدخل أي قيمة في حقل الإدخال", + "reason": "السبب", + "reasonPlaceholder": "أدخل سبب تغيير النقاط (اختياري)", + "preview": "معاينة التغيير", + "noReason": "(بدون سبب)", + "ungrouped": "غير مجمعة", + "category": { + "others": "أخرى" + }, + "scoreAdded": "تمت إضافة {{points}} نقطة لـ {{name}}", + "scoreDeducted": "تم خصم {{points}} نقطة من {{name}}", + "submitFailed": "فشل الإرسال", + "pleaseSelectPoints": "الرجاء اختيار أو إدخال النقاط", + "reasonDefault": "{{action}} {{points}} نقطة", + "studentCount": "{{count}} طالب", + "operationTitle": "عملية النقاط: {{name}}", + "submitOperation": "إرسال العملية", + "operationTitleBatch": "عملية النقاط: تم اختيار {{count}}", + "undoLastAction": "تراجع", + "undoLastHint": "تراجع عن الأخير: {{name}} {{delta}}", + "undoUnavailable": "لا توجد عملية نقاط للتراجع عنها", + "undoLastSuccess": "تم التراجع عن آخر عملية نقاط", + "multiSelect": "اختيار متعدد", + "batchMode": "وضع الدفعة", + "selectAll": "تحديد الكل", + "clearSelected": "مسح التحديد", + "batchOperate": "نقاط الدفعة", + "selected": "تم التحديد", + "selectedCount": "تم اختيار {{count}}", + "batchSuccess": "تم إرسال النقاط لـ {{count}} طالب", + "batchPartial": "تم إرسال {{success}}/{{total}} طالب بنجاح", + "selectStudentFirst": "الرجاء تحديد طالب أو أكثر أولاً", + "addPoints": "إضافة نقاط", + "deductPoints": "خصم نقاط", + "pointsChange": "تغيير النقاط", + "reasonLabel": "السبب: " + }, + "students": { + "title": "إدارة الطلاب", + "importList": "استيراد القائمة", + "addStudent": "إضافة طالب", + "name": "الاسم", + "group": "المجموعة", + "avatar": "الصورة", + "currentScore": "النقاط الحالية", + "tags": "الوسوم", + "noTags": "بدون وسوم", + "editTags": "تعديل الوسوم", + "editAvatar": "إعداد الصورة", + "deleteConfirm": "تأكيد حذف هذا الطالب؟", + "addTitle": "إضافة طالب", + "addConfirm": "إضافة", + "namePlaceholder": "أدخل اسم الطالب", + "groupPlaceholder": "أدخل اسم المجموعة (اختياري)", + "nameRequired": "الرجاء إدخال الاسم", + "nameExists": "اسم الطالب موجود بالفعل", + "addSuccess": "تمت الإضافة بنجاح", + "addFailed": "فشل الإضافة", + "deleteSuccess": "تم الحذف بنجاح", + "deleteFailed": "فشل الحذف", + "tagSaveSuccess": "تم حفظ الوسوم بنجاح", + "tagSaveFailed": "فشل حفظ الوسوم", + "importTitle": "استيراد القائمة", + "importTextHint": "الصق عدة أسطر، اسم طالب واحد لكل سطر", + "importTextPlaceholder": "مثال:\nمحمد\nفاطمة\nأحمد", + "importByText": "استيراد من النص", + "importByXlsx": "استيراد عبر xlsx", + "importByBanyou": "استيراد من BanYou", + "xlsxPreview": "معاينة واستيراد xlsx", + "file": "الملف", + "selectNameCol": "انقر على الرأس لتحديد عمود الأسماء", + "previewRows": "معاينة أول 50 سطر", + "importConfirm": "استيراد", + "importComplete": "اكتمل الاستيراد: تم إضافة {{inserted}}، تم تخطي {{skipped}}", + "workerNotReady": "العامل غير مهيأ", + "parseXlsxFailed": "فشل تحليل xlsx", + "selectNameColFirst": "الرجاء تحديد « عمود الأسماء » أولاً", + "noNamesFound": "لم يتم العثور على أسماء قابلة للاستيراد في العمود المحدد", + "importFailed": "فشل الاستيراد", + "banyouCookieHint": "بعد تسجيل الدخول إلى BanYou Web، انسخ ملف تعريف الارتباط الكامل والصقه أدناه.", + "banyouCookiePlaceholder": "uid=...; accessToken=...; ...", + "banyouCookieRequired": "الرجاء لصق ملف تعريف الارتباط أولاً", + "banyouFetch": "الحصول على قائمة الفصول", + "banyouFetchSuccess": "تم الحصول على {{count}} فصول", + "banyouFetchFailed": "فشل الحصول على فصول BanYou", + "banyouCreatedClasses": "فصولي التي أنشأتها", + "banyouJoinedClasses": "فصولي التي انضممت إليها", + "banyouNoClasses": "لا توجد بيانات فصول" + }, + "boards": { + "title": "إدارة اللوحات", + "addBoard": "إضافة لوحة", + "editBoard": "تعديل اللوحة", + "deleteBoard": "حذف اللوحة", + "name": "اسم اللوحة", + "description": "الوصف", + "visibility": "الرؤية", + "public": "عام", + "private": "خاص", + "studentsCount": "{{count}} طالب", + "noBoards": "لا توجد لوحات", + "addTitle": "إضافة لوحة", + "editTitle": "تعديل اللوحة", + "namePlaceholder": "أدخل اسم اللوحة", + "descriptionPlaceholder": "أدخل الوصف", + "confirmDelete": "تأكيد حذف هذه اللوحة؟", + "saved": "تم الحفظ", + "saveFailed": "فشل الحفظ", + "deleted": "تم الحذف", + "deleteFailed": "فشل الحذف", + "boardExists": "اللوحة موجودة بالفعل" + }, + "leaderboard": { + "title": "لوحة الصدارة", + "sortBy": "الترتيب حسب", + "score": "النقاط", + "name": "الاسم", + "rank": "الترتيب", + "student": "الطالب", + "points": "النقاط", + "noData": "لا توجد بيانات ترتيب", + "topStudents": "أفضل الطلاب", + "rankChanged": "الترتيب: {{rank}}" + }, + "settlements": { + "title": "سجل التسوية", + "phase": "المرحلة", + "date": "التاريخ", + "studentCount": "عدد الطلاب", + "totalPoints": "إجمالي النقاط", + "actions": "الإجراءات", + "viewDetails": "عرض التفاصيل", + "noSettlements": "لا توجد تسوية", + "phaseDetail": "تفاصيل المرحلة {{phase}}", + "settlementDate": "تاريخ التسوية: {{date}}", + "totalStudents": "إجمالي الطلاب: {{count}}", + "totalPointsChange": "إجمالي تغيير النقاط: {{points}}" + }, + "reasons": { + "title": "إدارة الأسباب", + "addReason": "إضافة سبب", + "editReason": "تعديل السبب", + "name": "اسم السبب", + "points": "النقاط", + "positive": "إيجابي", + "negative": "سلبي", + "noReasons": "لا يوجد أسباب", + "addTitle": "إضافة سبب", + "editTitle": "تعديل السبب", + "namePlaceholder": "أدخل اسم السبب", + "pointsPlaceholder": "أدخل النقاط", + "confirmDelete": "تأكيد حذف هذا السبب؟", + "saved": "تم الحفظ", + "saveFailed": "فشل الحفظ", + "deleted": "تم الحذف", + "deleteFailed": "فشل الحذف", + "reasonExists": "السبب موجود بالفعل" + }, + "autoScore": { + "title": "إضافة تلقائية للنقاط", + "addRule": "إضافة قاعدة", + "editRule": "تعديل القاعدة", + "deleteRule": "حذف القاعدة", + "ruleName": "اسم القاعدة", + "trigger": "المُحفز", + "action": "العملية", + "interval": "الفاصل الزمني", + "enabled": "مفعل", + "noRules": "لا توجد قواعد", + "addTitle": "إضافة قاعدة", + "editTitle": "تعديل القاعدة", + "namePlaceholder": "أدخل اسم القاعدة", + "triggerPlaceholder": "اختر المُحفز", + "actionPlaceholder": "اختر العملية", + "intervalPlaceholder": "اختر الفاصل الزمني", + "confirmDelete": "تأكيد حذف هذه القاعدة؟", + "saved": "تم الحفظ", + "saveFailed": "فشل الحفظ", + "deleted": "تم الحذف", + "deleteFailed": "فشل الحذف", + "ruleExists": "القاعدة موجودة بالفعل" + }, + "rewardExchange": { + "title": "تبادل المكافآت", + "rewards": "المكافآت", + "exchange": "تبادل", + "history": "السجل", + "noRewards": "لا توجد مكافآت", + "noHistory": "لا يوجد سجل", + "rewardName": "اسم المكافأة", + "cost": "التكلفة", + "stock": "المخزون", + "exchangeSuccess": "تم التبادل بنجاح", + "exchangeFailed": "فشل التبادل", + "insufficientPoints": "نقاط غير كافية", + "outOfStock": "نفاد المخزون" + }, + "rewardSettings": { + "title": "إعدادات المكافآت", + "addReward": "إضافة مكافأة", + "editReward": "تعديل المكافأة", + "deleteReward": "حذف المكافأة", + "name": "الاسم", + "cost": "التكلفة", + "stock": "المخزون", + "noRewards": "لا توجد مكافآت", + "addTitle": "إضافة مكافأة", + "editTitle": "تعديل المكافأة", + "namePlaceholder": "أدخل الاسم", + "costPlaceholder": "أدخل التكلفة", + "stockPlaceholder": "أدخل المخزون", + "confirmDelete": "تأكيد حذف هذه المكافأة؟", + "saved": "تم الحفظ", + "saveFailed": "فشل الحفظ", + "deleted": "تم الحذف", + "deleteFailed": "فشل الحذف", + "rewardExists": "المكافأة موجودة بالفعل" + }, + "recovery": { + "title": "نص استرداد SecScore", + "saved": "لقد قمت بالحفظ", + "export": "تصدير ملف نصي", + "exportHint": "يوصى بالحفظ خارج الخط. في حالة الضياع، لا يمكن استعادته.", + "hint": "يوصى بالحفظ خارج الخط بعد التصدير. في حالة الضياع، لا يمكن استعادته." + } +} diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json new file mode 100644 index 0000000..8e56b00 --- /dev/null +++ b/src/i18n/locales/de-DE.json @@ -0,0 +1,668 @@ +{ + "common": { + "next": "Nächste", + "prev": "Vorherige", + "finish": "Fertig", + "cancel": "Abbrechen", + "save": "Speichern", + "loading": "Laden...", + "confirm": "Bestätigen", + "success": "Erfolg", + "error": "Fehler", + "delete": "Löschen", + "edit": "Bearbeiten", + "add": "Hinzufügen", + "search": "Suchen", + "clear": "Löschen", + "close": "Schließen", + "yes": "Ja", + "no": "Nein", + "submit": "Senden", + "reset": "Zurücksetzen", + "import": "Importieren", + "export": "Exportieren", + "create": "Erstellen", + "update": "Aktualisieren", + "refresh": "Aktualisieren", + "view": "Ansehen", + "name": "Name", + "status": "Status", + "operation": "Operation", + "description": "Beschreibung", + "total": "Insgesamt {{count}} Elemente", + "noData": "Keine Daten", + "pleaseSelect": "Bitte auswählen", + "pleaseEnter": "Bitte eingeben", + "readOnly": "Schreibgeschützter Modus", + "none": "Keine", + "more": "Mehr" + }, + "oobe": { + "title": "Willkommen bei SecScore", + "subtitle": "Experte für Bildungs-Punktemanagement", + "step": "Schritt {{current}}/{{total}}", + "skip": "Überspringen", + "steps": { + "entry": { + "title": "Starten", + "description": "Wählen Sie das Verfahren aus", + "enterOobe": "In OOBE eintreten", + "connectPostgresAutoSync": "PostgreSQL mit automatischer Synchronisierung verbinden", + "skipDirect": "OOBE überspringen und direkt eintreten" + }, + "postgresql": { + "title": "PostgreSQL verbinden", + "description": "Geben Sie die PostgreSQL-Verbindungszeichenfolge ein. Das System verbindet und synchronisiert automatisch.", + "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", + "autoSyncAndEnter": "Automatisch verbinden und synchronisieren" + }, + "language": { + "title": "Sprache auswählen", + "description": "Bitte wählen Sie Ihre bevorzugte Sprache" + }, + "theme": { + "title": "Thema einstellen", + "description": "Wählen Sie das Erscheinungsbild der Schnittstelle", + "mode": "Modus", + "lightMode": "Hell", + "darkMode": "Dunkel", + "primaryColor": "Hauptfarbe", + "backgroundGradient": "Hintergrundfarbverlauf" + }, + "password": { + "title": "Passwort einstellen", + "description": "Legen Sie ein Passwort zum Schutz Ihrer Daten fest (optional)", + "adminPassword": "Admin-Passwort", + "adminPasswordHint": "Zugriff auf alle Funktionen, 6 Ziffern", + "pointsPassword": "Punkte-Passwort", + "pointsPasswordHint": "Nur Punkte-Operationen, 6 Ziffern", + "passwordPlaceholder": "6 Ziffern eingeben", + "hint": "Leer lassen zum Überspringen, später in den Einstellungen konfigurieren" + }, + "students": { + "title": "Schülerliste importieren", + "description": "Fügen Sie eine Schülerliste hinzu, um die Punktemanagement zu starten", + "import": "Liste importieren", + "manual": "Manuell hinzufügen", + "importHint": "Unterstützt Excel (.xlsx) oder JSON-Dateien", + "manualHint": "Mehrere Zeilen einfügen, ein Schülername pro Zeile", + "dragDrop": "Dateien hierher ziehen oder klicken zum Auswählen", + "supportedFormats": "Unterstützte Formate: .xlsx, .json", + "studentName": "Schülername", + "addStudent": "Schüler hinzufügen", + "noStudents": "Keine Schüler vorhanden, bitte hinzufügen oder importieren", + "studentCount": "{{count}} Schüler hinzugefügt", + "studentExists": "Schüler existiert bereits", + "importSuccess": "{{count}} Schüler erfolgreich importiert", + "noNewStudents": "Keine neuen Schüler zum Importieren", + "parseFailed": "Dateiparsing fehlgeschlagen", + "unsupportedFormat": "Nicht unterstütztes Dateiformat" + }, + "reasons": { + "title": "Gründe einstellen", + "description": "Fügen Sie häufige Punkteänderungsgründe hinzu", + "addReason": "Grund hinzufügen", + "reasonName": "Grundname", + "points": "Punkte", + "positive": "Punkte hinzufügen", + "negative": "Punkte abziehen", + "noReasons": "Keine Gründe vorhanden, bitte häufige Gründe hinzufügen", + "reasonCount": "{{count}} Gründe hinzugefügt", + "reasonExists": "Grund existiert bereits" + }, + "start": { + "title": "Bereit zur Verwendung", + "description": "Alles ist bereit. Lassen Sie uns SecScore verwenden!", + "features": { + "score": "Verwalten Sie Punkte der Schüler einfach", + "history": "Vollständige Historie der Punkteänderungen", + "settlement": "Unterstützt Abrechnung und Archivierung" + }, + "startButton": "Starten" + } + } + }, + "settings": { + "title": "Systemeinstellungen", + "tabs": { + "appearance": "Erscheinungsbild", + "security": "Sicherheit", + "account": "Konto", + "database": "Datenbankverbindung", + "dataManagement": "Datenverwaltung", + "urlProtocol": "URL-Protokoll", + "about": "Über" + }, + "language": "Sprache", + "languageHint": "Wählen Sie die Sprache für die Schnittstellenanzeige", + "fontFamily": "Globale Schriftart", + "fontFamilyHint": "Wählen Sie die Schnittstellenschriftart (erfordert Neuladen)", + "theme": "Thema", + "password": "Passwort einstellen", + "themeMode": "Modus", + "lightMode": "Hell", + "darkMode": "Dunkel", + "primaryColor": "Hauptfarbe", + "backgroundGradient": "Hintergrundfarbverlauf", + "searchKeyboard": { + "title": "Suchtastatur", + "hint": "Wählen Sie das Tastaturlayout unter dem Suchfeld", + "disableToggle": "Suchtastatur deaktivieren", + "disableHint": "Die Tastatur wird nicht mehr unter dem Suchfeld angezeigt", + "options": { + "qwerty26": "26 Tasten (QWERTY)", + "t9": "9 Tasten (T9)" + } + }, + "interfaceZoom": "Schnittstellenzoom", + "zoomOptions": { + "small70": "Klein (70%)", + "default100": "Standard (100%)", + "large150": "Groß (150%)" + }, + "general": { + "saved": "Gespeichert", + "saveFailed": "Speichern fehlgeschlagen" + }, + "zoomHint": "Gesamtgröße der Schnittstelle anpassen", + "mobile": { + "navigationTitle": "Seiteneingänge", + "bottomNav": { + "label": "Funktionen der unteren Navigationsleiste", + "hint": "Wählen Sie die Funktionen aus, die in der unteren Leiste des Mobiltelefons angezeigt werden sollen. Die Leiste zeigt maximal 4 Elemente an, die anderen gehen in «Mehr»." + }, + "dragHint": "Zum Neuordnen ziehen. Die ersten 4 Elemente erscheinen in der unteren Leiste, die anderen in «Mehr».", + "dropHere": "Hier loslassen", + "bottomSection": "Untere Leiste (max. 4)", + "moreSection": "Mehr", + "moveToMore": "Zu Mehr verschieben", + "moveToBottom": "Nach unten verschieben" + }, + "zoomUpdated": "Schnittstellenzoom aktualisiert", + "updateFailed": "Aktualisierung fehlgeschlagen", + "security": { + "title": "Passwortschutzsystem", + "adminPassword": "Admin-Passwort", + "pointsPassword": "Punkte-Passwort", + "recoveryString": "Wiederherstellungszeichenfolge", + "set": "Eingestellt", + "notSet": "Nicht eingestellt", + "generated": "Erstellt", + "notGenerated": "Nicht erstellt", + "enterPassword": "6 Ziffern eingeben (leer lassen zum Überspringen)", + "adminPasswordPlaceholder": "6 Ziffern Admin-Passwort eingeben (leer lassen zum Überspringen)", + "pointsPasswordPlaceholder": "6 Ziffern Punkte-Passwort eingeben (leer lassen zum Überspringen)", + "recoveryPlaceholder": "Wiederherstellungszeichenfolge eingeben", + "savePassword": "Passwort speichern", + "savePasswords": "Passwörter speichern", + "generateRecovery": "Wiederherstellungszeichenfolge generieren", + "clearAllPasswords": "Alle Passwörter löschen", + "recoveryReset": "Wiederherstellungszeichenfolge zurücksetzen", + "enterRecovery": "Wiederherstellungszeichenfolge eingeben", + "resetPassword": "Passwort zurücksetzen", + "resetHint": "Das Zurücksetzen löscht die Admin- und Punkte-Passwörter und generiert eine neue Wiederherstellungszeichenfolge.", + "confirmClear": "Alle Passwörter löschen bestätigen?", + "clearHint": "Nach dem Löschen wird der Schutz deaktiviert (ohne Passwort = Standard-Admin-Berechtigung).", + "saved": "Passwort aktualisiert", + "saveFailed": "Aktualisierung fehlgeschlagen", + "generateFailed": "Generierung fehlgeschlagen", + "resetFailed": "Zurücksetzen fehlgeschlagen", + "cleared": "Gelöscht", + "clearFailed": "Löschen fehlgeschlagen", + "passwordClearedNewRecovery": "Passwort gelöscht, neue Wiederherstellungszeichenfolge", + "newRecoveryString": "Neue Wiederherstellungszeichenfolge (bitte speichern)" + }, + "account": { + "title": "SECTL Auth-Konto", + "notLoggedIn": "Nicht bei SECTL Auth angemeldet", + "oauthHint": "Melden Sie sich mit Ihrem SECTL Auth-Konto an, um die einheitliche Authentifizierung und Remote-Abmeldung zu nutzen", + "loginButton": "Mit SECTL Auth anmelden", + "loginSuccess": "Anmeldung erfolgreich", + "logout": "Abmelden", + "userId": "Benutzer-ID", + "permission": "Berechtigungsstufe" + }, + "database": { + "title": "Datenbankverbindung", + "currentStatus": "Aktueller Datenbankstatus", + "sqliteLocal": "Lokale SQLite-Datenbank", + "postgresqlRemote": "Remote-PostgreSQL-Datenbank", + "connected": "Verbunden", + "disconnected": "Getrennt", + "postgresqlConnection": "Remote-PostgreSQL-Verbindung", + "connectionHint": "Geben Sie die PostgreSQL-Verbindungszeichenfolge ein, um eine Remote-Datenbank zu verbinden, Unterstützung für Multi-Plattform-Synchronisierung.", + "testConnection": "Verbindung testen", + "switchToPostgreSQL": "Zu PostgreSQL wechseln", + "switchToSQLite": "Zu lokalem SQLite wechseln", + "connectionTestSuccess": "Verbindungstest erfolgreich", + "connectionTestFailed": "Verbindungstest fehlgeschlagen", + "switchedTo": "Zu Datenbank {{type}} gewechselt", + "switchedToSQLite": "Zu lokaler SQLite-Datenbank gewechselt", + "switchFailed": "Wechsel fehlgeschlagen", + "syncDescription": "Anweisungen für Multi-Plattform-Synchronisierung", + "syncPoint1": "Die Verwendung einer Remote-PostgreSQL-Datenbank ermöglicht die Synchronisierung von Daten auf mehreren Plattformen.", + "syncPoint2": "Integrierter Operations-Warteschlangen-Mechanismus zur Gewährleistung der Datenkonsistenz bei gleichzeitigen Operationen.", + "syncPoint3": "Nach dem Wechsel der Datenbank ist ein Neustart der Anwendung erforderlich.", + "syncPoint4": "Empfohlene Cloud-Datenbankdienste: Neon, Supabase, AWS RDS usw.", + "enterConnectionString": "Bitte PostgreSQL-Verbindungszeichenfolge eingeben", + "connectionExample": "Beispiel: postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", + "uploadButton": "Lokale Daten auf Remote-Server hochladen", + "uploadHint": "Nach der Verbindung mit der Remote-Datenbank laden Sie den lokalen SQLite-Verlauf auf den Remote-Server hoch und fusionieren ihn.", + "uploadNeedRemote": "Bitte zuerst zu Remote-PostgreSQL-Datenbank wechseln und verbinden", + "noNeedUpload": "Lokale und Remote-Daten sind bereits konsistent", + "uploadConfirmTitle": "Lokale Daten auf Remote-Server hochladen bestätigen?", + "uploadConfirmContent": "Die Synchronisierung priorisiert lokale Daten. Erkannt: nur lokal {{localOnly}}, nur remote {{remoteOnly}}, Konflikte {{conflicts}}.", + "uploadSuccess": "Lokale Daten auf Remote-Server hochgeladen", + "uploadFailed": "Hochladen fehlgeschlagen" + }, + "data": { + "title": "Datenverwaltung", + "settlement": "Abrechnung", + "settlementAndRestart": "Abrechnen und neu starten", + "settlementHint": "Klassifiziert unabgerechnete Aufzeichnungen in eine Phase und setzt alle Schülerpunkte auf null zurück.", + "importExport": "Importieren / Exportieren", + "exportJson": "JSON exportieren", + "importJson": "JSON importieren", + "importHint": "Der Import überschreibt die vorhandenen Schüler/Gründe/Aufzeichnungen/Einstellungen (Sicherheitseinstellungen ausgenommen).", + "logs": "Protokolle", + "logLevel": "Protokollebene", + "logOperation": "Protokolloperation", + "viewLogs": "Protokolle anzeigen", + "exportLogs": "Protokolle exportieren", + "clearLogs": "Protokolle löschen", + "logsCleared": "Protokolle gelöscht", + "systemLogs": "Systemprotokolle (letzte 200)", + "noLogs": "Keine Protokolle", + "logLevelUpdated": "Protokollebene aktualisiert", + "readLogsFailed": "Protokolle lesen fehlgeschlagen", + "logsExported": "Protokolle exportiert", + "exportFailed": "Export fehlgeschlagen", + "exportSuccess": "Export erfolgreich", + "importSuccess": "Import erfolgreich, aktualisieren", + "importFailed": "Import fehlgeschlagen", + "clearFailed": "Löschen fehlgeschlagen", + "confirmSettlement": "Abrechnung und Neustart bestätigen?", + "settlementConfirm1": "Dies archiviert unabgerechnete Aufzeichnungen in einer Phase und setzt alle Schülerpunkte auf null zurück.", + "settlementConfirm2": "Die Schülerliste bleibt unverändert; die Abrechnungshistorie ist auf der Seite «Abrechnungshistorie» abrufbar.", + "settlementSuccess": "Abrechnung erfolgreich, Punkte neu gestartet", + "settlementFailed": "Abrechnung fehlgeschlagen", + "settle": "Abrechnen", + "logLevels": { + "debug": "DEBUG (Debuggen)", + "info": "INFO (Information)", + "warn": "WARN (Warnung)", + "error": "ERROR (Fehler)" + } + }, + "url": { + "title": "URL-Protokoll (secscore://)", + "protocol": "URL-Protokoll", + "description": "Sie können SecScore über eine URL aufrufen, um Operationen auszuführen, z. B.:", + "register": "URL-Protokoll registrieren", + "registered": "URL-Protokoll registriert", + "registerFailed": "Registrierung fehlgeschlagen", + "installerRequired": "Erfordert die installierte Version von SecScore, kann im Entwicklungsmodus nicht funktionieren." + }, + "mcp": { + "title": "MCP-Dienst", + "description": "Verwalten Sie den integrierten MCP HTTP-Dienst für externe MCP-Clients.", + "running": "Läuft", + "stopped": "Gestoppt", + "noUrl": "Keine URL", + "host": "Host", + "port": "Port", + "start": "Starten", + "stop": "Stoppen", + "refresh": "Status aktualisieren", + "hint": "Empfohlen, nur an 127.0.0.1 zu binden; nach dem Start ist der Endpunkt http://host:port/mcp", + "hostRequired": "Bitte MCP-Host eingeben", + "portInvalid": "Bitte gültigen Port eingeben (1-65535)", + "startSuccess": "MCP-Server gestartet", + "startFailed": "Starten des MCP-Servers fehlgeschlagen", + "stopSuccess": "MCP-Server gestoppt", + "stopFailed": "Stoppen des MCP-Servers fehlgeschlagen", + "startTimeout": "Zeitüberschreitung beim Starten des MCP-Servers, bitte erneut versuchen", + "stopTimeout": "Zeitüberschreitung beim Stoppen des MCP-Servers, bitte erneut versuchen" + }, + "app": { + "title": "Anwendungssteuerung", + "description": "Anwendung schnell neu starten oder schließen.", + "restart": "Anwendung neu starten", + "quit": "Anwendung schließen", + "confirmRestartTitle": "Neustart der Anwendung bestätigen?", + "confirmRestartContent": "Die Anwendung wird sofort geschlossen und neu gestartet.", + "confirmQuitTitle": "Schließen der Anwendung bestätigen?", + "confirmQuitContent": "Die Anwendung wird sofort geschlossen.", + "restartFailed": "Neustart fehlgeschlagen", + "quitFailed": "Schließen fehlgeschlagen" + }, + "about": { + "title": "Über", + "appName": "Bildungs-Punktemanagement", + "version": "Version", + "copyright": "Urheberrechte", + "ipcStatus": "IPC-Status", + "ipcConnected": "Verbunden", + "ipcDisconnected": "Getrennt (Vorladen fehlgeschlagen)", + "environment": "Umgebung", + "toggleDevTools": "Entwicklerwerkzeuge umschalten", + "license": "SecScore unterliegt der GPL3.0-Lizenz", + "copyRightText": "CopyRight © 2025-{{year}} SECTL" + } + }, + "sidebar": { + "home": "Startseite", + "students": "Schülerverwaltung", + "score": "Punkteoperationen", + "boards": "Boards", + "leaderboard": "Bestenliste", + "settlements": "Abrechnungshistorie", + "reasons": "Grundverwaltung", + "autoScore": "Automatische Punktehinzufügung", + "rewardExchange": "Belohnungsaustausch", + "rewardSettings": "Belohnungseinstellungen", + "settings": "Systemeinstellungen", + "remoteDb": "Remote-Datenbank", + "refreshStatus": "Status aktualisieren", + "syncNow": "Jetzt synchronisieren", + "syncSuccess": "Synchronisierung erfolgreich", + "syncFailed": "Synchronisierung fehlgeschlagen", + "getDbStatusFailed": "Datenbankstatus abrufen fehlgeschlagen", + "notRemoteMode": "Remote-Datenbankmodus nicht aktiviert, Anwendung neu starten", + "dbNotConnected": "Datenbank nicht verbunden" + }, + "auth": { + "unlock": "Berechtigung entsperren", + "unlockHint": "6-stelliges Passwort eingeben: Admin-Passwort = voller Zugriff, Punkte-Passwort = nur Punkteoperationen.", + "unlockButton": "Entsperren", + "unlocked": "Berechtigung entsperrt", + "logout": "Auf Nur-Lese-Modus umgeschaltet", + "passwordPlaceholder": "z. B. 123456", + "passwordError": "Falsches Passwort", + "enterPassword": "Passwort eingeben", + "lock": "Sperren" + }, + "languages": { + "zh-CN": "简体中文", + "en-US": "English", + "ja-JP": "日本語", + "fr-FR": "Français", + "es-ES": "Español", + "ko-KR": "한국어", + "ru-RU": "Русский", + "de-DE": "Deutsch", + "pt-BR": "Português", + "ar-SA": "العربية" + }, + "theme": { + "colorAndBackground": "Farbe und Hintergrund", + "gradientLabels": { + "blue": "Frisches Blau", + "pink": "Weicher Rosa", + "cyan": "Cyan", + "purple": "Violett" + }, + "gradientDirections": { + "vertical": "Vertikal", + "horizontal": "Horizontal", + "diagonal": "Diagonal" + }, + "generate": "Generieren", + "saved": "Gespeichert", + "saveFailed": "Speichern fehlgeschlagen", + "mode": "Modus", + "myTheme": "Mein Thema" + }, + "permissions": { + "admin": "Admin-Berechtigung", + "points": "Punkte-Berechtigung", + "view": "Nur-Lese" + }, + "home": { + "title": "Startseite der Schülerpunkte", + "subtitle": "Insgesamt {{count}} Schüler, klicken Sie auf die Karte, um Operationen durchzuführen", + "searchPlaceholder": "Name/Pinyin suchen...", + "sortBy": { + "alphabet": "Sortieren nach Name", + "surname": "Gruppe nach Nachname", + "group": "Sortieren nach Gruppe", + "score": "Rangfolge nach Punkten" + }, + "layoutBy": { + "grouped": "Gruppierte Liste", + "squareGrid": "Quadratisches Raster" + }, + "noStudents": "Keine Schülerdaten vorhanden, bitte in der Schülerverwaltung hinzufügen", + "noMatch": "Keine übereinstimmenden Schüler gefunden", + "clearSearch": "Suche löschen", + "points": "Punkte", + "currentScore": "Aktuelle Punkte", + "quickOptions": "Schnelloptionen", + "adjustPoints": "Punkte anpassen", + "customPoints": "Benutzerdefinierte Punkte", + "customPointsHint": "Beliebigen Wert im Eingabefeld eingeben", + "reason": "Grund", + "reasonPlaceholder": "Grund für die Punkteänderung eingeben (optional)", + "preview": "Änderungsvorschau", + "noReason": "(Kein Grund)", + "ungrouped": "Nicht gruppiert", + "category": { + "others": "Andere" + }, + "scoreAdded": "{{points}} Punkte zu {{name}} hinzugefügt", + "scoreDeducted": "{{points}} Punkte von {{name}} abgezogen", + "submitFailed": "Senden fehlgeschlagen", + "pleaseSelectPoints": "Bitte Punkte auswählen oder eingeben", + "reasonDefault": "{{action}} {{points}} Punkte", + "studentCount": "{{count}} Schüler", + "operationTitle": "Punkteoperation: {{name}}", + "submitOperation": "Operation senden", + "operationTitleBatch": "Punkteoperation: {{count}} ausgewählt", + "undoLastAction": "Rückgängig", + "undoLastHint": "Letzte Aktion rückgängig: {{name}} {{delta}}", + "undoUnavailable": "Keine Punkteoperation zum Rückgängig machen", + "undoLastSuccess": "Letzte Punkteoperation rückgängig gemacht", + "multiSelect": "Mehrfachauswahl", + "batchMode": "Batch-Modus", + "selectAll": "Alle auswählen", + "clearSelected": "Auswahl löschen", + "batchOperate": "Batch-Punkte", + "selected": "Ausgewählt", + "selectedCount": "{{count}} ausgewählt", + "batchSuccess": "Punkte für {{count}} Schüler gesendet", + "batchPartial": "{{success}}/{{total}} Schüler erfolgreich gesendet", + "selectStudentFirst": "Bitte wählen Sie zuerst einen oder mehrere Schüler aus", + "addPoints": "Punkte hinzufügen", + "deductPoints": "Punkte abziehen", + "pointsChange": "Punkteänderung", + "reasonLabel": "Grund: " + }, + "students": { + "title": "Schülerverwaltung", + "importList": "Liste importieren", + "addStudent": "Schüler hinzufügen", + "name": "Name", + "group": "Gruppe", + "avatar": "Avatar", + "currentScore": "Aktuelle Punkte", + "tags": "Tags", + "noTags": "Keine Tags", + "editTags": "Tags bearbeiten", + "editAvatar": "Avatar einstellen", + "deleteConfirm": "Diesen Schüler löschen bestätigen?", + "addTitle": "Schüler hinzufügen", + "addConfirm": "Hinzufügen", + "namePlaceholder": "Schülernamen eingeben", + "groupPlaceholder": "Gruppennamen eingeben (optional)", + "nameRequired": "Bitte Namen eingeben", + "nameExists": "Schülernamen existiert bereits", + "addSuccess": "Hinzugefügt erfolgreich", + "addFailed": "Hinzufügen fehlgeschlagen", + "deleteSuccess": "Gelöscht erfolgreich", + "deleteFailed": "Löschen fehlgeschlagen", + "tagSaveSuccess": "Tags gespeichert erfolgreich", + "tagSaveFailed": "Speichern von Tags fehlgeschlagen", + "importTitle": "Liste importieren", + "importTextHint": "Mehrere Zeilen einfügen, ein Schülername pro Zeile", + "importTextPlaceholder": "Beispiel:\nHans\nMaria\nPeter", + "importByText": "Aus Text importieren", + "importByXlsx": "Via xlsx importieren", + "importByBanyou": "Aus BanYou importieren", + "xlsxPreview": "xlsx-Vorschau und Import", + "file": "Datei", + "selectNameCol": "Klicken Sie auf die Kopfzeile, um die Namensspalte auszuwählen", + "previewRows": "Vorschau der ersten 50 Zeilen", + "importConfirm": "Importieren", + "importComplete": "Import abgeschlossen: {{inserted}} hinzugefügt, {{skipped}} übersprungen", + "workerNotReady": "Worker nicht initialisiert", + "parseXlsxFailed": "xlsx-Parsing fehlgeschlagen", + "selectNameColFirst": "Bitte wählen Sie zuerst die «Namensspalte» aus", + "noNamesFound": "Keine importierbaren Namen in der ausgewählten Spalte gefunden", + "importFailed": "Import fehlgeschlagen", + "banyouCookieHint": "Nach dem Anmelden bei BanYou Web kopieren Sie das vollständige Cookie und fügen es unten ein.", + "banyouCookiePlaceholder": "uid=...; accessToken=...; ...", + "banyouCookieRequired": "Bitte zuerst Cookie einfügen", + "banyouFetch": "Klasseliste abrufen", + "banyouFetchSuccess": "{{count}} Klassen abgerufen", + "banyouFetchFailed": "Abrufen von BanYou-Klassen fehlgeschlagen", + "banyouCreatedClasses": "Meine erstellten Klassen", + "banyouJoinedClasses": "Meine beigetretenen Klassen", + "banyouNoClasses": "Keine Klassendaten vorhanden" + }, + "boards": { + "title": "Boardverwaltung", + "addBoard": "Board hinzufügen", + "editBoard": "Board bearbeiten", + "deleteBoard": "Board löschen", + "name": "Boardname", + "description": "Beschreibung", + "visibility": "Sichtbarkeit", + "public": "Öffentlich", + "private": "Privat", + "studentsCount": "{{count}} Schüler", + "noBoards": "Keine Boards vorhanden", + "addTitle": "Board hinzufügen", + "editTitle": "Board bearbeiten", + "namePlaceholder": "Boardname eingeben", + "descriptionPlaceholder": "Beschreibung eingeben", + "confirmDelete": "Dieses Board löschen bestätigen?", + "saved": "Gespeichert", + "saveFailed": "Speichern fehlgeschlagen", + "deleted": "Gelöscht", + "deleteFailed": "Löschen fehlgeschlagen", + "boardExists": "Board existiert bereits" + }, + "leaderboard": { + "title": "Bestenliste", + "sortBy": "Sortieren nach", + "score": "Punkte", + "name": "Name", + "rank": "Rang", + "student": "Schüler", + "points": "Punkte", + "noData": "Keine Ranglisten-Daten vorhanden", + "topStudents": "Beste Schüler", + "rankChanged": "Rang: {{rank}}" + }, + "settlements": { + "title": "Abrechnungshistorie", + "phase": "Phase", + "date": "Datum", + "studentCount": "Anzahl der Schüler", + "totalPoints": "Gesamtpunkte", + "actions": "Aktionen", + "viewDetails": "Details anzeigen", + "noSettlements": "Keine Abrechnungen vorhanden", + "phaseDetail": "Details der Phase {{phase}}", + "settlementDate": "Abrechnungsdatum: {{date}}", + "totalStudents": "Gesamtzahl der Schüler: {{count}}", + "totalPointsChange": "Gesamte Punkteänderung: {{points}}" + }, + "reasons": { + "title": "Grundverwaltung", + "addReason": "Grund hinzufügen", + "editReason": "Grund bearbeiten", + "name": "Grundname", + "points": "Punkte", + "positive": "Positiv", + "negative": "Negativ", + "noReasons": "Keine Gründe vorhanden", + "addTitle": "Grund hinzufügen", + "editTitle": "Grund bearbeiten", + "namePlaceholder": "Grundname eingeben", + "pointsPlaceholder": "Punkte eingeben", + "confirmDelete": "Diesen Grund löschen bestätigen?", + "saved": "Gespeichert", + "saveFailed": "Speichern fehlgeschlagen", + "deleted": "Gelöscht", + "deleteFailed": "Löschen fehlgeschlagen", + "reasonExists": "Grund existiert bereits" + }, + "autoScore": { + "title": "Automatische Punktehinzufügung", + "addRule": "Regel hinzufügen", + "editRule": "Regel bearbeiten", + "deleteRule": "Regel löschen", + "ruleName": "Regelname", + "trigger": "Auslöser", + "action": "Aktion", + "interval": "Intervall", + "enabled": "Aktiviert", + "noRules": "Keine Regeln vorhanden", + "addTitle": "Regel hinzufügen", + "editTitle": "Regel bearbeiten", + "namePlaceholder": "Regelname eingeben", + "triggerPlaceholder": "Auslöser auswählen", + "actionPlaceholder": "Aktion auswählen", + "intervalPlaceholder": "Intervall auswählen", + "confirmDelete": "Diese Regel löschen bestätigen?", + "saved": "Gespeichert", + "saveFailed": "Speichern fehlgeschlagen", + "deleted": "Gelöscht", + "deleteFailed": "Löschen fehlgeschlagen", + "ruleExists": "Regel existiert bereits" + }, + "rewardExchange": { + "title": "Belohnungsaustausch", + "rewards": "Belohnungen", + "exchange": "Austauschen", + "history": "Historie", + "noRewards": "Keine Belohnungen vorhanden", + "noHistory": "Keine Historie vorhanden", + "rewardName": "Belohnungsname", + "cost": "Kosten", + "stock": "Bestand", + "exchangeSuccess": "Austausch erfolgreich", + "exchangeFailed": "Austausch fehlgeschlagen", + "insufficientPoints": "Unzureichende Punkte", + "outOfStock": "Nicht auf Lager" + }, + "rewardSettings": { + "title": "Belohnungseinstellungen", + "addReward": "Belohnung hinzufügen", + "editReward": "Belohnung bearbeiten", + "deleteReward": "Belohnung löschen", + "name": "Name", + "cost": "Kosten", + "stock": "Bestand", + "noRewards": "Keine Belohnungen vorhanden", + "addTitle": "Belohnung hinzufügen", + "editTitle": "Belohnung bearbeiten", + "namePlaceholder": "Name eingeben", + "costPlaceholder": "Kosten eingeben", + "stockPlaceholder": "Bestand eingeben", + "confirmDelete": "Diese Belohnung löschen bestätigen?", + "saved": "Gespeichert", + "saveFailed": "Speichern fehlgeschlagen", + "deleted": "Gelöscht", + "deleteFailed": "Löschen fehlgeschlagen", + "rewardExists": "Belohnung existiert bereits" + }, + "recovery": { + "title": "SecScore-Wiederherstellungszeichenfolge", + "saved": "Ich habe gespeichert", + "export": "Textdatei exportieren", + "exportHint": "Es wird empfohlen, offline zu speichern. Bei Verlust kann es nicht wiederhergestellt werden.", + "hint": "Es wird empfohlen, nach dem Export offline zu speichern. Bei Verlust kann es nicht wiederhergestellt werden." + } +} diff --git a/src/i18n/locales/es-ES.json b/src/i18n/locales/es-ES.json new file mode 100644 index 0000000..950942d --- /dev/null +++ b/src/i18n/locales/es-ES.json @@ -0,0 +1,668 @@ +{ + "common": { + "next": "Siguiente", + "prev": "Anterior", + "finish": "Finalizar", + "cancel": "Cancelar", + "save": "Guardar", + "loading": "Cargando...", + "confirm": "Confirmar", + "success": "Éxito", + "error": "Error", + "delete": "Eliminar", + "edit": "Editar", + "add": "Añadir", + "search": "Buscar", + "clear": "Borrar", + "close": "Cerrar", + "yes": "Sí", + "no": "No", + "submit": "Enviar", + "reset": "Reiniciar", + "import": "Importar", + "export": "Exportar", + "create": "Crear", + "update": "Actualizar", + "refresh": "Actualizar", + "view": "Ver", + "name": "Nombre", + "status": "Estado", + "operation": "Operación", + "description": "Descripción", + "total": "Total {{count}} elementos", + "noData": "Sin datos", + "pleaseSelect": "Por favor seleccione", + "pleaseEnter": "Por favor ingrese", + "readOnly": "Modo de solo lectura", + "none": "Ninguno", + "more": "Más" + }, + "oobe": { + "title": "Bienvenido a SecScore", + "subtitle": "Experto en gestión de puntos educativos", + "step": "Paso {{current}}/{{total}}", + "skip": "Saltar", + "steps": { + "entry": { + "title": "Comenzar", + "description": "Elija el procedimiento a seguir", + "enterOobe": "Entrar en el OOBE", + "connectPostgresAutoSync": "Conectar PostgreSQL con sincronización automática", + "skipDirect": "Saltar OOBE y entrar directamente" + }, + "postgresql": { + "title": "Conectar PostgreSQL", + "description": "Ingrese la cadena de conexión PostgreSQL. El sistema se conectará y sincronizará automáticamente.", + "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", + "autoSyncAndEnter": "Conectar y sincronizar automáticamente" + }, + "language": { + "title": "Elegir idioma", + "description": "Por favor elija su idioma preferido" + }, + "theme": { + "title": "Configurar tema", + "description": "Elija la apariencia de la interfaz", + "mode": "Modo", + "lightMode": "Claro", + "darkMode": "Oscuro", + "primaryColor": "Color principal", + "backgroundGradient": "Degradado de fondo" + }, + "password": { + "title": "Configurar contraseña", + "description": "Configure una contraseña para proteger sus datos (opcional)", + "adminPassword": "Contraseña de administrador", + "adminPasswordHint": "Acceso a todas las funciones, 6 dígitos", + "pointsPassword": "Contraseña de puntos", + "pointsPasswordHint": "Operaciones de puntos únicamente, 6 dígitos", + "passwordPlaceholder": "Ingrese 6 dígitos", + "hint": "Dejar vacío para omitir, configurar más tarde en configuraciones" + }, + "students": { + "title": "Importar lista de estudiantes", + "description": "Agregue una lista de estudiantes para comenzar la gestión de puntos", + "import": "Importar lista", + "manual": "Añadir manualmente", + "importHint": "Soporta archivos Excel (.xlsx) o JSON", + "manualHint": "Pegue varias líneas, un nombre de estudiante por línea", + "dragDrop": "Arrastre y suelte archivos aquí o haga clic para seleccionar", + "supportedFormats": "Formatos soportados: .xlsx, .json", + "studentName": "Nombre del estudiante", + "addStudent": "Añadir estudiante", + "noStudents": "Sin estudiantes por el momento, por favor añada o importe", + "studentCount": "{{count}} estudiantes añadidos", + "studentExists": "El estudiante ya existe", + "importSuccess": "{{count}} estudiantes importados con éxito", + "noNewStudents": "Sin nuevos estudiantes para importar", + "parseFailed": "Fallo al analizar el archivo", + "unsupportedFormat": "Formato de archivo no soportado" + }, + "reasons": { + "title": "Configurar razones", + "description": "Agregue las razones comunes de modificación de puntos", + "addReason": "Añadir razón", + "reasonName": "Nombre de la razón", + "points": "Puntos", + "positive": "Añadir puntos", + "negative": "Retirar puntos", + "noReasons": "Sin razones por el momento, por favor agregue razones comunes", + "reasonCount": "{{count}} razones añadidas", + "reasonExists": "La razón ya existe" + }, + "start": { + "title": "Listo para usar", + "description": "Todo está listo. ¡Comencemos a usar SecScore!", + "features": { + "score": "Gestione fácilmente los puntos de los estudiantes", + "history": "Historial completo de modificaciones de puntos", + "settlement": "Soporta liquidación y archivado" + }, + "startButton": "Comenzar" + } + } + }, + "settings": { + "title": "Configuraciones del sistema", + "tabs": { + "appearance": "Apariencia", + "security": "Seguridad", + "account": "Cuenta", + "database": "Conexión a base de datos", + "dataManagement": "Gestión de datos", + "urlProtocol": "Protocolo URL", + "about": "Acerca de" + }, + "language": "Idioma", + "languageHint": "Elegir el idioma de visualización de la interfaz", + "fontFamily": "Fuente global", + "fontFamilyHint": "Elegir la fuente de la interfaz (requiere actualización)", + "theme": "Tema", + "password": "Configurar contraseña", + "themeMode": "Modo", + "lightMode": "Claro", + "darkMode": "Oscuro", + "primaryColor": "Color principal", + "backgroundGradient": "Degradado de fondo", + "searchKeyboard": { + "title": "Teclado de búsqueda", + "hint": "Elegir la disposición del teclado bajo el campo de búsqueda", + "disableToggle": "Desactivar teclado de búsqueda", + "disableHint": "El teclado ya no se mostrará bajo el campo de búsqueda", + "options": { + "qwerty26": "26 teclas (QWERTY)", + "t9": "9 teclas (T9)" + } + }, + "interfaceZoom": "Zoom de la interfaz", + "zoomOptions": { + "small70": "Pequeño (70%)", + "default100": "Predeterminado (100%)", + "large150": "Grande (150%)" + }, + "general": { + "saved": "Guardado", + "saveFailed": "Fallo al guardar" + }, + "zoomHint": "Ajustar el tamaño global de la interfaz", + "mobile": { + "navigationTitle": "Entradas de página", + "bottomNav": { + "label": "Funciones de navegación inferior", + "hint": "Elegir las funciones a mostrar en la barra inferior del móvil. La barra muestra un máximo de 4 elementos, los demás van en «Más»." + }, + "dragHint": "Arrastre para reorganizar. Los primeros 4 elementos aparecen en la barra inferior, los demás en «Más».", + "dropHere": "Suelte aquí", + "bottomSection": "Barra inferior (máx. 4)", + "moreSection": "Más", + "moveToMore": "Mover a Más", + "moveToBottom": "Mover abajo" + }, + "zoomUpdated": "Zoom de la interfaz actualizado", + "updateFailed": "Fallo al actualizar", + "security": { + "title": "Sistema de protección por contraseña", + "adminPassword": "Contraseña de administrador", + "pointsPassword": "Contraseña de puntos", + "recoveryString": "Frase de recuperación", + "set": "Configurado", + "notSet": "No configurado", + "generated": "Generado", + "notGenerated": "No generado", + "enterPassword": "Ingrese 6 dígitos (dejar vacío para no modificar)", + "adminPasswordPlaceholder": "Ingrese 6 dígitos para la contraseña de administrador (dejar vacío para no modificar)", + "pointsPasswordPlaceholder": "Ingrese 6 dígitos para la contraseña de puntos (dejar vacío para no modificar)", + "recoveryPlaceholder": "Ingrese la frase de recuperación", + "savePassword": "Guardar contraseña", + "savePasswords": "Guardar contraseñas", + "generateRecovery": "Generar frase de recuperación", + "clearAllPasswords": "Borrar todas las contraseñas", + "recoveryReset": "Reiniciar frase de recuperación", + "enterRecovery": "Ingrese la frase de recuperación", + "resetPassword": "Reiniciar contraseña", + "resetHint": "El reinicio borrará las contraseñas de administrador y puntos, y generará una nueva frase de recuperación.", + "confirmClear": "¿Confirmar el borrado de todas las contraseñas?", + "clearHint": "Después del borrado, la protección se desactivará (sin contraseña = permiso de administrador por defecto).", + "saved": "Contraseña actualizada", + "saveFailed": "Fallo al actualizar", + "generateFailed": "Fallo al generar", + "resetFailed": "Fallo al reiniciar", + "cleared": "Borrado", + "clearFailed": "Fallo al borrar", + "passwordClearedNewRecovery": "Contraseña borrada, nueva frase de recuperación", + "newRecoveryString": "Nueva frase de recuperación (por favor guárdela)" + }, + "account": { + "title": "Cuenta SECTL Auth", + "notLoggedIn": "No conectado a SECTL Auth", + "oauthHint": "Conéctese con su cuenta SECTL Auth para disfrutar de la autenticación unificada y la desconexión remota", + "loginButton": "Conectar con SECTL Auth", + "loginSuccess": "Conexión exitosa", + "logout": "Desconectar", + "userId": "ID de usuario", + "permission": "Nivel de permiso" + }, + "database": { + "title": "Conexión a base de datos", + "currentStatus": "Estado actual de la base de datos", + "sqliteLocal": "Base de datos SQLite local", + "postgresqlRemote": "Base de datos PostgreSQL remota", + "connected": "Conectado", + "disconnected": "Desconectado", + "postgresqlConnection": "Conexión PostgreSQL remota", + "connectionHint": "Ingrese la cadena de conexión PostgreSQL para conectarse a una base de datos remota, soporta sincronización multiplataforma.", + "testConnection": "Probar conexión", + "switchToPostgreSQL": "Cambiar a PostgreSQL", + "switchToSQLite": "Cambiar a SQLite local", + "connectionTestSuccess": "Prueba de conexión exitosa", + "connectionTestFailed": "Fallo en la prueba de conexión", + "switchedTo": "Cambiado a la base de datos {{type}}", + "switchedToSQLite": "Cambiado a la base de datos SQLite local", + "switchFailed": "Fallo al cambiar", + "syncDescription": "Instrucciones de sincronización multiplataforma", + "syncPoint1": "El uso de una base de datos PostgreSQL remota permite la sincronización de datos multiplataforma.", + "syncPoint2": "Mecanismo de cola de operaciones integrado para garantizar la coherencia de los datos durante las operaciones simultáneas.", + "syncPoint3": "Es necesario reiniciar la aplicación después del cambio de base de datos.", + "syncPoint4": "Servicios de base de datos en la nube recomendados: Neon, Supabase, AWS RDS, etc.", + "enterConnectionString": "Por favor ingrese la cadena de conexión PostgreSQL", + "connectionExample": "Ejemplo: postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", + "uploadButton": "Cargar datos locales al servidor remoto", + "uploadHint": "Después de conectarse a la base de datos remota, cargue y fusione el historial SQLite local al servidor remoto.", + "uploadNeedRemote": "Por favor primero cambie y conecte a la base de datos PostgreSQL remota", + "noNeedUpload": "Los datos locales y remotos ya son coherentes", + "uploadConfirmTitle": "¿Confirmar la carga de datos locales al servidor remoto?", + "uploadConfirmContent": "La sincronización priorizará los datos locales. Detectado: local-solo {{localOnly}}, remoto-solo {{remoteOnly}}, conflictos {{conflicts}}.", + "uploadSuccess": "Datos locales cargados al servidor remoto", + "uploadFailed": "Fallo al cargar" + }, + "data": { + "title": "Gestión de datos", + "settlement": "Liquidación", + "settlementAndRestart": "Liquidar y reiniciar", + "settlementHint": "Clasifica los registros no liquidados en una fase y restablece todos los puntos de los estudiantes a cero.", + "importExport": "Importar / Exportar", + "exportJson": "Exportar JSON", + "importJson": "Importar JSON", + "importHint": "La importación sobrescribirá los estudiantes/razones/registros/ajustes existentes (los ajustes de seguridad están excluidos).", + "logs": "Registros", + "logLevel": "Nivel de registro", + "logOperation": "Operación en registros", + "viewLogs": "Ver registros", + "exportLogs": "Exportar registros", + "clearLogs": "Borrar registros", + "logsCleared": "Registros borrados", + "systemLogs": "Registros del sistema (últimos 200)", + "noLogs": "Sin registros", + "logLevelUpdated": "Nivel de registro actualizado", + "readLogsFailed": "Fallo al leer los registros", + "logsExported": "Registros exportados", + "exportFailed": "Fallo al exportar", + "exportSuccess": "Exportación exitosa", + "importSuccess": "Importación exitosa, actualizando", + "importFailed": "Fallo al importar", + "clearFailed": "Fallo al borrar", + "confirmSettlement": "¿Confirmar la liquidación y reinicio?", + "settlementConfirm1": "Esto archivará los registros no liquidados en una fase y restablecerá todos los puntos de los estudiantes a cero.", + "settlementConfirm2": "La lista de estudiantes permanece sin cambios; el historial de liquidación es consultable en la página «Historial de liquidaciones».", + "settlementSuccess": "Liquidación exitosa, puntos reiniciados", + "settlementFailed": "Fallo en la liquidación", + "settle": "Liquidar", + "logLevels": { + "debug": "DEBUG (depuración)", + "info": "INFO (información)", + "warn": "WARN (advertencia)", + "error": "ERROR (error)" + } + }, + "url": { + "title": "Protocolo URL (secscore://)", + "protocol": "Protocolo URL", + "description": "Puede invocar SecScore a través de una URL para realizar operaciones, por ejemplo:", + "register": "Registrar protocolo URL", + "registered": "Protocolo URL registrado", + "registerFailed": "Fallo al registrar", + "installerRequired": "Requiere la versión instalada de SecScore, puede no funcionar en modo desarrollo." + }, + "mcp": { + "title": "Servicio MCP", + "description": "Gestionar el servicio HTTP MCP integrado para clientes MCP externos.", + "running": "En ejecución", + "stopped": "Detenido", + "noUrl": "Sin URL", + "host": "Host", + "port": "Puerto", + "start": "Iniciar", + "stop": "Detener", + "refresh": "Actualizar estado", + "hint": "Recomendado para vincular solo a 127.0.0.1; después del inicio, el punto final es http://host:port/mcp", + "hostRequired": "Por favor ingrese el host MCP", + "portInvalid": "Por favor ingrese un puerto válido (1-65535)", + "startSuccess": "Servidor MCP iniciado con éxito", + "startFailed": "Fallo al iniciar el servidor MCP", + "stopSuccess": "Servidor MCP detenido", + "stopFailed": "Fallo al detener el servidor MCP", + "startTimeout": "Tiempo de espera agotado al iniciar el servidor MCP, por favor inténtelo de nuevo", + "stopTimeout": "Tiempo de espera agotado al detener el servidor MCP, por favor inténtelo de nuevo" + }, + "app": { + "title": "Controles de la aplicación", + "description": "Reiniciar o cerrar rápidamente la aplicación.", + "restart": "Reiniciar aplicación", + "quit": "Cerrar aplicación", + "confirmRestartTitle": "¿Confirmar el reinicio de la aplicación?", + "confirmRestartContent": "La aplicación se cerrará y reiniciará inmediatamente.", + "confirmQuitTitle": "¿Confirmar el cierre de la aplicación?", + "confirmQuitContent": "La aplicación se cerrará inmediatamente.", + "restartFailed": "Fallo al reiniciar", + "quitFailed": "Fallo al cerrar" + }, + "about": { + "title": "Acerca de", + "appName": "Gestión de puntos educativos", + "version": "Versión", + "copyright": "Copyright", + "ipcStatus": "Estado IPC", + "ipcConnected": "Conectado", + "ipcDisconnected": "Desconectado (Preload fallido)", + "environment": "Entorno", + "toggleDevTools": "Activar/desactivar herramientas de desarrollo", + "license": "SecScore está bajo licencia GPL3.0", + "copyRightText": "CopyRight © 2025-{{year}} SECTL" + } + }, + "sidebar": { + "home": "Inicio", + "students": "Gestión de estudiantes", + "score": "Operaciones de puntos", + "boards": "Tableros", + "leaderboard": "Clasificación", + "settlements": "Historial de liquidaciones", + "reasons": "Gestión de razones", + "autoScore": "Añadir puntos automáticamente", + "rewardExchange": "Intercambio de recompensas", + "rewardSettings": "Ajustes de recompensa", + "settings": "Configuraciones del sistema", + "remoteDb": "Base de datos remota", + "refreshStatus": "Actualizar estado", + "syncNow": "Sincronizar ahora", + "syncSuccess": "Sincronización exitosa", + "syncFailed": "Fallo en la sincronización", + "getDbStatusFailed": "Fallo al obtener el estado de la base de datos", + "notRemoteMode": "Modo base de datos remota no activado, por favor reinicie la aplicación", + "dbNotConnected": "Base de datos no conectada" + }, + "auth": { + "unlock": "Desbloquear permiso", + "unlockHint": "Ingrese la contraseña de 6 dígitos: contraseña de administrador = acceso completo, contraseña de puntos = operaciones de puntos únicamente.", + "unlockButton": "Desbloquear", + "unlocked": "Permiso desbloqueado", + "logout": "Pasado a solo lectura", + "passwordPlaceholder": "ej. 123456", + "passwordError": "Contraseña incorrecta", + "enterPassword": "Ingresar contraseña", + "lock": "Bloquear" + }, + "languages": { + "zh-CN": "简体中文", + "en-US": "English", + "ja-JP": "日本語", + "fr-FR": "Français", + "es-ES": "Español", + "ko-KR": "한국어", + "ru-RU": "Русский", + "de-DE": "Deutsch", + "pt-BR": "Português", + "ar-SA": "العربية" + }, + "theme": { + "colorAndBackground": "Color y fondo", + "gradientLabels": { + "blue": "Azul fresco", + "pink": "Rosa suave", + "cyan": "Cian", + "purple": "Violeta" + }, + "gradientDirections": { + "vertical": "Vertical", + "horizontal": "Horizontal", + "diagonal": "Diagonal" + }, + "generate": "Generar", + "saved": "Guardado", + "saveFailed": "Fallo al guardar", + "mode": "Modo", + "myTheme": "Mi tema" + }, + "permissions": { + "admin": "Permiso de administrador", + "points": "Permiso de puntos", + "view": "Solo lectura" + }, + "home": { + "title": "Página de inicio de puntos de estudiantes", + "subtitle": "{{count}} estudiantes en total, haga clic en la tarjeta para realizar operaciones", + "searchPlaceholder": "Buscar nombre/pinyin...", + "sortBy": { + "alphabet": "Ordenar por nombre", + "surname": "Agrupar por apellido", + "group": "Ordenar por grupo", + "score": "Clasificación por puntos" + }, + "layoutBy": { + "grouped": "Lista agrupada", + "squareGrid": "Cuadrícula cuadrada" + }, + "noStudents": "Sin datos de estudiantes, por favor añada en gestión de estudiantes", + "noMatch": "Sin estudiantes coincidentes encontrados", + "clearSearch": "Borrar búsqueda", + "points": "pts", + "currentScore": "Puntos actuales", + "quickOptions": "Opciones rápidas", + "adjustPoints": "Ajustar puntos", + "customPoints": "Puntos personalizados", + "customPointsHint": "Ingrese cualquier valor en el campo de entrada", + "reason": "Razón", + "reasonPlaceholder": "Ingrese la razón de la modificación de puntos (opcional)", + "preview": "Vista previa de la modificación", + "noReason": "(Sin razón)", + "ungrouped": "No agrupado", + "category": { + "others": "Otros" + }, + "scoreAdded": "{{points}} puntos añadidos a {{name}}", + "scoreDeducted": "{{points}} puntos retirados a {{name}}", + "submitFailed": "Fallo al enviar", + "pleaseSelectPoints": "Por favor seleccione o ingrese puntos", + "reasonDefault": "{{action}} {{points}} puntos", + "studentCount": "{{count}} estudiantes", + "operationTitle": "Operación de puntos: {{name}}", + "submitOperation": "Enviar operación", + "operationTitleBatch": "Operación de puntos: {{count}} seleccionados", + "undoLastAction": "Deshacer", + "undoLastHint": "Deshacer último: {{name}} {{delta}}", + "undoUnavailable": "Sin operación de puntos para deshacer", + "undoLastSuccess": "Última operación de puntos deshecha", + "multiSelect": "Selección múltiple", + "batchMode": "Modo por lotes", + "selectAll": "Seleccionar todo", + "clearSelected": "Borrar selección", + "batchOperate": "Puntos por lotes", + "selected": "Seleccionado", + "selectedCount": "{{count}} seleccionados", + "batchSuccess": "Puntos enviados para {{count}} estudiantes", + "batchPartial": "{{success}}/{{total}} estudiantes enviados con éxito", + "selectStudentFirst": "Por favor primero seleccione uno o más estudiantes", + "addPoints": "Añadir puntos", + "deductPoints": "Retirar puntos", + "pointsChange": "Modificación de puntos", + "reasonLabel": "Razón: " + }, + "students": { + "title": "Gestión de estudiantes", + "importList": "Importar lista", + "addStudent": "Añadir estudiante", + "name": "Nombre", + "group": "Grupo", + "avatar": "Avatar", + "currentScore": "Puntos actuales", + "tags": "Etiquetas", + "noTags": "Sin etiquetas", + "editTags": "Editar etiquetas", + "editAvatar": "Configurar avatar", + "deleteConfirm": "¿Confirmar la eliminación de este estudiante?", + "addTitle": "Añadir estudiante", + "addConfirm": "Añadir", + "namePlaceholder": "Ingrese el nombre del estudiante", + "groupPlaceholder": "Ingrese el nombre del grupo (opcional)", + "nameRequired": "Por favor ingrese el nombre", + "nameExists": "El nombre del estudiante ya existe", + "addSuccess": "Añadido con éxito", + "addFailed": "Fallo al añadir", + "deleteSuccess": "Eliminado con éxito", + "deleteFailed": "Fallo al eliminar", + "tagSaveSuccess": "Etiquetas guardadas con éxito", + "tagSaveFailed": "Fallo al guardar etiquetas", + "importTitle": "Importar lista", + "importTextHint": "Pegue varias líneas, un nombre de estudiante por línea", + "importTextPlaceholder": "Ejemplo:\nJuan\nMaría\nPablo", + "importByText": "Importar desde texto", + "importByXlsx": "Importar via xlsx", + "importByBanyou": "Importar desde BanYou", + "xlsxPreview": "Vista previa e importación xlsx", + "file": "Archivo", + "selectNameCol": "Haga clic en el encabezado para seleccionar la columna de nombre", + "previewRows": "Vista previa de las 50 primeras líneas", + "importConfirm": "Importar", + "importComplete": "Importación terminada: {{inserted}} añadidos, {{skipped}} ignorados", + "workerNotReady": "Worker no inicializado", + "parseXlsxFailed": "Fallo al analizar xlsx", + "selectNameColFirst": "Por favor primero seleccione la « Columna de nombre »", + "noNamesFound": "Sin nombres importables encontrados en la columna seleccionada", + "importFailed": "Fallo al importar", + "banyouCookieHint": "Después de iniciar sesión en BanYou Web, copie la cookie completa y péguela a continuación.", + "banyouCookiePlaceholder": "uid=...; accessToken=...; ...", + "banyouCookieRequired": "Por favor primero pegue la cookie", + "banyouFetch": "Obtener lista de clases", + "banyouFetchSuccess": "{{count}} clases obtenidas", + "banyouFetchFailed": "Fallo al obtener clases BanYou", + "banyouCreatedClasses": "Clases que he creado", + "banyouJoinedClasses": "Clases que he unido", + "banyouNoClasses": "Sin datos de clases por el momento" + }, + "boards": { + "title": "Gestión de tableros", + "addBoard": "Añadir tablero", + "editBoard": "Editar tablero", + "deleteBoard": "Eliminar tablero", + "name": "Nombre del tablero", + "description": "Descripción", + "visibility": "Visibilidad", + "public": "Público", + "private": "Privado", + "studentsCount": "{{count}} estudiantes", + "noBoards": "Sin tableros por el momento", + "addTitle": "Añadir tablero", + "editTitle": "Editar tablero", + "namePlaceholder": "Ingrese el nombre del tablero", + "descriptionPlaceholder": "Ingrese la descripción", + "confirmDelete": "¿Confirmar la eliminación de este tablero?", + "saved": "Guardado", + "saveFailed": "Fallo al guardar", + "deleted": "Eliminado", + "deleteFailed": "Fallo al eliminar", + "boardExists": "El tablero ya existe" + }, + "leaderboard": { + "title": "Clasificación", + "sortBy": "Ordenar por", + "score": "Puntos", + "name": "Nombre", + "rank": "Rango", + "student": "Estudiante", + "points": "Puntos", + "noData": "Sin datos de clasificación", + "topStudents": "Mejores estudiantes", + "rankChanged": "Rango: {{rank}}" + }, + "settlements": { + "title": "Historial de liquidaciones", + "phase": "Fase", + "date": "Fecha", + "studentCount": "Número de estudiantes", + "totalPoints": "Puntos totales", + "actions": "Acciones", + "viewDetails": "Ver detalles", + "noSettlements": "Sin liquidaciones por el momento", + "phaseDetail": "Detalles de la fase {{phase}}", + "settlementDate": "Fecha de liquidación: {{date}}", + "totalStudents": "Total de estudiantes: {{count}}", + "totalPointsChange": "Modificación total de puntos: {{points}}" + }, + "reasons": { + "title": "Gestión de razones", + "addReason": "Añadir razón", + "editReason": "Editar razón", + "name": "Nombre de la razón", + "points": "Puntos", + "positive": "Positivo", + "negative": "Negativo", + "noReasons": "Sin razones por el momento", + "addTitle": "Añadir razón", + "editTitle": "Editar razón", + "namePlaceholder": "Ingrese el nombre de la razón", + "pointsPlaceholder": "Ingrese los puntos", + "confirmDelete": "¿Confirmar la eliminación de esta razón?", + "saved": "Guardado", + "saveFailed": "Fallo al guardar", + "deleted": "Eliminado", + "deleteFailed": "Fallo al eliminar", + "reasonExists": "La razón ya existe" + }, + "autoScore": { + "title": "Añadir puntos automáticamente", + "addRule": "Añadir regla", + "editRule": "Editar regla", + "deleteRule": "Eliminar regla", + "ruleName": "Nombre de la regla", + "trigger": "Disparador", + "action": "Acción", + "interval": "Intervalo", + "enabled": "Habilitado", + "noRules": "Sin reglas por el momento", + "addTitle": "Añadir regla", + "editTitle": "Editar regla", + "namePlaceholder": "Ingrese el nombre de la regla", + "triggerPlaceholder": "Seleccione el disparador", + "actionPlaceholder": "Seleccione la acción", + "intervalPlaceholder": "Seleccione el intervalo", + "confirmDelete": "¿Confirmar la eliminación de esta regla?", + "saved": "Guardado", + "saveFailed": "Fallo al guardar", + "deleted": "Eliminado", + "deleteFailed": "Fallo al eliminar", + "ruleExists": "La regla ya existe" + }, + "rewardExchange": { + "title": "Intercambio de recompensas", + "rewards": "Recompensas", + "exchange": "Intercambiar", + "history": "Historial", + "noRewards": "Sin recompensas por el momento", + "noHistory": "Sin historial por el momento", + "rewardName": "Nombre de la recompensa", + "cost": "Costo", + "stock": "Stock", + "exchangeSuccess": "Intercambio exitoso", + "exchangeFailed": "Fallo al intercambiar", + "insufficientPoints": "Puntos insuficientes", + "outOfStock": "Agotado" + }, + "rewardSettings": { + "title": "Ajustes de recompensa", + "addReward": "Añadir recompensa", + "editReward": "Editar recompensa", + "deleteReward": "Eliminar recompensa", + "name": "Nombre", + "cost": "Costo", + "stock": "Stock", + "noRewards": "Sin recompensas por el momento", + "addTitle": "Añadir recompensa", + "editTitle": "Editar recompensa", + "namePlaceholder": "Ingrese el nombre", + "costPlaceholder": "Ingrese el costo", + "stockPlaceholder": "Ingrese el stock", + "confirmDelete": "¿Confirmar la eliminación de esta recompensa?", + "saved": "Guardado", + "saveFailed": "Fallo al guardar", + "deleted": "Eliminado", + "deleteFailed": "Fallo al eliminar", + "rewardExists": "La recompensa ya existe" + }, + "recovery": { + "title": "Frase de recuperación SecScore", + "saved": "He guardado", + "export": "Exportar archivo de texto", + "exportHint": "Se recomienda exportar y almacenar fuera de línea. En caso de pérdida, no podrá ser recuperado.", + "hint": "Se recomienda exportar y almacenar fuera de línea después de la exportación. En caso de pérdida, no podrá ser recuperado." + } +} diff --git a/src/i18n/locales/fr-FR.json b/src/i18n/locales/fr-FR.json new file mode 100644 index 0000000..c30d1e3 --- /dev/null +++ b/src/i18n/locales/fr-FR.json @@ -0,0 +1,668 @@ +{ + "common": { + "next": "Suivant", + "prev": "Précédent", + "finish": "Terminer", + "cancel": "Annuler", + "save": "Enregistrer", + "loading": "Chargement...", + "confirm": "Confirmer", + "success": "Succès", + "error": "Erreur", + "delete": "Supprimer", + "edit": "Modifier", + "add": "Ajouter", + "search": "Rechercher", + "clear": "Effacer", + "close": "Fermer", + "yes": "Oui", + "no": "Non", + "submit": "Envoyer", + "reset": "Réinitialiser", + "import": "Importer", + "export": "Exporter", + "create": "Créer", + "update": "Mettre à jour", + "refresh": "Actualiser", + "view": "Voir", + "name": "Nom", + "status": "Statut", + "operation": "Opération", + "description": "Description", + "total": "Total {{count}} éléments", + "noData": "Aucune donnée", + "pleaseSelect": "Veuillez sélectionner", + "pleaseEnter": "Veuillez entrer", + "readOnly": "Mode lecture seule", + "none": "Aucun", + "more": "Plus" + }, + "oobe": { + "title": "Bienvenue sur SecScore", + "subtitle": "Expert en gestion de points éducatifs", + "step": "Étape {{current}}/{{total}}", + "skip": "Passer", + "steps": { + "entry": { + "title": "Commencer", + "description": "Choisissez la procédure à suivre", + "enterOobe": "Entrer dans l'OOBE", + "connectPostgresAutoSync": "Connecter PostgreSQL avec synchronisation automatique", + "skipDirect": "Passer l'OOBE et entrer directement" + }, + "postgresql": { + "title": "Connecter PostgreSQL", + "description": "Entrez la chaîne de connexion PostgreSQL. Le système se connectera et se synchronisera automatiquement.", + "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", + "autoSyncAndEnter": "Se connecter et synchroniser automatiquement" + }, + "language": { + "title": "Choisir la langue", + "description": "Veuillez choisir votre langue préférée" + }, + "theme": { + "title": "Définir le thème", + "description": "Choisissez l'apparence de l'interface", + "mode": "Mode", + "lightMode": "Clair", + "darkMode": "Sombre", + "primaryColor": "Couleur principale", + "backgroundGradient": "Dégradé de fond" + }, + "password": { + "title": "Définir le mot de passe", + "description": "Définissez un mot de passe pour protéger vos données (facultatif)", + "adminPassword": "Mot de passe administrateur", + "adminPasswordHint": "Accès à toutes les fonctionnalités, 6 chiffres", + "pointsPassword": "Mot de passe points", + "pointsPasswordHint": "Opérations sur les points uniquement, 6 chiffres", + "passwordPlaceholder": "Entrez 6 chiffres", + "hint": "Laisser vide pour ignorer, configurer plus tard dans les paramètres" + }, + "students": { + "title": "Importer la liste d'élèves", + "description": "Ajoutez une liste d'élèves pour commencer la gestion des points", + "import": "Importer la liste", + "manual": "Ajout manuel", + "importHint": "Supporte les fichiers Excel (.xlsx) ou JSON", + "manualHint": "Collez plusieurs lignes, un nom d'élève par ligne", + "dragDrop": "Glissez-déposez des fichiers ici ou cliquez pour sélectionner", + "supportedFormats": "Formats supportés : .xlsx, .json", + "studentName": "Nom de l'élève", + "addStudent": "Ajouter un élève", + "noStudents": "Aucun élève pour le moment, veuillez ajouter ou importer", + "studentCount": "{{count}} élèves ajoutés", + "studentExists": "L'élève existe déjà", + "importSuccess": "{{count}} élèves importés avec succès", + "noNewStudents": "Aucun nouvel élève à importer", + "parseFailed": "Échec de l'analyse du fichier", + "unsupportedFormat": "Format de fichier non supporté" + }, + "reasons": { + "title": "Définir les raisons", + "description": "Ajoutez les raisons courantes de modification de points", + "addReason": "Ajouter une raison", + "reasonName": "Nom de la raison", + "points": "Points", + "positive": "Ajouter des points", + "negative": "Retirer des points", + "noReasons": "Aucune raison pour le moment, veuillez ajouter des raisons courantes", + "reasonCount": "{{count}} raisons ajoutées", + "reasonExists": "La raison existe déjà" + }, + "start": { + "title": "Prêt à l'emploi", + "description": "Tout est prêt. Commençons à utiliser SecScore !", + "features": { + "score": "Gérez facilement les points des élèves", + "history": "Historique complet des modifications de points", + "settlement": "Supporte le règlement et l'archivage" + }, + "startButton": "Commencer" + } + } + }, + "settings": { + "title": "Paramètres système", + "tabs": { + "appearance": "Apparence", + "security": "Sécurité", + "account": "Compte", + "database": "Connexion à la base de données", + "dataManagement": "Gestion des données", + "urlProtocol": "Protocole URL", + "about": "À propos" + }, + "language": "Langue", + "languageHint": "Choisir la langue d'affichage de l'interface", + "fontFamily": "Police globale", + "fontFamilyHint": "Choisir la police de l'interface (nécessite un rafraîchissement)", + "theme": "Thème", + "password": "Définir le mot de passe", + "themeMode": "Mode", + "lightMode": "Clair", + "darkMode": "Sombre", + "primaryColor": "Couleur principale", + "backgroundGradient": "Dégradé de fond", + "searchKeyboard": { + "title": "Clavier de recherche", + "hint": "Choisir la disposition du clavier sous la zone de recherche", + "disableToggle": "Désactiver le clavier de recherche", + "disableHint": "Le clavier ne s'affichera plus sous la zone de recherche", + "options": { + "qwerty26": "26 touches (QWERTY)", + "t9": "9 touches (T9)" + } + }, + "interfaceZoom": "Zoom de l'interface", + "zoomOptions": { + "small70": "Petit (70%)", + "default100": "Par défaut (100%)", + "large150": "Grand (150%)" + }, + "general": { + "saved": "Enregistré", + "saveFailed": "Échec de l'enregistrement" + }, + "zoomHint": "Ajuster la taille globale de l'interface", + "mobile": { + "navigationTitle": "Entrées de page", + "bottomNav": { + "label": "Fonctions de navigation inférieure", + "hint": "Choisir les fonctions à afficher dans la barre inférieure du mobile. La barre affiche maximum 4 éléments, les autres vont dans « Plus »." + }, + "dragHint": "Glissez pour réorganiser. Les 4 premiers éléments apparaissent dans la barre inférieure, les autres dans « Plus ».", + "dropHere": "Relâchez ici", + "bottomSection": "Barre inférieure (max 4)", + "moreSection": "Plus", + "moveToMore": "Déplacer vers Plus", + "moveToBottom": "Déplacer vers le bas" + }, + "zoomUpdated": "Zoom de l'interface mis à jour", + "updateFailed": "Échec de la mise à jour", + "security": { + "title": "Système de protection par mot de passe", + "adminPassword": "Mot de passe administrateur", + "pointsPassword": "Mot de passe points", + "recoveryString": "Phrase de récupération", + "set": "Défini", + "notSet": "Non défini", + "generated": "Généré", + "notGenerated": "Non généré", + "enterPassword": "Entrez 6 chiffres (laisser vide pour ne pas modifier)", + "adminPasswordPlaceholder": "Entrez 6 chiffres pour le mot de passe administrateur (laisser vide pour ne pas modifier)", + "pointsPasswordPlaceholder": "Entrez 6 chiffres pour le mot de passe points (laisser vide pour ne pas modifier)", + "recoveryPlaceholder": "Entrez la phrase de récupération", + "savePassword": "Enregistrer le mot de passe", + "savePasswords": "Enregistrer les mots de passe", + "generateRecovery": "Générer la phrase de récupération", + "clearAllPasswords": "Effacer tous les mots de passe", + "recoveryReset": "Réinitialiser la phrase de récupération", + "enterRecovery": "Entrez la phrase de récupération", + "resetPassword": "Réinitialiser le mot de passe", + "resetHint": "La réinitialisation effacera les mots de passe administrateur et points, et générera une nouvelle phrase de récupération.", + "confirmClear": "Confirmer l'effacement de tous les mots de passe ?", + "clearHint": "Après effacement, la protection sera désactivée (aucun mot de passe = permission administrateur par défaut).", + "saved": "Mot de passe mis à jour", + "saveFailed": "Échec de la mise à jour", + "generateFailed": "Échec de la génération", + "resetFailed": "Échec de la réinitialisation", + "cleared": "Effacé", + "clearFailed": "Échec de l'effacement", + "passwordClearedNewRecovery": "Mot de passe effacé, nouvelle phrase de récupération", + "newRecoveryString": "Nouvelle phrase de récupération (veuillez l'enregistrer)" + }, + "account": { + "title": "Compte SECTL Auth", + "notLoggedIn": "Non connecté à SECTL Auth", + "oauthHint": "Connectez-vous avec votre compte SECTL Auth pour profiter de l'authentification unifiée et de la déconnexion à distance", + "loginButton": "Se connecter avec SECTL Auth", + "loginSuccess": "Connexion réussie", + "logout": "Déconnexion", + "userId": "ID utilisateur", + "permission": "Niveau de permission" + }, + "database": { + "title": "Connexion à la base de données", + "currentStatus": "Statut actuel de la base de données", + "sqliteLocal": "Base de données SQLite locale", + "postgresqlRemote": "Base de données PostgreSQL distante", + "connected": "Connecté", + "disconnected": "Déconnecté", + "postgresqlConnection": "Connexion PostgreSQL distante", + "connectionHint": "Entrez la chaîne de connexion PostgreSQL pour se connecter à une base de données distante, supporte la synchronisation multi-plateforme.", + "testConnection": "Tester la connexion", + "switchToPostgreSQL": "Passer à PostgreSQL", + "switchToSQLite": "Passer à SQLite local", + "connectionTestSuccess": "Test de connexion réussi", + "connectionTestFailed": "Échec du test de connexion", + "switchedTo": "Passé à la base de données {{type}}", + "switchedToSQLite": "Passé à la base de données SQLite locale", + "switchFailed": "Échec du changement", + "syncDescription": "Instructions de synchronisation multi-plateforme", + "syncPoint1": "L'utilisation d'une base de données PostgreSQL distante permet la synchronisation des données multi-plateforme.", + "syncPoint2": "Mécanisme de file d'opérations intégré pour assurer la cohérence des données lors des opérations simultanées.", + "syncPoint3": "Un redémarrage de l'application est nécessaire après le changement de base de données.", + "syncPoint4": "Services de base de données cloud recommandés : Neon, Supabase, AWS RDS, etc.", + "enterConnectionString": "Veuillez entrer la chaîne de connexion PostgreSQL", + "connectionExample": "Exemple : postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", + "uploadButton": "Télécharger les données locales vers le serveur distant", + "uploadHint": "Après connexion à la base de données distante, téléchargez et fusionnez l'historique SQLite local vers le serveur distant.", + "uploadNeedRemote": "Veuillez d'abord passer et connecter à la base de données PostgreSQL distante", + "noNeedUpload": "Les données locales et distantes sont déjà cohérentes", + "uploadConfirmTitle": "Confirmer le téléchargement des données locales vers le serveur distant ?", + "uploadConfirmContent": "La synchronisation privilégiera les données locales. Détecté : local-only {{localOnly}}, distant-only {{remoteOnly}}, conflits {{conflicts}}.", + "uploadSuccess": "Données locales téléchargées vers le serveur distant", + "uploadFailed": "Échec du téléchargement" + }, + "data": { + "title": "Gestion des données", + "settlement": "Règlement", + "settlementAndRestart": "Régler et redémarrer", + "settlementHint": "Classe les enregistrements non régulés dans une phase et réinitialise tous les points des élèves à zéro.", + "importExport": "Importer / Exporter", + "exportJson": "Exporter JSON", + "importJson": "Importer JSON", + "importHint": "L'importation écrasera les élèves/raisons/enregistrements/paramètres existants (les paramètres de sécurité sont exclus).", + "logs": "Journaux", + "logLevel": "Niveau de journal", + "logOperation": "Opération sur les journaux", + "viewLogs": "Voir les journaux", + "exportLogs": "Exporter les journaux", + "clearLogs": "Effacer les journaux", + "logsCleared": "Journaux effacés", + "systemLogs": "Journaux système (200 derniers)", + "noLogs": "Aucun journal", + "logLevelUpdated": "Niveau de journal mis à jour", + "readLogsFailed": "Échec de la lecture des journaux", + "logsExported": "Journaux exportés", + "exportFailed": "Échec de l'exportation", + "exportSuccess": "Exportation réussie", + "importSuccess": "Importation réussie, actualisation en cours", + "importFailed": "Échec de l'importation", + "clearFailed": "Échec de l'effacement", + "confirmSettlement": "Confirmer le règlement et le redémarrage ?", + "settlementConfirm1": "Cela archivera les enregistrements non régulés dans une phase et réinitialisera tous les points des élèves à zéro.", + "settlementConfirm2": "La liste des élèves reste inchangée ; l'historique du règlement est consultable dans la page « Historique des règlements ».", + "settlementSuccess": "Règlement réussi, points redémarrés", + "settlementFailed": "Échec du règlement", + "settle": "Régler", + "logLevels": { + "debug": "DEBUG (débogage)", + "info": "INFO (information)", + "warn": "WARN (avertissement)", + "error": "ERROR (erreur)" + } + }, + "url": { + "title": "Protocole URL (secscore://)", + "protocol": "Protocole URL", + "description": "Vous pouvez invoquer SecScore via une URL pour effectuer des opérations, par exemple :", + "register": "Enregistrer le protocole URL", + "registered": "Protocole URL enregistré", + "registerFailed": "Échec de l'enregistrement", + "installerRequired": "Nécessite la version installée de SecScore, peut ne pas fonctionner en mode développement." + }, + "mcp": { + "title": "Service MCP", + "description": "Gérer le service HTTP MCP intégré pour les clients MCP externes.", + "running": "En cours d'exécution", + "stopped": "Arrêté", + "noUrl": "Aucune URL", + "host": "Hôte", + "port": "Port", + "start": "Démarrer", + "stop": "Arrêter", + "refresh": "Actualiser l'état", + "hint": "Recommandé pour lier uniquement à 127.0.0.1 ; après le démarrage, le point de terminaison est http://host:port/mcp", + "hostRequired": "Veuillez entrer l'hôte MCP", + "portInvalid": "Veuillez entrer un port valide (1-65535)", + "startSuccess": "Serveur MCP démarré avec succès", + "startFailed": "Échec du démarrage du serveur MCP", + "stopSuccess": "Serveur MCP arrêté", + "stopFailed": "Échec de l'arrêt du serveur MCP", + "startTimeout": "Démarrage du serveur MCP dépassé, veuillez réessayer", + "stopTimeout": "Arrêt du serveur MCP dépassé, veuillez réessayer" + }, + "app": { + "title": "Contrôles de l'application", + "description": "Redémarrer ou quitter rapidement l'application.", + "restart": "Redémarrer l'application", + "quit": "Quitter l'application", + "confirmRestartTitle": "Confirmer le redémarrage de l'application ?", + "confirmRestartContent": "L'application se fermera et redémarrera immédiatement.", + "confirmQuitTitle": "Confirmer la fermeture de l'application ?", + "confirmQuitContent": "L'application se fermera immédiatement.", + "restartFailed": "Échec du redémarrage", + "quitFailed": "Échec de la fermeture" + }, + "about": { + "title": "À propos", + "appName": "Gestion des points éducatifs", + "version": "Version", + "copyright": "Copyright", + "ipcStatus": "Statut IPC", + "ipcConnected": "Connecté", + "ipcDisconnected": "Déconnecté (Preload échoué)", + "environment": "Environnement", + "toggleDevTools": "Activer/désactiver les outils de développement", + "license": "SecScore est sous licence GPL3.0", + "copyRightText": "CopyRight © 2025-{{year}} SECTL" + } + }, + "sidebar": { + "home": "Accueil", + "students": "Gestion des élèves", + "score": "Opérations sur les points", + "boards": "Tableaux", + "leaderboard": "Classement", + "settlements": "Historique des règlements", + "reasons": "Gestion des raisons", + "autoScore": "Ajout automatique de points", + "rewardExchange": "Échange de récompenses", + "rewardSettings": "Paramètres de récompense", + "settings": "Paramètres système", + "remoteDb": "Base de données distante", + "refreshStatus": "Actualiser l'état", + "syncNow": "Synchroniser maintenant", + "syncSuccess": "Synchronisation réussie", + "syncFailed": "Échec de la synchronisation", + "getDbStatusFailed": "Échec de l'obtention du statut de la base de données", + "notRemoteMode": "Mode base de données distante non activé, veuillez redémarrer l'application", + "dbNotConnected": "Base de données non connectée" + }, + "auth": { + "unlock": "Déverrouiller la permission", + "unlockHint": "Entrez le mot de passe à 6 chiffres : mot de passe administrateur = accès complet, mot de passe points = opérations sur les points uniquement.", + "unlockButton": "Déverrouiller", + "unlocked": "Permission déverrouillée", + "logout": "Passé en lecture seule", + "passwordPlaceholder": "ex. 123456", + "passwordError": "Mot de passe incorrect", + "enterPassword": "Entrer le mot de passe", + "lock": "Verrouiller" + }, + "languages": { + "zh-CN": "简体中文", + "en-US": "English", + "ja-JP": "日本語", + "fr-FR": "Français", + "es-ES": "Español", + "ko-KR": "한국어", + "ru-RU": "Русский", + "de-DE": "Deutsch", + "pt-BR": "Português", + "ar-SA": "العربية" + }, + "theme": { + "colorAndBackground": "Couleur et fond", + "gradientLabels": { + "blue": "Bleu frais", + "pink": "Rose doux", + "cyan": "Cyan", + "purple": "Violet" + }, + "gradientDirections": { + "vertical": "Vertical", + "horizontal": "Horizontal", + "diagonal": "Diagonal" + }, + "generate": "Générer", + "saved": "Enregistré", + "saveFailed": "Échec de l'enregistrement", + "mode": "Mode", + "myTheme": "Mon thème" + }, + "permissions": { + "admin": "Permission administrateur", + "points": "Permission points", + "view": "Lecture seule" + }, + "home": { + "title": "Page d'accueil des points des élèves", + "subtitle": "{{count}} élèves au total, cliquez sur la carte pour effectuer des opérations", + "searchPlaceholder": "Rechercher nom/pinyin...", + "sortBy": { + "alphabet": "Tri par nom", + "surname": "Groupe par nom de famille", + "group": "Tri par groupe", + "score": "Classement par points" + }, + "layoutBy": { + "grouped": "Liste groupée", + "squareGrid": "Grille carrée" + }, + "noStudents": "Aucune donnée d'élève, veuillez ajouter dans la gestion des élèves", + "noMatch": "Aucun élève correspondant trouvé", + "clearSearch": "Effacer la recherche", + "points": "pts", + "currentScore": "Points actuels", + "quickOptions": "Options rapides", + "adjustPoints": "Ajuster les points", + "customPoints": "Points personnalisés", + "customPointsHint": "Entrez n'importe quelle valeur dans la zone de saisie", + "reason": "Raison", + "reasonPlaceholder": "Entrez la raison de la modification des points (facultatif)", + "preview": "Aperçu de la modification", + "noReason": "(Aucune raison)", + "ungrouped": "Non groupé", + "category": { + "others": "Autres" + }, + "scoreAdded": "{{points}} points ajoutés à {{name}}", + "scoreDeducted": "{{points}} points retirés à {{name}}", + "submitFailed": "Échec de l'envoi", + "pleaseSelectPoints": "Veuillez sélectionner ou entrer des points", + "reasonDefault": "{{action}} {{points}} points", + "studentCount": "{{count}} élèves", + "operationTitle": "Opération sur les points : {{name}}", + "submitOperation": "Envoyer l'opération", + "operationTitleBatch": "Opération sur les points : {{count}} sélectionnés", + "undoLastAction": "Annuler", + "undoLastHint": "Annuler le dernier : {{name}} {{delta}}", + "undoUnavailable": "Aucune opération sur les points à annuler", + "undoLastSuccess": "Dernière opération sur les points annulée", + "multiSelect": "Sélection multiple", + "batchMode": "Mode batch", + "selectAll": "Tout sélectionner", + "clearSelected": "Effacer la sélection", + "batchOperate": "Points en batch", + "selected": "Sélectionné", + "selectedCount": "{{count}} sélectionnés", + "batchSuccess": "Points soumis pour {{count}} élèves", + "batchPartial": "{{success}}/{{total}} élèves soumis avec succès", + "selectStudentFirst": "Veuillez d'abord sélectionner un ou plusieurs élèves", + "addPoints": "Ajouter des points", + "deductPoints": "Retirer des points", + "pointsChange": "Modification des points", + "reasonLabel": "Raison : " + }, + "students": { + "title": "Gestion des élèves", + "importList": "Importer la liste", + "addStudent": "Ajouter un élève", + "name": "Nom", + "group": "Groupe", + "avatar": "Avatar", + "currentScore": "Points actuels", + "tags": "Tags", + "noTags": "Aucun tag", + "editTags": "Modifier les tags", + "editAvatar": "Définir l'avatar", + "deleteConfirm": "Confirmer la suppression de cet élève ?", + "addTitle": "Ajouter un élève", + "addConfirm": "Ajouter", + "namePlaceholder": "Entrez le nom de l'élève", + "groupPlaceholder": "Entrez le nom du groupe (facultatif)", + "nameRequired": "Veuillez entrer le nom", + "nameExists": "Le nom de l'élève existe déjà", + "addSuccess": "Ajouté avec succès", + "addFailed": "Échec de l'ajout", + "deleteSuccess": "Supprimé avec succès", + "deleteFailed": "Échec de la suppression", + "tagSaveSuccess": "Tags enregistrés avec succès", + "tagSaveFailed": "Échec de l'enregistrement des tags", + "importTitle": "Importer la liste", + "importTextHint": "Collez plusieurs lignes, un nom d'élève par ligne", + "importTextPlaceholder": "Exemple :\nJean\nMarie\nPierre", + "importByText": "Importer depuis le texte", + "importByXlsx": "Importer via xlsx", + "importByBanyou": "Importer depuis BanYou", + "xlsxPreview": "Aperçu et import xlsx", + "file": "Fichier", + "selectNameCol": "Cliquez sur l'en-tête pour sélectionner la colonne de nom", + "previewRows": "Aperçu des 50 premières lignes", + "importConfirm": "Importer", + "importComplete": "Importation terminée : {{inserted}} ajoutés, {{skipped}} ignorés", + "workerNotReady": "Worker non initialisé", + "parseXlsxFailed": "Échec de l'analyse xlsx", + "selectNameColFirst": "Veuillez d'abord sélectionner la « Colonne de nom »", + "noNamesFound": "Aucun nom importable trouvé dans la colonne sélectionnée", + "importFailed": "Échec de l'importation", + "banyouCookieHint": "Après vous être connecté à BanYou Web, copiez le cookie complet et collez-le ci-dessous.", + "banyouCookiePlaceholder": "uid=...; accessToken=...; ...", + "banyouCookieRequired": "Veuillez d'abord coller le cookie", + "banyouFetch": "Récupérer la liste des classes", + "banyouFetchSuccess": "{{count}} classes récupérées", + "banyouFetchFailed": "Échec de la récupération des classes BanYou", + "banyouCreatedClasses": "Classes que j'ai créées", + "banyouJoinedClasses": "Classes que j'ai rejointes", + "banyouNoClasses": "Aucune donnée de classe pour le moment" + }, + "boards": { + "title": "Gestion des tableaux", + "addBoard": "Ajouter un tableau", + "editBoard": "Modifier le tableau", + "deleteBoard": "Supprimer le tableau", + "name": "Nom du tableau", + "description": "Description", + "visibility": "Visibilité", + "public": "Public", + "private": "Privé", + "studentsCount": "{{count}} élèves", + "noBoards": "Aucun tableau pour le moment", + "addTitle": "Ajouter un tableau", + "editTitle": "Modifier le tableau", + "namePlaceholder": "Entrez le nom du tableau", + "descriptionPlaceholder": "Entrez la description", + "confirmDelete": "Confirmer la suppression de ce tableau ?", + "saved": "Enregistré", + "saveFailed": "Échec de l'enregistrement", + "deleted": "Supprimé", + "deleteFailed": "Échec de la suppression", + "boardExists": "Le tableau existe déjà" + }, + "leaderboard": { + "title": "Classement", + "sortBy": "Trier par", + "score": "Points", + "name": "Nom", + "rank": "Rang", + "student": "Élève", + "points": "Points", + "noData": "Aucune donnée de classement", + "topStudents": "Meilleurs élèves", + "rankChanged": "Rang : {{rank}}" + }, + "settlements": { + "title": "Historique des règlements", + "phase": "Phase", + "date": "Date", + "studentCount": "Nombre d'élèves", + "totalPoints": "Points totaux", + "actions": "Actions", + "viewDetails": "Voir les détails", + "noSettlements": "Aucun règlement pour le moment", + "phaseDetail": "Détails de la phase {{phase}}", + "settlementDate": "Date de règlement : {{date}}", + "totalStudents": "Total des élèves : {{count}}", + "totalPointsChange": "Modification totale des points : {{points}}" + }, + "reasons": { + "title": "Gestion des raisons", + "addReason": "Ajouter une raison", + "editReason": "Modifier la raison", + "name": "Nom de la raison", + "points": "Points", + "positive": "Positif", + "negative": "Négatif", + "noReasons": "Aucune raison pour le moment", + "addTitle": "Ajouter une raison", + "editTitle": "Modifier la raison", + "namePlaceholder": "Entrez le nom de la raison", + "pointsPlaceholder": "Entrez les points", + "confirmDelete": "Confirmer la suppression de cette raison ?", + "saved": "Enregistré", + "saveFailed": "Échec de l'enregistrement", + "deleted": "Supprimé", + "deleteFailed": "Échec de la suppression", + "reasonExists": "La raison existe déjà" + }, + "autoScore": { + "title": "Ajout automatique de points", + "addRule": "Ajouter une règle", + "editRule": "Modifier la règle", + "deleteRule": "Supprimer la règle", + "ruleName": "Nom de la règle", + "trigger": "Déclencheur", + "action": "Action", + "interval": "Intervalle", + "enabled": "Activé", + "noRules": "Aucune règle pour le moment", + "addTitle": "Ajouter une règle", + "editTitle": "Modifier la règle", + "namePlaceholder": "Entrez le nom de la règle", + "triggerPlaceholder": "Sélectionnez le déclencheur", + "actionPlaceholder": "Sélectionnez l'action", + "intervalPlaceholder": "Sélectionnez l'intervalle", + "confirmDelete": "Confirmer la suppression de cette règle ?", + "saved": "Enregistré", + "saveFailed": "Échec de l'enregistrement", + "deleted": "Supprimé", + "deleteFailed": "Échec de la suppression", + "ruleExists": "La règle existe déjà" + }, + "rewardExchange": { + "title": "Échange de récompenses", + "rewards": "Récompenses", + "exchange": "Échanger", + "history": "Historique", + "noRewards": "Aucune récompense pour le moment", + "noHistory": "Aucun historique pour le moment", + "rewardName": "Nom de la récompense", + "cost": "Coût", + "stock": "Stock", + "exchangeSuccess": "Échange réussi", + "exchangeFailed": "Échec de l'échange", + "insufficientPoints": "Points insuffisants", + "outOfStock": "Rupture de stock" + }, + "rewardSettings": { + "title": "Paramètres de récompense", + "addReward": "Ajouter une récompense", + "editReward": "Modifier la récompense", + "deleteReward": "Supprimer la récompense", + "name": "Nom", + "cost": "Coût", + "stock": "Stock", + "noRewards": "Aucune récompense pour le moment", + "addTitle": "Ajouter une récompense", + "editTitle": "Modifier la récompense", + "namePlaceholder": "Entrez le nom", + "costPlaceholder": "Entrez le coût", + "stockPlaceholder": "Entrez le stock", + "confirmDelete": "Confirmer la suppression de cette récompense ?", + "saved": "Enregistré", + "saveFailed": "Échec de l'enregistrement", + "deleted": "Supprimé", + "deleteFailed": "Échec de la suppression", + "rewardExists": "La récompense existe déjà" + }, + "recovery": { + "title": "Phrase de récupération SecScore", + "saved": "J'ai enregistré", + "export": "Exporter le fichier texte", + "exportHint": "Il est recommandé d'exporter et de stocker hors ligne. En cas de perte, il ne pourra pas être récupéré.", + "hint": "Il est recommandé d'exporter et de stocker hors ligne après l'export. En cas de perte, il ne pourra pas être récupéré." + } +} diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json new file mode 100644 index 0000000..15ee239 --- /dev/null +++ b/src/i18n/locales/ja-JP.json @@ -0,0 +1,905 @@ +{ + "common": { + "next": "次へ", + "prev": "前へ", + "finish": "完了", + "cancel": "キャンセル", + "save": "保存", + "loading": "読み込み中...", + "confirm": "確認", + "success": "成功", + "error": "エラー", + "delete": "削除", + "edit": "編集", + "add": "追加", + "search": "検索", + "clear": "クリア", + "close": "閉じる", + "yes": "はい", + "no": "いいえ", + "submit": "送信", + "reset": "リセット", + "import": "インポート", + "export": "エクスポート", + "create": "作成", + "update": "更新", + "refresh": "更新", + "view": "表示", + "name": "名前", + "status": "状態", + "operation": "操作", + "description": "説明", + "total": "{{count}} 件", + "noData": "データなし", + "pleaseSelect": "選択してください", + "pleaseEnter": "入力してください", + "readOnly": "読み取り専用モード", + "none": "なし", + "more": "もっと" + }, + "oobe": { + "title": "SecScoreへようこそ", + "subtitle": "教育ポイント管理エキスパート", + "step": "ステップ {{current}}/{{total}}", + "skip": "スキップ", + "steps": { + "entry": { + "title": "開始", + "description": "次の操作を選択してください", + "enterOobe": "OOBEに入る", + "connectPostgresAutoSync": "PostgreSQLに接続して自動同期", + "skipDirect": "OOBEをスキップして直接入る" + }, + "postgresql": { + "title": "PostgreSQLに接続", + "description": "PostgreSQL接続文字列を入力してください。自動接続・同期後にアプリに入ります。", + "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", + "autoSyncAndEnter": "接続・自動同期して入る" + }, + "language": { + "title": "言語を選択", + "description": "お好みのインターフェース言語を選択してください" + }, + "theme": { + "title": "テーマを設定", + "description": "お好みの外観を選択してください", + "mode": "モード", + "lightMode": "ライト", + "darkMode": "ダーク", + "primaryColor": "メインカラー", + "backgroundGradient": "背景グラデーション" + }, + "password": { + "title": "パスワードを設定", + "description": "データを保護するログインパスワードを設定(任意)", + "adminPassword": "管理者パスワード", + "adminPasswordHint": "全機能にアクセス可能、6桁の数字", + "pointsPassword": "ポイントパスワード", + "pointsPasswordHint": "ポイント操作のみ、6桁の数字", + "passwordPlaceholder": "6桁の数字を入力", + "hint": "空欄でスキップ、後で設定で構成できます" + }, + "students": { + "title": "名簿をインポート", + "description": "生徒名簿を追加してポイント管理を開始", + "import": "名簿をインポート", + "manual": "手動追加", + "importHint": "Excel (.xlsx) または JSON ファイルに対応", + "manualHint": "複数行のテキストを貼り付け、1行に1名前", + "dragDrop": "ファイルをここにドラッグ、またはクリックして選択", + "supportedFormats": ".xlsx, .json 形式に対応", + "studentName": "生徒名", + "addStudent": "生徒を追加", + "noStudents": "生徒がいません。追加またはインポートしてください", + "studentCount": "{{count}} 名の生徒を追加", + "studentExists": "生徒は既に存在します", + "importSuccess": "{{count}} 名の生徒をインポート成功", + "noNewStudents": "インポートする新規生徒はいません", + "parseFailed": "ファイル解析に失敗", + "unsupportedFormat": "サポートされていないファイル形式" + }, + "reasons": { + "title": "理由を設定", + "description": "よく使うポイント変動理由を追加", + "addReason": "理由を追加", + "reasonName": "理由名", + "points": "ポイント", + "positive": "加点", + "negative": "減点", + "noReasons": "理由がありません。よく使う理由を追加してください", + "reasonCount": "{{count}} 件の理由を追加", + "reasonExists": "理由は既に存在します" + }, + "start": { + "title": "準備完了", + "description": "すべて準備が整いました。SecScoreを使い始めましょう!", + "features": { + "score": "生徒のポイントを簡単管理", + "history": "完全なポイント変動履歴", + "settlement": "決済とアーカイブに対応" + }, + "startButton": "始める" + } + } + }, + "settings": { + "title": "設定", + "tabs": { + "appearance": "外観", + "security": "セキュリティ", + "account": "アカウント", + "database": "データベース接続", + "dataManagement": "データ管理", + "urlProtocol": "URLプロトコル", + "about": "について" + }, + "language": "言語", + "languageHint": "インターフェースの表示言語を選択", + "fontFamily": "グローバルフォント", + "fontFamilyHint": "アプリのフォントを選択(完全に反映するにはページの更新が必要)", + "theme": "テーマ", + "password": "パスワードを設定", + "themeMode": "モード", + "lightMode": "ライト", + "darkMode": "ダーク", + "primaryColor": "メインカラー", + "backgroundGradient": "背景グラデーション", + "searchKeyboard": { + "title": "検索キーボード", + "hint": "検索ボックス下に表示するキーボードレイアウトを選択", + "disableToggle": "検索キーボードを無効化", + "disableHint": "有効にすると、検索ボックス下にキーボードが表示されなくなります", + "options": { + "qwerty26": "26キー (QWERTY)", + "t9": "9キー (T9)" + } + }, + "interfaceZoom": "インターフェースズーム", + "zoomOptions": { + "small70": "小 (70%)", + "default100": "デフォルト (100%)", + "large150": "大 (150%)" + }, + "general": { + "saved": "保存しました", + "saveFailed": "保存に失敗しました" + }, + "zoomHint": "アプリのインターフェースの全体サイズを調整", + "mobile": { + "navigationTitle": "ページショートカット", + "bottomNav": { + "label": "下部ナビゲーション機能", + "hint": "モバイル下部バーに表示する機能を選択。バーは最大4項目、残りは「もっと見る」に収納。" + }, + "dragHint": "ドラッグで並べ替え。最初の4項目が下部バーに表示、残りは「もっと見る」に。", + "dropHere": "ここにドロップ", + "bottomSection": "下部バー(最大4)", + "moreSection": "もっと見る", + "moveToMore": "もっと見るに移動", + "moveToBottom": "下部バーに移動" + }, + "zoomUpdated": "ズームが更新されました", + "updateFailed": "更新に失敗しました", + "security": { + "title": "パスワード保護システム", + "adminPassword": "管理者パスワード", + "pointsPassword": "ポイントパスワード", + "recoveryString": "復旧文字列", + "set": "設定済み", + "notSet": "未設定", + "generated": "生成済み", + "notGenerated": "未生成", + "enterPassword": "6桁の数字を入力(空欄でスキップ)", + "adminPasswordPlaceholder": "6桁の管理者パスワードを入力(空欄でスキップ)", + "pointsPasswordPlaceholder": "6桁のポイントパスワードを入力(空欄でスキップ)", + "recoveryPlaceholder": "復旧文字列を入力", + "savePassword": "パスワードを保存", + "savePasswords": "パスワードを保存", + "generateRecovery": "復旧文字列を生成", + "clearAllPasswords": "全パスワードをクリア", + "recoveryReset": "復旧文字列リセット", + "enterRecovery": "復旧文字列を入力", + "resetPassword": "パスワードをリセット", + "resetHint": "リセットすると管理者/ポイントパスワードがクリアされ、新しい復旧文字列が生成されます。", + "confirmClear": "全パスワードをクリアしますか?", + "clearHint": "クリア後、保護が無効になります(パスワードがない場合、管理者権限がデフォルト)。", + "saved": "パスワードが更新されました", + "saveFailed": "更新に失敗しました", + "generateFailed": "生成に失敗しました", + "resetFailed": "リセットに失敗しました", + "cleared": "クリアしました", + "clearFailed": "クリアに失敗しました", + "passwordClearedNewRecovery": "パスワードがクリアされ、新しい復旧文字列", + "newRecoveryString": "新しい復旧文字列(保存してください)" + }, + "account": { + "title": "SECTL Auth アカウント", + "notLoggedIn": "SECTL Auth にログインしていません", + "oauthHint": "SECTL Auth アカウントでログインして、統合認証とリモートログアウトを利用", + "loginButton": "SECTL Auth でログイン", + "loginSuccess": "ログイン成功", + "logout": "ログアウト", + "userId": "ユーザーID", + "permission": "権限レベル" + }, + "database": { + "title": "データベース接続", + "currentStatus": "現在のデータベース状態", + "sqliteLocal": "SQLite ローカルデータベース", + "postgresqlRemote": "PostgreSQL リモートデータベース", + "connected": "接続済み", + "disconnected": "未接続", + "postgresqlConnection": "PostgreSQL リモート接続", + "connectionHint": "PostgreSQL接続文字列を入力してリモートデータベースに接続。マルチデバイス同期に対応。", + "testConnection": "接続テスト", + "switchToPostgreSQL": "PostgreSQLに切り替え", + "switchToSQLite": "ローカルSQLiteに切り替え", + "connectionTestSuccess": "接続テスト成功", + "connectionTestFailed": "接続テスト失敗", + "switchedTo": "{{type}} データベースに切り替えました", + "switchedToSQLite": "SQLite ローカルデータベースに切り替えました", + "switchFailed": "切り替えに失敗しました", + "syncDescription": "マルチデバイス同期情報", + "syncPoint1": "PostgreSQLリモートデータベースを使用すると、マルチデバイスデータ同期が可能です。", + "syncPoint2": "内蔵の操作キューメカニズムにより、同時操作時のデータ整合性を確保。", + "syncPoint3": "データベース切り替え後、アプリの再起動が必要です。", + "syncPoint4": "推奨クラウドデータベースサービス:Neon、Supabase、AWS RDSなど。", + "enterConnectionString": "PostgreSQL接続文字列を入力してください", + "connectionExample": "例:postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", + "uploadButton": "ローカルデータをリモートにアップロード", + "uploadHint": "リモートデータベース接続後、ローカルSQLiteの履歴データをリモートにアップロード・マージ。", + "uploadNeedRemote": "先にPostgreSQLリモートデータベースに接続してください", + "noNeedUpload": "ローカルとリモートのデータは既に一致しています", + "uploadConfirmTitle": "ローカルデータをリモートにアップロードしますか?", + "uploadConfirmContent": "ローカルデータを優先して同期します。ローカルのみ {{localOnly}}、リモートのみ {{remoteOnly}}、競合 {{conflicts}} を検出。", + "uploadSuccess": "ローカルデータをリモートにアップロードしました", + "uploadFailed": "アップロードに失敗しました" + }, + "data": { + "title": "データ管理", + "settlement": "決済", + "settlementAndRestart": "決済して再開", + "settlementHint": "現在の未決済レコードを1つのフェーズにまとめ、全生徒のポイントをゼロにリセット。", + "importExport": "インポート / エクスポート", + "exportJson": "JSONエクスポート", + "importJson": "JSONインポート", + "importHint": "インポートは既存の生徒/理由/レコード/設定を上書きします(セキュリティ設定は除く)。", + "logs": "ログ", + "logLevel": "ログレベル", + "logOperation": "ログ操作", + "viewLogs": "ログを表示", + "exportLogs": "ログをエクスポート", + "clearLogs": "ログをクリア", + "logsCleared": "ログをクリアしました", + "systemLogs": "システムログ(最新200件)", + "noLogs": "ログなし", + "logLevelUpdated": "ログレベルが更新されました", + "readLogsFailed": "ログの読み取りに失敗しました", + "logsExported": "ログをエクスポートしました", + "exportFailed": "エクスポートに失敗しました", + "exportSuccess": "エクスポート成功", + "importSuccess": "インポート成功、更新中", + "importFailed": "インポートに失敗しました", + "clearFailed": "クリアに失敗しました", + "confirmSettlement": "決済して再開しますか?", + "settlementConfirm1": "現在の未決済レコードを1つのフェーズにアーカイブし、全生徒のポイントをゼロにリセットします。", + "settlementConfirm2": "生徒名簿は変更されません。決済履歴は「決済履歴」ページで確認できます。", + "settlementSuccess": "決済成功、ポイントを再開しました", + "settlementFailed": "決済に失敗しました", + "settle": "決済", + "logLevels": { + "debug": "DEBUG (デバッグ)", + "info": "INFO (情報)", + "warn": "WARN (警告)", + "error": "ERROR (エラー)" + } + }, + "url": { + "title": "URLプロトコル (secscore://)", + "protocol": "URLプロトコル", + "description": "URLリンクからSecScoreを起動して操作を実行できます。例:", + "register": "URLプロトコルを登録", + "registered": "URLプロトコルが登録されました", + "registerFailed": "登録に失敗しました", + "installerRequired": "インストール版のSecScoreが必要です。開発モードでは動作しない場合があります。" + }, + "mcp": { + "title": "MCPサービス", + "description": "内蔵MCP HTTPサービスを管理。外部MCPクライアントからの呼び出しに対応。", + "running": "実行中", + "stopped": "停止", + "noUrl": "URLなし", + "host": "ホスト", + "port": "ポート", + "start": "開始", + "stop": "停止", + "refresh": "状態を更新", + "hint": "127.0.0.1のみにバインドすることを推奨。開始後、エンドポイントは http://host:port/mcp", + "hostRequired": "MCPホストを入力してください", + "portInvalid": "有効なポートを入力してください(1-65535)", + "startSuccess": "MCPサーバーが開始しました", + "startFailed": "MCPサーバーの開始に失敗しました", + "stopSuccess": "MCPサーバーが停止しました", + "stopFailed": "MCPサーバーの停止に失敗しました", + "startTimeout": "MCPサーバーの開始がタイムアウトしました。後でもう一度お試しください", + "stopTimeout": "MCPサーバーの停止がタイムアウトしました。後でもう一度お試しください" + }, + "app": { + "title": "アプリ制御", + "description": "アプリを素早く再起動または終了。", + "restart": "アプリを再起動", + "quit": "アプリを終了", + "confirmRestartTitle": "アプリを再起動しますか?", + "confirmRestartContent": "アプリはすぐに閉じて再起動します。", + "confirmQuitTitle": "アプリを終了しますか?", + "confirmQuitContent": "アプリはすぐに終了します。", + "restartFailed": "再起動に失敗しました", + "quitFailed": "終了に失敗しました" + }, + "about": { + "title": "について", + "appName": "教育ポイント管理", + "version": "バージョン", + "copyright": "著作権", + "ipcStatus": "IPC状態", + "ipcConnected": "接続済み", + "ipcDisconnected": "未接続(Preload失敗)", + "environment": "環境", + "toggleDevTools": "開発者ツールを切り替え", + "license": "SecScoreはGPL3.0ライセンスに従います", + "copyRightText": "CopyRight © 2025-{{year}} SECTL" + } + }, + "sidebar": { + "home": "ホーム", + "students": "生徒管理", + "score": "ポイント操作", + "boards": "ボード", + "leaderboard": "ランキング", + "settlements": "決済履歴", + "reasons": "理由管理", + "autoScore": "自動加点", + "rewardExchange": "報酬交換", + "rewardSettings": "報酬設定", + "settings": "設定", + "remoteDb": "リモートDB", + "refreshStatus": "状態を更新", + "syncNow": "今すぐ同期", + "syncSuccess": "同期成功", + "syncFailed": "同期失敗", + "getDbStatusFailed": "データベース状態の取得に失敗しました", + "notRemoteMode": "リモートデータベースモードではありません。アプリを再起動してください", + "dbNotConnected": "データベース未接続" + }, + "auth": { + "unlock": "権限ロック解除", + "unlockHint": "6桁のパスワードを入力:管理者パスワード=全機能、ポイントパスワード=ポイント操作のみ。", + "unlockButton": "ロック解除", + "unlocked": "権限がロック解除されました", + "logout": "読み取り専用に切り替え", + "passwordPlaceholder": "例:123456", + "passwordError": "パスワードが間違っています", + "enterPassword": "パスワードを入力", + "lock": "ロック" + }, + "languages": { + "zh-CN": "简体中文", + "en-US": "English", + "ja-JP": "日本語", + "fr-FR": "Français", + "es-ES": "Español", + "ko-KR": "한국어", + "ru-RU": "Русский", + "de-DE": "Deutsch", + "pt-BR": "Português", + "ar-SA": "العربية" + }, + "theme": { + "colorAndBackground": "色と背景", + "gradientLabels": { + "blue": "爽やかブルー", + "pink": "ソフトピンク", + "cyan": "シアン", + "purple": "パープル" + }, + "gradientDirections": { + "vertical": "縦", + "horizontal": "横", + "diagonal": "斜め" + }, + "generate": "生成", + "saved": "保存しました", + "saveFailed": "保存に失敗しました", + "mode": "モード", + "myTheme": "マイテーマ" + }, + "permissions": { + "admin": "管理者権限", + "points": "ポイント権限", + "view": "読み取り専用" + }, + "home": { + "title": "生徒ポイントホーム", + "subtitle": "{{count}} 名の生徒、カードをクリックしてポイント操作", + "searchPlaceholder": "名前/ピンインで検索...", + "sortBy": { + "alphabet": "名前順", + "surname": "姓グループ", + "group": "グループ順", + "score": "ポイント順" + }, + "layoutBy": { + "grouped": "グループリスト", + "squareGrid": "正方形グリッド" + }, + "noStudents": "生徒データがありません。生徒管理で追加してください", + "noMatch": "一致する生徒が見つかりません", + "clearSearch": "検索をクリア", + "points": "点", + "currentScore": "現在のポイント", + "quickOptions": "クイックオプション", + "adjustPoints": "ポイントを調整", + "customPoints": "カスタムポイント", + "customPointsHint": "入力ボックスで任意の値を入力できます", + "reason": "理由", + "reasonPlaceholder": "ポイント変動の理由を入力(任意)", + "preview": "変更プレビュー", + "noReason": "(理由なし)", + "ungrouped": "未グループ", + "category": { + "others": "その他" + }, + "scoreAdded": "{{name}} に {{points}} 点を加点しました", + "scoreDeducted": "{{name}} から {{points}} 点を減点しました", + "submitFailed": "送信に失敗しました", + "pleaseSelectPoints": "ポイントを選択または入力してください", + "reasonDefault": "{{action}}{{points}}点", + "studentCount": "{{count}} 名", + "operationTitle": "ポイント操作:{{name}}", + "submitOperation": "操作を送信", + "operationTitleBatch": "ポイント操作:{{count}} 名選択中", + "undoLastAction": "元に戻す", + "undoLastHint": "最新の操作を元に戻す:{{name}} {{delta}}", + "undoUnavailable": "元に戻せるポイント操作がありません", + "undoLastSuccess": "最後のポイント操作を元に戻しました", + "multiSelect": "複数選択", + "batchMode": "バッチモード", + "selectAll": "全選択", + "clearSelected": "選択をクリア", + "batchOperate": "バッチポイント", + "selected": "選択済み", + "selectedCount": "{{count}} 名選択", + "batchSuccess": "{{count}} 名の生徒にポイントを送信しました", + "batchPartial": "{{success}}/{{total}} 名の生徒に送信成功", + "selectStudentFirst": "先に生徒を選択してください", + "addPoints": "加点", + "deductPoints": "減点", + "pointsChange": "ポイント変更", + "reasonLabel": "理由:" + }, + "students": { + "title": "生徒管理", + "importList": "名簿をインポート", + "addStudent": "生徒を追加", + "name": "名前", + "group": "グループ", + "avatar": "アバター", + "currentScore": "現在のポイント", + "tags": "タグ", + "noTags": "タグなし", + "editTags": "タグを編集", + "editAvatar": "アバターを設定", + "deleteConfirm": "この生徒を削除しますか?", + "addTitle": "生徒を追加", + "addConfirm": "追加", + "namePlaceholder": "生徒名を入力", + "groupPlaceholder": "グループ名を入力(任意)", + "nameRequired": "名前を入力してください", + "nameExists": "生徒名は既に存在します", + "addSuccess": "追加成功", + "addFailed": "追加に失敗しました", + "deleteSuccess": "削除成功", + "deleteFailed": "削除に失敗しました", + "tagSaveSuccess": "タグを保存しました", + "tagSaveFailed": "タグの保存に失敗しました", + "importTitle": "名簿をインポート", + "importTextHint": "複数行のテキストを貼り付け、1行に1名前", + "importTextPlaceholder": "例:\\n田中\\n佐藤\\n鈴木", + "importByText": "テキストからインポート", + "importByXlsx": "xlsxからインポート", + "importByBanyou": "班優からインポート", + "xlsxPreview": "xlsxプレビューとインポート", + "file": "ファイル", + "selectNameCol": "ヘッダーをクリックして名前列を選択", + "previewRows": "最初の50行をプレビュー", + "importConfirm": "インポート", + "importComplete": "インポート完了:{{inserted}} 件追加、{{skipped}} 件スキップ", + "workerNotReady": "Workerが初期化されていません", + "parseXlsxFailed": "xlsxの解析に失敗しました", + "selectNameColFirst": "先に「名前列」を選択してください", + "noNamesFound": "選択した列にインポート可能な名前が見つかりません", + "importFailed": "インポートに失敗しました", + "banyouCookieHint": "班優ウェブ版にログイン後、完全なCookieをコピーして下に貼り付けてください。", + "banyouCookiePlaceholder": "uid=...; accessToken=...; ...", + "banyouCookieRequired": "先にCookieを貼り付けてください", + "banyouFetch": "クラス一覧を取得", + "banyouFetchSuccess": "{{count}} 件のクラスを取得", + "banyouFetchFailed": "班優クラスの取得に失敗しました", + "banyouCreatedClasses": "作成したクラス", + "banyouJoinedClasses": "参加したクラス", + "banyouNoClasses": "クラスデータなし", + "banyouClassTeacher": "担任:", + "banyouStudentCount": "生徒:", + "banyouPraiseCount": "表彰:", + "banyouFetchDetailFailed": "クラス詳細の取得に失敗しました", + "banyouClassDetail": "クラス詳細", + "banyouDetailEmpty": "クラス詳細データなし", + "banyouReasonList": "理由一覧", + "banyouStudentList": "生徒一覧", + "banyouTeamList": "グループ一覧", + "banyouNoTeams": "グループデータなし(teamPlanIdを入力して再度開いてみてください)", + "banyouTeamPlanSelector": "グループプラン", + "banyouLoadTeamPlan": "このプランを読み込む", + "banyouTeamPlanSource": "プランソースAPI:", + "banyouSelectTeamPlanFirst": "先にグループプランを選択してください", + "banyouImportSelected": "選択項目をインポート", + "banyouNothingSelected": "インポートする項目を少なくとも1つ選択してください", + "banyouImportSummary": "班優インポート完了:生徒 +{{studentsInserted}}/{{studentsSkipped}} スキップ、理由 +{{reasonsInserted}}/{{reasonsSkipped}} スキップ、グループ {{groupsUpdated}} 更新/{{groupsSkipped}} スキップ", + "editTagTitle": "タグを編集 - {{name}}", + "editGroup": "グループを設定", + "editGroupTitle": "グループを設定 - {{name}}", + "groupSaveSuccess": "グループを保存しました", + "groupSaveFailed": "グループの保存に失敗しました", + "existingGroups": "既存のグループ(クリックで入力)", + "noExistingGroups": "利用可能なグループがありません", + "groupBoardEdit": "グループを編集", + "groupBoardTitle": "クイックグループエディタ", + "groupBoardHint": "生徒をグループ列にドラッグして保存で一括適用。", + "groupBoardNewGroupPlaceholder": "新しいグループ名を入力", + "groupBoardAddGroup": "新しいグループ", + "groupBoardGroupExists": "グループは既に存在します", + "groupBoardGroupNameRequired": "グループ名を入力してください", + "groupBoardGroupNameInvalid": "無効なグループ名", + "groupBoardEmpty": "生徒をここにドラッグ", + "groupBoardNoChanges": "グループに変更はありません", + "groupBoardSaveSuccess": "{{count}} 名の生徒のグループを更新しました", + "groupBoardSaveFailed": "保存に失敗、{{failed}} 名の生徒が更新されませんでした", + "noGroup": "未グループ", + "editAvatarTitle": "アバターを設定 - {{name}}", + "avatarUpload": "画像をアップロード", + "avatarClear": "アバターをクリア", + "noAvatar": "アバターなし", + "avatarTip": "一般的な画像形式に対応、2MB以下", + "avatarSaveSuccess": "アバターを保存しました", + "avatarSaveFailed": "アバターの保存に失敗しました", + "avatarInvalidFile": "画像ファイルを選択してください", + "avatarTooLarge": "画像は2MB以下にしてください", + "avatarReadFailed": "画像の読み取りに失敗しました" + }, + "score": { + "title": "ポイント管理", + "student": "生徒", + "change": "変動", + "reason": "理由", + "time": "時間", + "undoConfirm": "このレコードを元に戻しますか?生徒のポイントがロールバックされます。", + "undo": "元に戻す", + "undoSuccess": "操作を元に戻しました", + "undoFailed": "元に戻すのに失敗しました", + "recentRecords": "最近のレコード", + "pleaseSelectStudent": "生徒を選択または検索してください", + "selectAllStudents": "全クラス", + "clearSelectedStudents": "クリア", + "selectedCount": "{{selected}} / {{total}} 名選択", + "points": "ポイント", + "addPoints": "加点", + "deductPoints": "減点", + "quickReason": "クイック理由", + "selectReason": "プリセット理由を選択", + "reasonContent": "理由内容", + "reasonPlaceholder": "手動入力またはクイック理由を選択", + "submit": "確認して送信", + "pleaseEnterInfo": "完全な情報を入力してください", + "pleaseEnterPoints": "ポイントを入力するかプリセット理由を選択してください", + "batchSuccess": "{{count}} 名の生徒にポイントを送信しました", + "batchPartial": "{{success}}/{{total}} 名の生徒に送信成功" + }, + "reasons": { + "title": "理由管理", + "addReason": "プリセット理由を追加", + "category": "カテゴリ", + "content": "理由内容", + "presetPoints": "プリセットポイント", + "deleteConfirm": "この理由を削除しますか?", + "addTitle": "理由を追加", + "addConfirm": "追加", + "categoryPlaceholder": "例:学習、規律", + "contentPlaceholder": "理由を入力", + "pointsPlaceholder": "例:2 または -2", + "contentRequired": "理由内容を入力してください", + "pointsRequired": "プリセットポイントを入力してください", + "reasonExists": "このカテゴリに同じ理由が既に存在します", + "addSuccess": "追加成功", + "addFailed": "追加に失敗しました", + "deleteSuccess": "削除成功", + "deleteFailed": "削除に失敗しました", + "others": "その他" + }, + "rewardExchange": { + "title": "報酬交換", + "enterMode": "交換モードに入る", + "exitMode": "交換モードを終了", + "modeHint": "報酬交換モードがアクティブです。生徒カードをクリックして利用可能な報酬を選択。", + "normalHint": "現在のカードは元のスコアを表示しています。モードに入ると交換可能ポイントが表示されます。", + "remainingRewardPoints": "残り交換ポイント", + "currentScore": "現在のスコア", + "chooseRewardTitle": "{{name}} の報酬を選択", + "currentRewardPoints": "現在の交換可能ポイント:{{points}}", + "costLabel": "消費ポイント:{{points}}", + "redeemNow": "今すぐ交換", + "noAffordableRewards": "現在交換できる報酬がありません。もっとポイントを貯めてください。", + "redeemSuccess": "{{student}} が「{{reward}}」を交換し、{{points}} ポイントを消費しました", + "redeemFailed": "交換に失敗しました", + "recordsTitle": "交換記録", + "recordStudent": "生徒", + "recordReward": "報酬", + "recordCost": "消費ポイント", + "recordTime": "交換日時" + }, + "rewardSettings": { + "title": "報酬設定", + "addReward": "報酬を追加", + "addTitle": "報酬を追加", + "editTitle": "報酬を編集", + "rewardName": "報酬名", + "costPoints": "消費ポイント", + "namePlaceholder": "例:宿題免除券", + "costPlaceholder": "消費ポイントを入力", + "nameRequired": "報酬名を入力してください", + "costRequired": "正の整数のポイントを入力してください", + "createSuccess": "報酬を作成しました", + "createFailed": "報酬の作成に失敗しました", + "updateSuccess": "報酬を更新しました", + "updateFailed": "報酬の更新に失敗しました", + "deleteSuccess": "報酬を削除しました", + "deleteFailed": "報酬の削除に失敗しました", + "deleteConfirm": "報酬「{{name}}」を削除しますか?" + }, + "board": { + "title": "ボード", + "editable": "編集可能", + "readonly": "読み取り専用", + "addBoard": "ボードを追加", + "removeBoard": "ボードを削除", + "removeBoardConfirm": "現在のボードを削除しますか?", + "boardConfig": "ボード設定", + "boardNamePlaceholder": "ボード名を入力", + "renameBoard": "ボード名を変更", + "newBoard": "新しいボード", + "untitledBoard": "無題ボード", + "addList": "生徒リストを追加", + "removeList": "リストを削除", + "removeListConfirm": "現在の生徒リストを削除しますか?", + "editList": "編集", + "sqlEditorTitle": "生徒リストを編集", + "splitHorizontal": "左右分割", + "splitVertical": "上下分割", + "dragHandle": "ドラッグしてレイアウトを調整", + "listNamePlaceholder": "リスト名を入力", + "newList": "新しいリスト", + "applyPreset": "プリセットを適用", + "run": "SQLを実行", + "runAll": "すべて実行", + "viewModes": { + "list": "リスト表示", + "card": "カード表示", + "grid": "グリッド表示" + }, + "scoreDisplayModes": { + "total": "合計スコアを表示", + "split": "加点と減点を表示" + }, + "metrics": { + "totalScore": "合計", + "addScore": "加点", + "deductScore": "減点", + "rewardPoints": "報酬", + "weekChange": "7日間", + "weekDeducted": "7日間減点", + "todayAnswered": "今日" + }, + "sqlPlaceholder": "SQLを入力(単一のSELECT / WITHクエリのみ)", + "templateHint": "テンプレート変数:{{today_start}}、{{this_week_start}}、{{last_week_start}}、{{since_7d}}、{{since_30d}}、{{now}}", + "templateDescription": "SQL内でテンプレート変数を直接使用。実行時にISOタイムスタンプに自動置換(SQLiteとPostgreSQL両対応)。", + "saving": "保存中...", + "saveFailed": "ボードの保存に失敗しました", + "runFailed": "クエリの実行に失敗しました", + "keepAtLeastOneBoard": "少なくとも1つのボードを保持してください", + "keepAtLeastOneList": "少なくとも1つの生徒リストを保持してください", + "presets": { + "weekLowDeduct": { + "name": "低減点ランキング(7日間)", + "description": "過去1週間の減点 < 3 の生徒ランキング" + }, + "todayActive": { + "name": "今日のアクティブ", + "description": "今日の回答回数とスコア変動でソート" + }, + "rewardRanking": { + "name": "報酬ポイントランキング", + "description": "報酬ポイントと合計スコアでソート" + } + } + }, + "leaderboard": { + "title": "ポイントランキング", + "rank": "順位", + "name": "名前", + "totalScore": "合計ポイント", + "todayChange": "今日の変動", + "weekChange": "今週の変動", + "monthChange": "今月の変動", + "viewHistory": "表示", + "exportXlsx": "XLSXエクスポート", + "exportSuccess": "エクスポート成功", + "queryFailed": "クエリに失敗しました", + "historyTitle": "{{name}} - 操作履歴", + "close": "閉じる", + "today": "今日", + "week": "今週", + "month": "今月", + "operationRecord": "操作記録", + "change": "変動" + }, + "settlements": { + "title": "決済履歴", + "phase": "フェーズ #{{id}}", + "viewLeaderboard": "ランキングを表示", + "recordCount": "レコード数:{{count}}", + "noRecords": "決済レコードなし", + "noSettlements": "決済レコードなし", + "back": "戻る", + "leaderboardTitle": "決済ランキング", + "phaseScore": "フェーズポイント", + "queryFailed": "クエリに失敗しました", + "rank": "順位", + "name": "名前" + }, + "autoScore": { + "title": "自動加点管理", + "name": "自動化名", + "namePlaceholder": "例:毎日チェックイン加点", + "nameRequired": "自動化名を入力してください", + "triggers": "トリガー", + "actions": "アクション", + "applicableStudents": "対象生徒", + "allStudents": "全生徒", + "lastExecuted": "最終実行", + "notExecuted": "未実行", + "invalidTime": "無効な時間", + "addTrigger": "ルールを追加", + "addAction": "アクションを追加", + "whenTriggered": "以下のルールがトリガーされた時", + "triggeredActions": "ルールが満たされた時にトリガーされるアクション", + "addAutomation": "自動化を追加", + "updateAutomation": "自動化を更新", + "cancelEdit": "編集をキャンセル", + "resetForm": "フォームをリセット", + "deleteConfirm": "この自動化を削除しますか?", + "adminRequired": "自動加点の表示には管理者権限が必要です", + "adminCreateRequired": "自動加点の作成・更新には管理者権限が必要です", + "adminDeleteRequired": "自動加点の削除には管理者権限が必要です", + "adminToggleRequired": "自動加点の有効化/無効化には管理者権限が必要です", + "fetchFailed": "自動化の取得に失敗しました", + "unsupportedLogic": "現在の保存はAND条件のみ対応。OR / NOT論理はバックエンド未対応", + "triggerRequired": "少なくとも1つのトリガーを追加してください", + "actionRequired": "少なくとも1つのアクションを追加してください", + "createSuccess": "自動化を作成しました", + "updateSuccess": "自動化を更新しました", + "createFailed": "自動化の作成に失敗しました", + "updateFailed": "自動化の更新に失敗しました", + "deleteSuccess": "自動化を削除しました", + "deleteFailed": "自動化の削除に失敗しました", + "enabled": "自動化が有効になりました", + "disabled": "自動化が無効になりました", + "enableFailed": "自動化の有効化に失敗しました", + "disableFailed": "自動化の無効化に失敗しました", + "noTriggerAvailable": "利用可能なトリガータイプがありません", + "noActionAvailable": "利用可能なアクションタイプがありません", + "sort": "並べ替え", + "status": "状態", + "triggerCount": "{{count}} 件のトリガー", + "actionCount": "{{count}} 件のアクション", + "studentCount": "{{count}} 名の生徒", + "studentPlaceholder": "生徒を選択または検索(空欄で全生徒)", + "triggerCondition": "トリガー条件", + "executeAction": "アクションを実行", + "addOperation": "操作を追加", + "scoreLabel": "スコア", + "tagNameLabel": "タグ名", + "operationNoteLabel": "操作メモ(任意)", + "triggerIntervalTime": "間隔時間", + "triggerStudentTag": "生徒タグ", + "actionAddScore": "加点", + "actionAddTag": "タグを追加", + "intervalAmountPlaceholder": "間隔数を入力", + "intervalUnitMonth": "ヶ月後", + "intervalUnitDay": "日後", + "intervalUnitMinute": "分後", + "minutesPlaceholder": "分数を入力", + "minutes": "分", + "tagsPlaceholder": "タグ名を入力", + "scorePlaceholder": "スコアを入力", + "tagNamePlaceholder": "タグ名を入力", + "reasonPlaceholder": "理由を入力", + "relationAnd": "かつ", + "relationOr": "または" + }, + "triggers": { + "studentTag": { + "label": "生徒タグ", + "description": "生徒が特定のタグに一致した時にトリガー", + "placeholder": "タグを選択", + "required": "タグ名を入力してください" + }, + "randomTime": { + "label": "ランダム時間トリガー", + "description": "指定時間範囲内でランダムにトリガー", + "required": "有効な時間範囲(分)を入力してください" + }, + "intervalTime": { + "label": "間隔時間トリガー", + "description": "間隔時間に達した時にトリガー", + "placeholder": "時間間隔を入力", + "required": "有効な時間を入力してください", + "monthLabel": "月", + "weekLabel": "週", + "timeLabel": "時間" + } + }, + "actions": { + "sendNotification": { + "label": "通知を送信", + "description": "生徒に通知を送信", + "placeholder": "通知内容を入力" + }, + "addTag": { + "label": "タグを追加", + "description": "生徒にタグを追加", + "placeholder": "タグを入力" + }, + "addScore": { + "label": "スコアを追加", + "description": "生徒にスコアを追加", + "pointsPlaceholder": "スコアを入力", + "reasonPlaceholder": "理由を入力" + } + }, + "wizard": { + "welcomeTitle": "SecScore ポイント管理へようこそ", + "welcomeDesc": "SecScoreを選択いただきありがとうございます。開始前に、基本設定を1分で完了してください。", + "configComplete": "設定完了!", + "configFailed": "設定の保存に失敗しました", + "startJourney": "ポイント管理を始める" + }, + "tags": { + "editTitle": "タグを編集", + "inputPlaceholder": "タグ名を入力、Enterで追加", + "selectedTags": "選択済みタグ(クリックで解除)", + "noTagsSelected": "タグ未選択", + "availableTags": "利用可能なタグ(クリックで選択)", + "noAvailableTags": "利用可能なタグなし", + "fetchFailed": "タグの取得に失敗しました", + "nameTooLong": "タグ名は30文字以内にしてください", + "addFailed": "タグの追加に失敗しました", + "deleteSuccess": "タグを削除しました", + "deleteFailed": "タグの削除に失敗しました" + }, + "recovery": { + "title": "SecScore 復旧文字列", + "saved": "保存しました", + "export": "テキストファイルをエクスポート", + "exportHint": "エクスポート後にオフライン保存を推奨。紛失すると復旧できません。", + "hint": "エクスポート後にオフライン保存を推奨。紛失すると復旧できません。" + } +} diff --git a/src/i18n/locales/ko-KR.json b/src/i18n/locales/ko-KR.json new file mode 100644 index 0000000..1cfdb4d --- /dev/null +++ b/src/i18n/locales/ko-KR.json @@ -0,0 +1,668 @@ +{ + "common": { + "next": "다음", + "prev": "이전", + "finish": "완료", + "cancel": "취소", + "save": "저장", + "loading": "로딩 중...", + "confirm": "확인", + "success": "성공", + "error": "오류", + "delete": "삭제", + "edit": "편집", + "add": "추가", + "search": "검색", + "clear": "지우기", + "close": "닫기", + "yes": "예", + "no": "아니요", + "submit": "제출", + "reset": "초기화", + "import": "가져오기", + "export": "내보내기", + "create": "생성", + "update": "업데이트", + "refresh": "새로 고침", + "view": "보기", + "name": "이름", + "status": "상태", + "operation": "작업", + "description": "설명", + "total": "총 {{count}}개 항목", + "noData": "데이터 없음", + "pleaseSelect": "선택해 주세요", + "pleaseEnter": "입력해 주세요", + "readOnly": "읽기 전용 모드", + "none": "없음", + "more": "더 보기" + }, + "oobe": { + "title": "SecScore에 오신 것을 환영합니다", + "subtitle": "교육 포인트 관리 전문가", + "step": "단계 {{current}}/{{total}}", + "skip": "건너뛰기", + "steps": { + "entry": { + "title": "시작하기", + "description": "진행할 절차를 선택하세요", + "enterOobe": "OOBE에 입장", + "connectPostgresAutoSync": "PostgreSQL에 자동 동기화로 연결", + "skipDirect": "OOBE 건너뛰고 바로 입장" + }, + "postgresql": { + "title": "PostgreSQL 연결", + "description": "PostgreSQL 연결 문자열을 입력하세요. 시스템이 자동으로 연결되고 동기화됩니다.", + "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", + "autoSyncAndEnter": "자동 연결 및 동기화 후 입장" + }, + "language": { + "title": "언어 선택", + "description": "선호하는 언어를 선택해 주세요" + }, + "theme": { + "title": "테마 설정", + "description": "인터페이스 모양을 선택하세요", + "mode": "모드", + "lightMode": "라이트", + "darkMode": "다크", + "primaryColor": "주요 색상", + "backgroundGradient": "배경 그라데이션" + }, + "password": { + "title": "비밀번호 설정", + "description": "데이터를 보호할 로그인 비밀번호를 설정하세요 (선택 사항)", + "adminPassword": "관리자 비밀번호", + "adminPasswordHint": "모든 기능 접근 가능, 6자리 숫자", + "pointsPassword": "포인트 비밀번호", + "pointsPasswordHint": "포인트 작업만 가능, 6자리 숫자", + "passwordPlaceholder": "6자리 숫자 입력", + "hint": "비워두면 건너뛰기, 나중에 설정에서 구성 가능" + }, + "students": { + "title": "학생 목록 가져오기", + "description": "학생 목록을 추가하여 포인트 관리를 시작하세요", + "import": "목록 가져오기", + "manual": "수동 추가", + "importHint": "Excel (.xlsx) 또는 JSON 파일 지원", + "manualHint": "여러 줄을 붙여넣기, 한 줄에 하나의 학생 이름", + "dragDrop": "여기에 파일을 드래그 앤 드롭하거나 클릭하여 선택", + "supportedFormats": "지원되는 형식: .xlsx, .json", + "studentName": "학생 이름", + "addStudent": "학생 추가", + "noStudents": "학생이 없습니다. 추가하거나 가져와 주세요", + "studentCount": "{{count}}명의 학생이 추가되었습니다", + "studentExists": "학생이 이미 존재합니다", + "importSuccess": "{{count}}명의 학생을 성공적으로 가져왔습니다", + "noNewStudents": "가져올 새로운 학생이 없습니다", + "parseFailed": "파일 분석 실패", + "unsupportedFormat": "지원되지 않는 파일 형식" + }, + "reasons": { + "title": "이유 설정", + "description": "일반적인 포인트 변경 이유를 추가하세요", + "addReason": "이유 추가", + "reasonName": "이유 이름", + "points": "포인트", + "positive": "포인트 추가", + "negative": "포인트 차감", + "noReasons": "이유가 없습니다. 일반적인 이유를 추가해 주세요", + "reasonCount": "{{count}}개의 이유가 추가되었습니다", + "reasonExists": "이유가 이미 존재합니다" + }, + "start": { + "title": "사용 준비 완료", + "description": "모든 준비가 완료되었습니다. SecScore를 사용해 봅시다!", + "features": { + "score": "학생 포인트를 쉽게 관리하세요", + "history": "완전한 포인트 변경 이력", + "settlement": "결제 및 보관 지원" + }, + "startButton": "시작하기" + } + } + }, + "settings": { + "title": "시스템 설정", + "tabs": { + "appearance": "모양새", + "security": "보안", + "account": "계정", + "database": "데이터베이스 연결", + "dataManagement": "데이터 관리", + "urlProtocol": "URL 프로토콜", + "about": "정보" + }, + "language": "언어", + "languageHint": "인터페이스 표시 언어 선택", + "fontFamily": "글로벌 글꼴", + "fontFamilyHint": "인터페이스 글꼴 선택 (새로 고침 필요)", + "theme": "테마", + "password": "비밀번호 설정", + "themeMode": "모드", + "lightMode": "라이트", + "darkMode": "다크", + "primaryColor": "주요 색상", + "backgroundGradient": "배경 그라데이션", + "searchKeyboard": { + "title": "검색 키보드", + "hint": "검색창 아래에 표시할 키보드 레이아웃 선택", + "disableToggle": "검색 키보드 비활성화", + "disableHint": "키보드가 검색창 아래에 표시되지 않습니다", + "options": { + "qwerty26": "26자 (QWERTY)", + "t9": "9자 (T9)" + } + }, + "interfaceZoom": "인터페이스 확대/축소", + "zoomOptions": { + "small70": "작게 (70%)", + "default100": "기본 (100%)", + "large150": "크게 (150%)" + }, + "general": { + "saved": "저장됨", + "saveFailed": "저장 실패" + }, + "zoomHint": "인터페이스 전체 크기 조정", + "mobile": { + "navigationTitle": "페이지 진입", + "bottomNav": { + "label": "하단 네비게이션 기능", + "hint": "모바일 하단 바에 표시할 기능을 선택하세요. 바는 최대 4개 항목을 표시하며, 나머지는 «더보기»로 이동합니다." + }, + "dragHint": "드래그하여 재정렬. 처음 4개 항목이 하단 바에 표시되고, 나머지는 «더보기»에 있습니다.", + "dropHere": "여기에 놓으세요", + "bottomSection": "하단 바 (최대 4개)", + "moreSection": "더보기", + "moveToMore": "더보기로 이동", + "moveToBottom": "하단으로 이동" + }, + "zoomUpdated": "인터페이스 확대/축소가 업데이트되었습니다", + "updateFailed": "업데이트 실패", + "security": { + "title": "비밀번호 보호 시스템", + "adminPassword": "관리자 비밀번호", + "pointsPassword": "포인트 비밀번호", + "recoveryString": "복구 문자열", + "set": "설정됨", + "notSet": "설정되지 않음", + "generated": "생성됨", + "notGenerated": "생성되지 않음", + "enterPassword": "6자리 숫자 입력 (비워두면 수정하지 않음)", + "adminPasswordPlaceholder": "6자리 관리자 비밀번호 입력 (비워두면 수정하지 않음)", + "pointsPasswordPlaceholder": "6자리 포인트 비밀번호 입력 (비워두면 수정하지 않음)", + "recoveryPlaceholder": "복구 문자열 입력", + "savePassword": "비밀번호 저장", + "savePasswords": "비밀번호 저장", + "generateRecovery": "복구 문자열 생성", + "clearAllPasswords": "모든 비밀번호 지우기", + "recoveryReset": "복구 문자열 초기화", + "enterRecovery": "복구 문자열 입력", + "resetPassword": "비밀번호 초기화", + "resetHint": "초기화하면 관리자 및 포인트 비밀번호가 지워지고, 새로운 복구 문자열이 생성됩니다.", + "confirmClear": "모든 비밀번호를 지우시겠습니까?", + "clearHint": "지운 후 보호가 비활성화됩니다 (비밀번호 없음 = 기본 관리자 권한).", + "saved": "비밀번호가 업데이트되었습니다", + "saveFailed": "업데이트 실패", + "generateFailed": "생성 실패", + "resetFailed": "초기화 실패", + "cleared": "지워짐", + "clearFailed": "지우기 실패", + "passwordClearedNewRecovery": "비밀번호가 지워졌습니다. 새로운 복구 문자열", + "newRecoveryString": "새로운 복구 문자열 (저장해 주세요)" + }, + "account": { + "title": "SECTL Auth 계정", + "notLoggedIn": "SECTL Auth에 로그인하지 않았습니다", + "oauthHint": "SECTL Auth 계정으로 로그인하여 통합 인증 및 원격 로그아웃을 사용하세요", + "loginButton": "SECTL Auth로 로그인", + "loginSuccess": "로그인 성공", + "logout": "로그아웃", + "userId": "사용자 ID", + "permission": "권한 수준" + }, + "database": { + "title": "데이터베이스 연결", + "currentStatus": "현재 데이터베이스 상태", + "sqliteLocal": "SQLite 로컬 데이터베이스", + "postgresqlRemote": "PostgreSQL 원격 데이터베이스", + "connected": "연결됨", + "disconnected": "연결되지 않음", + "postgresqlConnection": "PostgreSQL 원격 연결", + "connectionHint": "PostgreSQL 연결 문자열을 입력하여 원격 데이터베이스에 연결하세요. 멀티 플랫폼 동기화를 지원합니다.", + "testConnection": "연결 테스트", + "switchToPostgreSQL": "PostgreSQL로 전환", + "switchToSQLite": "SQLite 로컬로 전환", + "connectionTestSuccess": "연결 테스트 성공", + "connectionTestFailed": "연결 테스트 실패", + "switchedTo": "{{type}} 데이터베이스로 전환되었습니다", + "switchedToSQLite": "SQLite 로컬 데이터베이스로 전환되었습니다", + "switchFailed": "전환 실패", + "syncDescription": "멀티 플랫폼 동기화 지침", + "syncPoint1": "PostgreSQL 원격 데이터베이스 사용 시 멀티 플랫폼 데이터 동기화가 가능합니다.", + "syncPoint2": "내장된 작업 큐 메커니즘으로 동시 작업 시 데이터 일관성을 보장합니다.", + "syncPoint3": "데이터베이스 전환 후 애플리케이션을 재시작해야 합니다.", + "syncPoint4": "추천 클라우드 데이터베이스 서비스: Neon, Supabase, AWS RDS 등", + "enterConnectionString": "PostgreSQL 연결 문자열을 입력해 주세요", + "connectionExample": "예: postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", + "uploadButton": "로컬 데이터를 원격 서버에 업로드", + "uploadHint": "원격 데이터베이스에 연결한 후, 로컬 SQLite 이력을 원격 서버에 업로드하고 병합하세요.", + "uploadNeedRemote": "먼저 PostgreSQL 원격 데이터베이스로 전환하고 연결해 주세요", + "noNeedUpload": "로컬 및 원격 데이터가 이미 일치합니다", + "uploadConfirmTitle": "로컬 데이터를 원격 서버에 업로드하시겠습니까?", + "uploadConfirmContent": "동기화는 로컬 데이터를 우선시합니다. 감지됨: 로컬 전용 {{localOnly}}, 원격 전용 {{remoteOnly}}, 충돌 {{conflicts}}.", + "uploadSuccess": "로컬 데이터가 원격 서버에 업로드되었습니다", + "uploadFailed": "업로드 실패" + }, + "data": { + "title": "데이터 관리", + "settlement": "결제", + "settlementAndRestart": "결제 및 재시작", + "settlementHint": "미결제 레코드를 한 단계로 분류하고 모든 학생 포인트를 0으로 재설정합니다.", + "importExport": "가져오기 / 내보내기", + "exportJson": "JSON 내보내기", + "importJson": "JSON 가져오기", + "importHint": "가져오기는 기존 학생/이유/레코드/설정을 덮어씁니다 (보안 설정은 제외).", + "logs": "로그", + "logLevel": "로그 수준", + "logOperation": "로그 작업", + "viewLogs": "로그 보기", + "exportLogs": "로그 내보내기", + "clearLogs": "로그 지우기", + "logsCleared": "로그가 지워졌습니다", + "systemLogs": "시스템 로그 (최근 200개)", + "noLogs": "로그 없음", + "logLevelUpdated": "로그 수준이 업데이트되었습니다", + "readLogsFailed": "로그 읽기 실패", + "logsExported": "로그가 내보내졌습니다", + "exportFailed": "내보내기 실패", + "exportSuccess": "내보내기 성공", + "importSuccess": "가져오기 성공, 새로 고치는 중", + "importFailed": "가져오기 실패", + "clearFailed": "지우기 실패", + "confirmSettlement": "결제 및 재시작을 확인하시겠습니까?", + "settlementConfirm1": "미결제 레코드를 한 단계로 보관하고 모든 학생 포인트를 0으로 재설정합니다.", + "settlementConfirm2": "학생 목록은 변경되지 않습니다. 결제 이력은 «결제 이력» 페이지에서 조회할 수 있습니다.", + "settlementSuccess": "결제 성공, 포인트가 재시작되었습니다", + "settlementFailed": "결제 실패", + "settle": "결제", + "logLevels": { + "debug": "DEBUG (디버그)", + "info": "INFO (정보)", + "warn": "WARN (경고)", + "error": "ERROR (오류)" + } + }, + "url": { + "title": "URL 프로토콜 (secscore://)", + "protocol": "URL 프로토콜", + "description": "URL을 통해 SecScore를 호출하여 작업을 수행할 수 있습니다. 예:", + "register": "URL 프로토콜 등록", + "registered": "URL 프로토콜이 등록되었습니다", + "registerFailed": "등록 실패", + "installerRequired": "설치된 SecScore 버전이 필요합니다. 개발 모드에서는 작동하지 않을 수 있습니다." + }, + "mcp": { + "title": "MCP 서비스", + "description": "외부 MCP 클라이언트를 위한 내장 MCP HTTP 서비스를 관리하세요.", + "running": "실행 중", + "stopped": "중지됨", + "noUrl": "URL 없음", + "host": "호스트", + "port": "포트", + "start": "시작", + "stop": "중지", + "refresh": "상태 새로 고침", + "hint": "127.0.0.1에만 바인딩하는 것이 좋습니다. 시작 후 엔드포인트는 http://host:port/mcp입니다", + "hostRequired": "MCP 호스트를 입력해 주세요", + "portInvalid": "유효한 포트를 입력해 주세요 (1-65535)", + "startSuccess": "MCP 서버가 시작되었습니다", + "startFailed": "MCP 서버 시작 실패", + "stopSuccess": "MCP 서버가 중지되었습니다", + "stopFailed": "MCP 서버 중지 실패", + "startTimeout": "MCP 서버 시작 시간 초과, 다시 시도해 주세요", + "stopTimeout": "MCP 서버 중지 시간 초과, 다시 시도해 주세요" + }, + "app": { + "title": "애플리케이션 제어", + "description": "애플리케이션을 빠르게 재시작하거나 종료하세요.", + "restart": "애플리케이션 재시작", + "quit": "애플리케이션 종료", + "confirmRestartTitle": "애플리케이션을 재시작하시겠습니까?", + "confirmRestartContent": "애플리케이션이 즉시 닫히고 재시작됩니다.", + "confirmQuitTitle": "애플리케이션을 종료하시겠습니까?", + "confirmQuitContent": "애플리케이션이 즉시 종료됩니다.", + "restartFailed": "재시작 실패", + "quitFailed": "종료 실패" + }, + "about": { + "title": "정보", + "appName": "교육 포인트 관리", + "version": "버전", + "copyright": "저작권", + "ipcStatus": "IPC 상태", + "ipcConnected": "연결됨", + "ipcDisconnected": "연결되지 않음 (사전 로드 실패)", + "environment": "환경", + "toggleDevTools": "개발자 도구 전환", + "license": "SecScore는 GPL3.0 라이선스를 따릅니다", + "copyRightText": "CopyRight © 2025-{{year}} SECTL" + } + }, + "sidebar": { + "home": "홈", + "students": "학생 관리", + "score": "포인트 작업", + "boards": "보드", + "leaderboard": "순위표", + "settlements": "결제 이력", + "reasons": "이유 관리", + "autoScore": "자동 포인트 추가", + "rewardExchange": "보상 교환", + "rewardSettings": "보상 설정", + "settings": "시스템 설정", + "remoteDb": "원격 데이터베이스", + "refreshStatus": "상태 새로 고침", + "syncNow": "지금 동기화", + "syncSuccess": "동기화 성공", + "syncFailed": "동기화 실패", + "getDbStatusFailed": "데이터베이스 상태 가져오기 실패", + "notRemoteMode": "원격 데이터베이스 모드가 활성화되지 않았습니다. 애플리케이션을 재시작해 주세요", + "dbNotConnected": "데이터베이스가 연결되지 않았습니다" + }, + "auth": { + "unlock": "권한 잠금 해제", + "unlockHint": "6자리 비밀번호를 입력하세요: 관리자 비밀번호 = 전체 접근, 포인트 비밀번호 = 포인트 작업만 가능.", + "unlockButton": "잠금 해제", + "unlocked": "권한이 잠금 해제되었습니다", + "logout": "읽기 전용으로 전환되었습니다", + "passwordPlaceholder": "예: 123456", + "passwordError": "비밀번호가 잘못되었습니다", + "enterPassword": "비밀번호 입력", + "lock": "잠그기" + }, + "languages": { + "zh-CN": "简体中文", + "en-US": "English", + "ja-JP": "日本語", + "fr-FR": "Français", + "es-ES": "Español", + "ko-KR": "한국어", + "ru-RU": "Русский", + "de-DE": "Deutsch", + "pt-BR": "Português", + "ar-SA": "العربية" + }, + "theme": { + "colorAndBackground": "색상 및 배경", + "gradientLabels": { + "blue": "청량한 파랑", + "pink": "부드러운 핑크", + "cyan": "청록", + "purple": "보라" + }, + "gradientDirections": { + "vertical": "세로", + "horizontal": "가로", + "diagonal": "대각선" + }, + "generate": "생성", + "saved": "저장됨", + "saveFailed": "저장 실패", + "mode": "모드", + "myTheme": "내 테마" + }, + "permissions": { + "admin": "관리자 권한", + "points": "포인트 권한", + "view": "읽기 전용" + }, + "home": { + "title": "학생 포인트 홈", + "subtitle": "총 {{count}}명의 학생, 카드를 클릭하여 작업 수행", + "searchPlaceholder": "이름/병음을 검색...", + "sortBy": { + "alphabet": "이름순 정렬", + "surname": "성별 그룹", + "group": "그룹순 정렬", + "score": "포인트 순위" + }, + "layoutBy": { + "grouped": "그룹화된 목록", + "squareGrid": "정사각형 그리드" + }, + "noStudents": "학생 데이터가 없습니다. 학생 관리에서 추가해 주세요", + "noMatch": "일치하는 학생이 없습니다", + "clearSearch": "검색 지우기", + "points": "포인트", + "currentScore": "현재 포인트", + "quickOptions": "빠른 옵션", + "adjustPoints": "포인트 조정", + "customPoints": "사용자 정의 포인트", + "customPointsHint": "입력란에 원하는 값을 입력하세요", + "reason": "이유", + "reasonPlaceholder": "포인트 변경 이유 입력 (선택 사항)", + "preview": "변경 미리보기", + "noReason": "(이유 없음)", + "ungrouped": "그룹화되지 않음", + "category": { + "others": "기타" + }, + "scoreAdded": "{{name}}에게 {{points}}포인트 추가됨", + "scoreDeducted": "{{name}}에게서 {{points}}포인트 차감됨", + "submitFailed": "제출 실패", + "pleaseSelectPoints": "포인트를 선택하거나 입력해 주세요", + "reasonDefault": "{{action}} {{points}} 포인트", + "studentCount": "{{count}}명의 학생", + "operationTitle": "포인트 작업: {{name}}", + "submitOperation": "작업 제출", + "operationTitleBatch": "포인트 작업: {{count}}개 선택됨", + "undoLastAction": "실행 취소", + "undoLastHint": "마지막 작업 실행 취소: {{name}} {{delta}}", + "undoUnavailable": "실행 취소할 포인트 작업이 없습니다", + "undoLastSuccess": "마지막 포인트 작업이 실행 취소되었습니다", + "multiSelect": "다중 선택", + "batchMode": "배치 모드", + "selectAll": "모두 선택", + "clearSelected": "선택 지우기", + "batchOperate": "배치 포인트", + "selected": "선택됨", + "selectedCount": "{{count}}개 선택됨", + "batchSuccess": "{{count}}명의 학생 포인트가 제출되었습니다", + "batchPartial": "{{success}}/{{total}}명의 학생이 성공적으로 제출되었습니다", + "selectStudentFirst": "먼저 하나 이상의 학생을 선택해 주세요", + "addPoints": "포인트 추가", + "deductPoints": "포인트 차감", + "pointsChange": "포인트 변경", + "reasonLabel": "이유: " + }, + "students": { + "title": "학생 관리", + "importList": "목록 가져오기", + "addStudent": "학생 추가", + "name": "이름", + "group": "그룹", + "avatar": "아바타", + "currentScore": "현재 포인트", + "tags": "태그", + "noTags": "태그 없음", + "editTags": "태그 편집", + "editAvatar": "아바타 설정", + "deleteConfirm": "이 학생을 삭제하시겠습니까?", + "addTitle": "학생 추가", + "addConfirm": "추가", + "namePlaceholder": "학생 이름 입력", + "groupPlaceholder": "그룹 이름 입력 (선택 사항)", + "nameRequired": "이름을 입력해 주세요", + "nameExists": "학생 이름이 이미 존재합니다", + "addSuccess": "추가 성공", + "addFailed": "추가 실패", + "deleteSuccess": "삭제 성공", + "deleteFailed": "삭제 실패", + "tagSaveSuccess": "태그 저장 성공", + "tagSaveFailed": "태그 저장 실패", + "importTitle": "목록 가져오기", + "importTextHint": "여러 줄을 붙여넣기, 한 줄에 하나의 학생 이름", + "importTextPlaceholder": "예:\n홍길동\n김철수\n이영희", + "importByText": "텍스트에서 가져오기", + "importByXlsx": "xlsx로 가져오기", + "importByBanyou": "반유에서 가져오기", + "xlsxPreview": "xlsx 미리보기 및 가져오기", + "file": "파일", + "selectNameCol": "헤더를 클릭하여 이름 열 선택", + "previewRows": "처음 50줄 미리보기", + "importConfirm": "가져오기", + "importComplete": "가져오기 완료: {{inserted}}개 추가, {{skipped}}개 건너뜀", + "workerNotReady": "워커가 초기화되지 않았습니다", + "parseXlsxFailed": "xlsx 분석 실패", + "selectNameColFirst": "먼저 «이름 열»을 선택해 주세요", + "noNamesFound": "선택한 열에 가져올 수 있는 이름이 없습니다", + "importFailed": "가져오기 실패", + "banyouCookieHint": "반유 웹에 로그인한 후, 전체 쿠키를 복사하여 아래에 붙여넣어 주세요.", + "banyouCookiePlaceholder": "uid=...; accessToken=...; ...", + "banyouCookieRequired": "먼저 쿠키를 붙여넣어 주세요", + "banyouFetch": "클래스 목록 가져오기", + "banyouFetchSuccess": "{{count}}개의 클래스를 가져왔습니다", + "banyouFetchFailed": "반유 클래스 가져오기 실패", + "banyouCreatedClasses": "내가 만든 클래스", + "banyouJoinedClasses": "내가 가입한 클래스", + "banyouNoClasses": "클래스 데이터가 없습니다" + }, + "boards": { + "title": "보드 관리", + "addBoard": "보드 추가", + "editBoard": "보드 편집", + "deleteBoard": "보드 삭제", + "name": "보드 이름", + "description": "설명", + "visibility": "가시성", + "public": "공개", + "private": "비공개", + "studentsCount": "{{count}}명의 학생", + "noBoards": "보드가 없습니다", + "addTitle": "보드 추가", + "editTitle": "보드 편집", + "namePlaceholder": "보드 이름 입력", + "descriptionPlaceholder": "설명 입력", + "confirmDelete": "이 보드를 삭제하시겠습니까?", + "saved": "저장됨", + "saveFailed": "저장 실패", + "deleted": "삭제됨", + "deleteFailed": "삭제 실패", + "boardExists": "보드가 이미 존재합니다" + }, + "leaderboard": { + "title": "순위표", + "sortBy": "정렬 기준", + "score": "포인트", + "name": "이름", + "rank": "순위", + "student": "학생", + "points": "포인트", + "noData": "순위 데이터가 없습니다", + "topStudents": "최고의 학생들", + "rankChanged": "순위: {{rank}}" + }, + "settlements": { + "title": "결제 이력", + "phase": "단계", + "date": "날짜", + "studentCount": "학생 수", + "totalPoints": "총 포인트", + "actions": "작업", + "viewDetails": "세부 정보 보기", + "noSettlements": "결제가 없습니다", + "phaseDetail": "단계 {{phase}} 세부 정보", + "settlementDate": "결제 날짜: {{date}}", + "totalStudents": "총 학생 수: {{count}}", + "totalPointsChange": "총 포인트 변경: {{points}}" + }, + "reasons": { + "title": "이유 관리", + "addReason": "이유 추가", + "editReason": "이유 편집", + "name": "이유 이름", + "points": "포인트", + "positive": "긍정", + "negative": "부정", + "noReasons": "이유가 없습니다", + "addTitle": "이유 추가", + "editTitle": "이유 편집", + "namePlaceholder": "이유 이름 입력", + "pointsPlaceholder": "포인트 입력", + "confirmDelete": "이 이유를 삭제하시겠습니까?", + "saved": "저장됨", + "saveFailed": "저장 실패", + "deleted": "삭제됨", + "deleteFailed": "삭제 실패", + "reasonExists": "이유가 이미 존재합니다" + }, + "autoScore": { + "title": "자동 포인트 추가", + "addRule": "규칙 추가", + "editRule": "규칙 편집", + "deleteRule": "규칙 삭제", + "ruleName": "규칙 이름", + "trigger": "트리거", + "action": "작업", + "interval": "간격", + "enabled": "활성화", + "noRules": "규칙이 없습니다", + "addTitle": "규칙 추가", + "editTitle": "규칙 편집", + "namePlaceholder": "규칙 이름 입력", + "triggerPlaceholder": "트리거 선택", + "actionPlaceholder": "작업 선택", + "intervalPlaceholder": "간격 선택", + "confirmDelete": "이 규칙을 삭제하시겠습니까?", + "saved": "저장됨", + "saveFailed": "저장 실패", + "deleted": "삭제됨", + "deleteFailed": "삭제 실패", + "ruleExists": "규칙이 이미 존재합니다" + }, + "rewardExchange": { + "title": "보상 교환", + "rewards": "보상", + "exchange": "교환", + "history": "이력", + "noRewards": "보상이 없습니다", + "noHistory": "이력이 없습니다", + "rewardName": "보상 이름", + "cost": "비용", + "stock": "재고", + "exchangeSuccess": "교환 성공", + "exchangeFailed": "교환 실패", + "insufficientPoints": "포인트 부족", + "outOfStock": "재고 없음" + }, + "rewardSettings": { + "title": "보상 설정", + "addReward": "보상 추가", + "editReward": "보상 편집", + "deleteReward": "보상 삭제", + "name": "이름", + "cost": "비용", + "stock": "재고", + "noRewards": "보상이 없습니다", + "addTitle": "보상 추가", + "editTitle": "보상 편집", + "namePlaceholder": "이름 입력", + "costPlaceholder": "비용 입력", + "stockPlaceholder": "재고 입력", + "confirmDelete": "이 보상을 삭제하시겠습니까?", + "saved": "저장됨", + "saveFailed": "저장 실패", + "deleted": "삭제됨", + "deleteFailed": "삭제 실패", + "rewardExists": "보상이 이미 존재합니다" + }, + "recovery": { + "title": "SecScore 복구 문자열", + "saved": "저장했습니다", + "export": "텍스트 파일 내보내기", + "exportHint": "오프라인으로 저장하는 것이 좋습니다. 분실 시 복구할 수 없습니다.", + "hint": "내보낸 후 오프라인으로 저장하는 것이 좋습니다. 분실 시 복구할 수 없습니다." + } +} diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json new file mode 100644 index 0000000..8fea588 --- /dev/null +++ b/src/i18n/locales/pt-BR.json @@ -0,0 +1,668 @@ +{ + "common": { + "next": "Próximo", + "prev": "Anterior", + "finish": "Concluir", + "cancel": "Cancelar", + "save": "Salvar", + "loading": "Carregando...", + "confirm": "Confirmar", + "success": "Sucesso", + "error": "Erro", + "delete": "Excluir", + "edit": "Editar", + "add": "Adicionar", + "search": "Buscar", + "clear": "Limpar", + "close": "Fechar", + "yes": "Sim", + "no": "Não", + "submit": "Enviar", + "reset": "Redefinir", + "import": "Importar", + "export": "Exportar", + "create": "Criar", + "update": "Atualizar", + "refresh": "Atualizar", + "view": "Ver", + "name": "Nome", + "status": "Status", + "operation": "Operação", + "description": "Descrição", + "total": "Total {{count}} itens", + "noData": "Sem dados", + "pleaseSelect": "Por favor, selecione", + "pleaseEnter": "Por favor, insira", + "readOnly": "Modo somente leitura", + "none": "Nenhum", + "more": "Mais" + }, + "oobe": { + "title": "Bem-vindo ao SecScore", + "subtitle": "Especialista em Gestão de Pontos Educacionais", + "step": "Passo {{current}}/{{total}}", + "skip": "Pular", + "steps": { + "entry": { + "title": "Começar", + "description": "Escolha o procedimento a ser realizado", + "enterOobe": "Entrar no OOBE", + "connectPostgresAutoSync": "Conectar PostgreSQL com sincronização automática", + "skipDirect": "Pular OOBE e entrar diretamente" + }, + "postgresql": { + "title": "Conectar PostgreSQL", + "description": "Insira a string de conexão PostgreSQL. O sistema se conectará e sincronizará automaticamente.", + "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", + "autoSyncAndEnter": "Conectar e sincronizar automaticamente" + }, + "language": { + "title": "Escolher idioma", + "description": "Por favor, escolha seu idioma preferido" + }, + "theme": { + "title": "Configurar tema", + "description": "Escolha a aparência da interface", + "mode": "Modo", + "lightMode": "Claro", + "darkMode": "Escuro", + "primaryColor": "Cor principal", + "backgroundGradient": "Gradiente de fundo" + }, + "password": { + "title": "Configurar senha", + "description": "Defina uma senha para proteger seus dados (opcional)", + "adminPassword": "Senha de administrador", + "adminPasswordHint": "Acesso a todas as funções, 6 dígitos", + "pointsPassword": "Senha de pontos", + "pointsPasswordHint": "Apenas operações de pontos, 6 dígitos", + "passwordPlaceholder": "Insira 6 dígitos", + "hint": "Deixe em branco para pular, configure posteriormente nas configurações" + }, + "students": { + "title": "Importar lista de alunos", + "description": "Adicione uma lista de alunos para começar a gerenciar pontos", + "import": "Importar lista", + "manual": "Adicionar manualmente", + "importHint": "Suporta arquivos Excel (.xlsx) ou JSON", + "manualHint": "Cole várias linhas, um nome de aluno por linha", + "dragDrop": "Arraste e solte arquivos aqui ou clique para selecionar", + "supportedFormats": "Formatos suportados: .xlsx, .json", + "studentName": "Nome do aluno", + "addStudent": "Adicionar aluno", + "noStudents": "Nenhum aluno ainda, por favor adicione ou importe", + "studentCount": "{{count}} alunos adicionados", + "studentExists": "Aluno já existe", + "importSuccess": "{{count}} alunos importados com sucesso", + "noNewStudents": "Nenhum novo aluno para importar", + "parseFailed": "Falha ao analisar o arquivo", + "unsupportedFormat": "Formato de arquivo não suportado" + }, + "reasons": { + "title": "Configurar motivos", + "description": "Adicione motivos comuns de alteração de pontos", + "addReason": "Adicionar motivo", + "reasonName": "Nome do motivo", + "points": "Pontos", + "positive": "Adicionar pontos", + "negative": "Remover pontos", + "noReasons": "Nenhum motivo ainda, por favor adicione motivos comuns", + "reasonCount": "{{count}} motivos adicionados", + "reasonExists": "Motivo já existe" + }, + "start": { + "title": "Pronto para uso", + "description": "Tudo está pronto. Vamos começar a usar o SecScore!", + "features": { + "score": "Gerencie facilmente os pontos dos alunos", + "history": "Histórico completo de alterações de pontos", + "settlement": "Suporta liquidação e arquivamento" + }, + "startButton": "Começar" + } + } + }, + "settings": { + "title": "Configurações do sistema", + "tabs": { + "appearance": "Aparência", + "security": "Segurança", + "account": "Conta", + "database": "Conexão com banco de dados", + "dataManagement": "Gerenciamento de dados", + "urlProtocol": "Protocolo URL", + "about": "Sobre" + }, + "language": "Idioma", + "languageHint": "Escolha o idioma de exibição da interface", + "fontFamily": "Fonte global", + "fontFamilyHint": "Escolha a fonte da interface (requer atualização)", + "theme": "Tema", + "password": "Configurar senha", + "themeMode": "Modo", + "lightMode": "Claro", + "darkMode": "Escuro", + "primaryColor": "Cor principal", + "backgroundGradient": "Gradiente de fundo", + "searchKeyboard": { + "title": "Teclado de busca", + "hint": "Escolha o layout do teclado abaixo do campo de busca", + "disableToggle": "Desativar teclado de busca", + "disableHint": "O teclado não será mais exibido abaixo do campo de busca", + "options": { + "qwerty26": "26 teclas (QWERTY)", + "t9": "9 teclas (T9)" + } + }, + "interfaceZoom": "Zoom da interface", + "zoomOptions": { + "small70": "Pequeno (70%)", + "default100": "Padrão (100%)", + "large150": "Grande (150%)" + }, + "general": { + "saved": "Salvo", + "saveFailed": "Falha ao salvar" + }, + "zoomHint": "Ajustar o tamanho total da interface", + "mobile": { + "navigationTitle": "Entradas de página", + "bottomNav": { + "label": "Funções da barra de navegação inferior", + "hint": "Escolha as funções a serem exibidas na barra inferior do celular. A barra exibe no máximo 4 itens, os outros vão para «Mais»." + }, + "dragHint": "Arraste para reorganizar. Os primeiros 4 itens aparecem na barra inferior, os outros em «Mais».", + "dropHere": "Solte aqui", + "bottomSection": "Barra inferior (máx. 4)", + "moreSection": "Mais", + "moveToMore": "Mover para Mais", + "moveToBottom": "Mover para baixo" + }, + "zoomUpdated": "Zoom da interface atualizado", + "updateFailed": "Falha ao atualizar", + "security": { + "title": "Sistema de proteção por senha", + "adminPassword": "Senha de administrador", + "pointsPassword": "Senha de pontos", + "recoveryString": "Frase de recuperação", + "set": "Configurado", + "notSet": "Não configurado", + "generated": "Gerado", + "notGenerated": "Não gerado", + "enterPassword": "Insira 6 dígitos (deixe em branco para pular)", + "adminPasswordPlaceholder": "Insira 6 dígitos para a senha de administrador (deixe em branco para pular)", + "pointsPasswordPlaceholder": "Insira 6 dígitos para a senha de pontos (deixe em branco para pular)", + "recoveryPlaceholder": "Insira a frase de recuperação", + "savePassword": "Salvar senha", + "savePasswords": "Salvar senhas", + "generateRecovery": "Gerar frase de recuperação", + "clearAllPasswords": "Limpar todas as senhas", + "recoveryReset": "Redefinir frase de recuperação", + "enterRecovery": "Insira a frase de recuperação", + "resetPassword": "Redefinir senha", + "resetHint": "A redefinição limpará as senhas de administrador e pontos e gerará uma nova frase de recuperação.", + "confirmClear": "Confirmar a limpeza de todas as senhas?", + "clearHint": "Após a limpeza, a proteção será desativada (sem senha = permissão de administrador por padrão).", + "saved": "Senha atualizada", + "saveFailed": "Falha ao atualizar", + "generateFailed": "Falha ao gerar", + "resetFailed": "Falha ao redefinir", + "cleared": "Limpo", + "clearFailed": "Falha ao limpar", + "passwordClearedNewRecovery": "Senha limpa, nova frase de recuperação", + "newRecoveryString": "Nova frase de recuperação (por favor, salve)" + }, + "account": { + "title": "Conta SECTL Auth", + "notLoggedIn": "Não conectado ao SECTL Auth", + "oauthHint": "Conecte-se com sua conta SECTL Auth para usar autenticação unificada e logout remoto", + "loginButton": "Conectar com SECTL Auth", + "loginSuccess": "Conexão bem-sucedida", + "logout": "Sair", + "userId": "ID do usuário", + "permission": "Nível de permissão" + }, + "database": { + "title": "Conexão com banco de dados", + "currentStatus": "Status atual do banco de dados", + "sqliteLocal": "Banco de dados SQLite local", + "postgresqlRemote": "Banco de dados PostgreSQL remoto", + "connected": "Conectado", + "disconnected": "Desconectado", + "postgresqlConnection": "Conexão PostgreSQL remota", + "connectionHint": "Insira a string de conexão PostgreSQL para conectar ao banco de dados remoto, suporta sincronização multiplataforma.", + "testConnection": "Testar conexão", + "switchToPostgreSQL": "Mudar para PostgreSQL", + "switchToSQLite": "Mudar para SQLite local", + "connectionTestSuccess": "Teste de conexão bem-sucedido", + "connectionTestFailed": "Falha no teste de conexão", + "switchedTo": "Mudado para o banco de dados {{type}}", + "switchedToSQLite": "Mudado para o banco de dados SQLite local", + "switchFailed": "Falha ao mudar", + "syncDescription": "Instruções de sincronização multiplataforma", + "syncPoint1": "O uso de um banco de dados PostgreSQL remoto permite a sincronização de dados multiplataforma.", + "syncPoint2": "Mecanismo de fila de operações integrado para garantir a consistência dos dados durante operações simultâneas.", + "syncPoint3": "É necessário reiniciar o aplicativo após a mudança de banco de dados.", + "syncPoint4": "Serviços de banco de dados em nuvem recomendados: Neon, Supabase, AWS RDS, etc.", + "enterConnectionString": "Por favor, insira a string de conexão PostgreSQL", + "connectionExample": "Exemplo: postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", + "uploadButton": "Carregar dados locais para o servidor remoto", + "uploadHint": "Após conectar ao banco de dados remoto, carregue e mesclie o histórico SQLite local para o servidor remoto.", + "uploadNeedRemote": "Por favor, primeiro mude e conecte ao banco de dados PostgreSQL remoto", + "noNeedUpload": "Os dados locais e remotos já são consistentes", + "uploadConfirmTitle": "Confirmar o upload dos dados locais para o servidor remoto?", + "uploadConfirmContent": "A sincronização priorizará os dados locais. Detectado: apenas local {{localOnly}}, apenas remoto {{remoteOnly}}, conflitos {{conflicts}}.", + "uploadSuccess": "Dados locais carregados para o servidor remoto", + "uploadFailed": "Falha ao carregar" + }, + "data": { + "title": "Gerenciamento de dados", + "settlement": "Liquidação", + "settlementAndRestart": "Liquidar e reiniciar", + "settlementHint": "Classifica os registros não liquidados em uma fase e redefine todos os pontos dos alunos para zero.", + "importExport": "Importar / Exportar", + "exportJson": "Exportar JSON", + "importJson": "Importar JSON", + "importHint": "A importação substituirá os alunos/motivos/registros/configurações existentes (configurações de segurança excluídas).", + "logs": "Logs", + "logLevel": "Nível de log", + "logOperation": "Operação de logs", + "viewLogs": "Ver logs", + "exportLogs": "Exportar logs", + "clearLogs": "Limpar logs", + "logsCleared": "Logs limpos", + "systemLogs": "Logs do sistema (últimos 200)", + "noLogs": "Sem logs", + "logLevelUpdated": "Nível de log atualizado", + "readLogsFailed": "Falha ao ler logs", + "logsExported": "Logs exportados", + "exportFailed": "Falha ao exportar", + "exportSuccess": "Exportação bem-sucedida", + "importSuccess": "Importação bem-sucedida, atualizando", + "importFailed": "Falha ao importar", + "clearFailed": "Falha ao limpar", + "confirmSettlement": "Confirmar liquidação e reinício?", + "settlementConfirm1": "Isso arquivará os registros não liquidados em uma fase e redefinirá todos os pontos dos alunos para zero.", + "settlementConfirm2": "A lista de alunos permanece inalterada; o histórico de liquidação está disponível na página «Histórico de Liquidações».", + "settlementSuccess": "Liquidação bem-sucedida, pontos reiniciados", + "settlementFailed": "Falha na liquidação", + "settle": "Liquidar", + "logLevels": { + "debug": "DEBUG (depuração)", + "info": "INFO (informação)", + "warn": "WARN (aviso)", + "error": "ERROR (erro)" + } + }, + "url": { + "title": "Protocolo URL (secscore://)", + "protocol": "Protocolo URL", + "description": "Você pode invocar o SecScore por meio de uma URL para realizar operações, por exemplo:", + "register": "Registrar protocolo URL", + "registered": "Protocolo URL registrado", + "registerFailed": "Falha ao registrar", + "installerRequired": "Requer a versão instalada do SecScore, pode não funcionar no modo de desenvolvimento." + }, + "mcp": { + "title": "Serviço MCP", + "description": "Gerencie o serviço HTTP MCP integrado para clientes MCP externos.", + "running": "Em execução", + "stopped": "Parado", + "noUrl": "Sem URL", + "host": "Host", + "port": "Porta", + "start": "Iniciar", + "stop": "Parar", + "refresh": "Atualizar status", + "hint": "Recomendado para vincular apenas a 127.0.0.1; após o início, o endpoint é http://host:port/mcp", + "hostRequired": "Por favor, insira o host MCP", + "portInvalid": "Por favor, insira uma porta válida (1-65535)", + "startSuccess": "Servidor MCP iniciado", + "startFailed": "Falha ao iniciar o servidor MCP", + "stopSuccess": "Servidor MCP parado", + "stopFailed": "Falha ao parar o servidor MCP", + "startTimeout": "Tempo limite excedido ao iniciar o servidor MCP, tente novamente", + "stopTimeout": "Tempo limite excedido ao parar o servidor MCP, tente novamente" + }, + "app": { + "title": "Controles do aplicativo", + "description": "Reinicie ou feche rapidamente o aplicativo.", + "restart": "Reiniciar aplicativo", + "quit": "Fechar aplicativo", + "confirmRestartTitle": "Confirmar reinício do aplicativo?", + "confirmRestartContent": "O aplicativo será fechado e reiniciado imediatamente.", + "confirmQuitTitle": "Confirmar fechamento do aplicativo?", + "confirmQuitContent": "O aplicativo será fechado imediatamente.", + "restartFailed": "Falha ao reiniciar", + "quitFailed": "Falha ao fechar" + }, + "about": { + "title": "Sobre", + "appName": "Gestão de Pontos Educacionais", + "version": "Versão", + "copyright": "Direitos autorais", + "ipcStatus": "Status IPC", + "ipcConnected": "Conectado", + "ipcDisconnected": "Desconectado (Falha no pré-carregamento)", + "environment": "Ambiente", + "toggleDevTools": "Alternar ferramentas de desenvolvedor", + "license": "SecScore está sob licença GPL3.0", + "copyRightText": "CopyRight © 2025-{{year}} SECTL" + } + }, + "sidebar": { + "home": "Início", + "students": "Gerenciamento de alunos", + "score": "Operações de pontos", + "boards": "Quadros", + "leaderboard": "Tabela de classificação", + "settlements": "Histórico de liquidações", + "reasons": "Gerenciamento de motivos", + "autoScore": "Adição automática de pontos", + "rewardExchange": "Troca de recompensas", + "rewardSettings": "Configurações de recompensa", + "settings": "Configurações do sistema", + "remoteDb": "Banco de dados remoto", + "refreshStatus": "Atualizar status", + "syncNow": "Sincronizar agora", + "syncSuccess": "Sincronização bem-sucedida", + "syncFailed": "Falha na sincronização", + "getDbStatusFailed": "Falha ao obter status do banco de dados", + "notRemoteMode": "Modo de banco de dados remoto não ativado, reinicie o aplicativo", + "dbNotConnected": "Banco de dados não conectado" + }, + "auth": { + "unlock": "Desbloquear permissão", + "unlockHint": "Insira a senha de 6 dígitos: senha de administrador = acesso total, senha de pontos = apenas operações de pontos.", + "unlockButton": "Desbloquear", + "unlocked": "Permissão desbloqueada", + "logout": "Alternado para somente leitura", + "passwordPlaceholder": "ex. 123456", + "passwordError": "Senha incorreta", + "enterPassword": "Inserir senha", + "lock": "Bloquear" + }, + "languages": { + "zh-CN": "简体中文", + "en-US": "English", + "ja-JP": "日本語", + "fr-FR": "Français", + "es-ES": "Español", + "ko-KR": "한국어", + "ru-RU": "Русский", + "de-DE": "Deutsch", + "pt-BR": "Português", + "ar-SA": "العربية" + }, + "theme": { + "colorAndBackground": "Cor e fundo", + "gradientLabels": { + "blue": "Azul fresco", + "pink": "Rosa suave", + "cyan": "Ciano", + "purple": "Roxo" + }, + "gradientDirections": { + "vertical": "Vertical", + "horizontal": "Horizontal", + "diagonal": "Diagonal" + }, + "generate": "Gerar", + "saved": "Salvo", + "saveFailed": "Falha ao salvar", + "mode": "Modo", + "myTheme": "Meu tema" + }, + "permissions": { + "admin": "Permissão de administrador", + "points": "Permissão de pontos", + "view": "Somente leitura" + }, + "home": { + "title": "Página inicial de pontos dos alunos", + "subtitle": "Total de {{count}} alunos, clique no cartão para realizar operações", + "searchPlaceholder": "Buscar nome/pinyin...", + "sortBy": { + "alphabet": "Ordenar por nome", + "surname": "Agrupar por sobrenome", + "group": "Ordenar por grupo", + "score": "Classificação por pontos" + }, + "layoutBy": { + "grouped": "Lista agrupada", + "squareGrid": "Grade quadrada" + }, + "noStudents": "Sem dados de alunos, por favor adicione em gerenciamento de alunos", + "noMatch": "Nenhum aluno correspondente encontrado", + "clearSearch": "Limpar busca", + "points": "pts", + "currentScore": "Pontos atuais", + "quickOptions": "Opções rápidas", + "adjustPoints": "Ajustar pontos", + "customPoints": "Pontos personalizados", + "customPointsHint": "Insira qualquer valor no campo de entrada", + "reason": "Motivo", + "reasonPlaceholder": "Insira o motivo da alteração de pontos (opcional)", + "preview": "Visualização da alteração", + "noReason": "(Sem motivo)", + "ungrouped": "Não agrupado", + "category": { + "others": "Outros" + }, + "scoreAdded": "{{points}} pontos adicionados a {{name}}", + "scoreDeducted": "{{points}} pontos removidos de {{name}}", + "submitFailed": "Falha ao enviar", + "pleaseSelectPoints": "Por favor, selecione ou insira pontos", + "reasonDefault": "{{action}} {{points}} pontos", + "studentCount": "{{count}} alunos", + "operationTitle": "Operação de pontos: {{name}}", + "submitOperation": "Enviar operação", + "operationTitleBatch": "Operação de pontos: {{count}} selecionados", + "undoLastAction": "Desfazer", + "undoLastHint": "Desfazer último: {{name}} {{delta}}", + "undoUnavailable": "Nenhuma operação de pontos para desfazer", + "undoLastSuccess": "Última operação de pontos desfeita", + "multiSelect": "Seleção múltipla", + "batchMode": "Modo em lote", + "selectAll": "Selecionar tudo", + "clearSelected": "Limpar seleção", + "batchOperate": "Pontos em lote", + "selected": "Selecionado", + "selectedCount": "{{count}} selecionados", + "batchSuccess": "Pontos enviados para {{count}} alunos", + "batchPartial": "{{success}}/{{total}} alunos enviados com sucesso", + "selectStudentFirst": "Por favor, selecione primeiro um ou mais alunos", + "addPoints": "Adicionar pontos", + "deductPoints": "Remover pontos", + "pointsChange": "Alteração de pontos", + "reasonLabel": "Motivo: " + }, + "students": { + "title": "Gerenciamento de alunos", + "importList": "Importar lista", + "addStudent": "Adicionar aluno", + "name": "Nome", + "group": "Grupo", + "avatar": "Avatar", + "currentScore": "Pontos atuais", + "tags": "Tags", + "noTags": "Sem tags", + "editTags": "Editar tags", + "editAvatar": "Configurar avatar", + "deleteConfirm": "Confirmar a exclusão deste aluno?", + "addTitle": "Adicionar aluno", + "addConfirm": "Adicionar", + "namePlaceholder": "Insira o nome do aluno", + "groupPlaceholder": "Insira o nome do grupo (opcional)", + "nameRequired": "Por favor, insira o nome", + "nameExists": "Nome do aluno já existe", + "addSuccess": "Adicionado com sucesso", + "addFailed": "Falha ao adicionar", + "deleteSuccess": "Excluído com sucesso", + "deleteFailed": "Falha ao excluir", + "tagSaveSuccess": "Tags salvas com sucesso", + "tagSaveFailed": "Falha ao salvar tags", + "importTitle": "Importar lista", + "importTextHint": "Cole várias linhas, um nome de aluno por linha", + "importTextPlaceholder": "Exemplo:\nJoão\nMaria\nPedro", + "importByText": "Importar do texto", + "importByXlsx": "Importar via xlsx", + "importByBanyou": "Importar do BanYou", + "xlsxPreview": "Visualização e importação xlsx", + "file": "Arquivo", + "selectNameCol": "Clique no cabeçalho para selecionar a coluna de nomes", + "previewRows": "Visualizar as 50 primeiras linhas", + "importConfirm": "Importar", + "importComplete": "Importação concluída: {{inserted}} adicionados, {{skipped}} ignorados", + "workerNotReady": "Worker não inicializado", + "parseXlsxFailed": "Falha ao analisar xlsx", + "selectNameColFirst": "Por favor, selecione primeiro a « Coluna de nomes »", + "noNamesFound": "Nenhum nome importável encontrado na coluna selecionada", + "importFailed": "Falha ao importar", + "banyouCookieHint": "Após fazer login no BanYou Web, copie o cookie completo e cole abaixo.", + "banyouCookiePlaceholder": "uid=...; accessToken=...; ...", + "banyouCookieRequired": "Por favor, cole o cookie primeiro", + "banyouFetch": "Obter lista de classes", + "banyouFetchSuccess": "{{count}} classes obtidas", + "banyouFetchFailed": "Falha ao obter classes do BanYou", + "banyouCreatedClasses": "Minhas classes criadas", + "banyouJoinedClasses": "Minhas classes unidas", + "banyouNoClasses": "Sem dados de classes" + }, + "boards": { + "title": "Gerenciamento de quadros", + "addBoard": "Adicionar quadro", + "editBoard": "Editar quadro", + "deleteBoard": "Excluir quadro", + "name": "Nome do quadro", + "description": "Descrição", + "visibility": "Visibilidade", + "public": "Público", + "private": "Privado", + "studentsCount": "{{count}} alunos", + "noBoards": "Sem quadros", + "addTitle": "Adicionar quadro", + "editTitle": "Editar quadro", + "namePlaceholder": "Insira o nome do quadro", + "descriptionPlaceholder": "Insira a descrição", + "confirmDelete": "Confirmar a exclusão deste quadro?", + "saved": "Salvo", + "saveFailed": "Falha ao salvar", + "deleted": "Excluído", + "deleteFailed": "Falha ao excluir", + "boardExists": "Quadro já existe" + }, + "leaderboard": { + "title": "Tabela de classificação", + "sortBy": "Ordenar por", + "score": "Pontos", + "name": "Nome", + "rank": "Classificação", + "student": "Aluno", + "points": "Pontos", + "noData": "Sem dados de classificação", + "topStudents": "Melhores alunos", + "rankChanged": "Classificação: {{rank}}" + }, + "settlements": { + "title": "Histórico de liquidações", + "phase": "Fase", + "date": "Data", + "studentCount": "Número de alunos", + "totalPoints": "Pontos totais", + "actions": "Ações", + "viewDetails": "Ver detalhes", + "noSettlements": "Sem liquidações", + "phaseDetail": "Detalhes da fase {{phase}}", + "settlementDate": "Data de liquidação: {{date}}", + "totalStudents": "Total de alunos: {{count}}", + "totalPointsChange": "Alteração total de pontos: {{points}}" + }, + "reasons": { + "title": "Gerenciamento de motivos", + "addReason": "Adicionar motivo", + "editReason": "Editar motivo", + "name": "Nome do motivo", + "points": "Pontos", + "positive": "Positivo", + "negative": "Negativo", + "noReasons": "Sem motivos", + "addTitle": "Adicionar motivo", + "editTitle": "Editar motivo", + "namePlaceholder": "Insira o nome do motivo", + "pointsPlaceholder": "Insira os pontos", + "confirmDelete": "Confirmar a exclusão deste motivo?", + "saved": "Salvo", + "saveFailed": "Falha ao salvar", + "deleted": "Excluído", + "deleteFailed": "Falha ao excluir", + "reasonExists": "Motivo já existe" + }, + "autoScore": { + "title": "Adição automática de pontos", + "addRule": "Adicionar regra", + "editRule": "Editar regra", + "deleteRule": "Excluir regra", + "ruleName": "Nome da regra", + "trigger": "Gatilho", + "action": "Ação", + "interval": "Intervalo", + "enabled": "Habilitado", + "noRules": "Sem regras", + "addTitle": "Adicionar regra", + "editTitle": "Editar regra", + "namePlaceholder": "Insira o nome da regra", + "triggerPlaceholder": "Selecionar gatilho", + "actionPlaceholder": "Selecionar ação", + "intervalPlaceholder": "Selecionar intervalo", + "confirmDelete": "Confirmar a exclusão desta regra?", + "saved": "Salvo", + "saveFailed": "Falha ao salvar", + "deleted": "Excluído", + "deleteFailed": "Falha ao excluir", + "ruleExists": "Regra já existe" + }, + "rewardExchange": { + "title": "Troca de recompensas", + "rewards": "Recompensas", + "exchange": "Trocar", + "history": "Histórico", + "noRewards": "Sem recompensas", + "noHistory": "Sem histórico", + "rewardName": "Nome da recompensa", + "cost": "Custo", + "stock": "Estoque", + "exchangeSuccess": "Troca bem-sucedida", + "exchangeFailed": "Falha ao trocar", + "insufficientPoints": "Pontos insuficientes", + "outOfStock": "Fora de estoque" + }, + "rewardSettings": { + "title": "Configurações de recompensa", + "addReward": "Adicionar recompensa", + "editReward": "Editar recompensa", + "deleteReward": "Excluir recompensa", + "name": "Nome", + "cost": "Custo", + "stock": "Estoque", + "noRewards": "Sem recompensas", + "addTitle": "Adicionar recompensa", + "editTitle": "Editar recompensa", + "namePlaceholder": "Insira o nome", + "costPlaceholder": "Insira o custo", + "stockPlaceholder": "Insira o estoque", + "confirmDelete": "Confirmar a exclusão desta recompensa?", + "saved": "Salvo", + "saveFailed": "Falha ao salvar", + "deleted": "Excluído", + "deleteFailed": "Falha ao excluir", + "rewardExists": "Recompensa já existe" + }, + "recovery": { + "title": "Frase de recuperação SecScore", + "saved": "Eu salvei", + "export": "Exportar arquivo de texto", + "exportHint": "Recomenda-se salvar offline. Se perdido, não poderá ser recuperado.", + "hint": "Recomenda-se salvar offline após a exportação. Se perdido, não poderá ser recuperado." + } +} diff --git a/src/i18n/locales/ru-RU.json b/src/i18n/locales/ru-RU.json new file mode 100644 index 0000000..536ff1f --- /dev/null +++ b/src/i18n/locales/ru-RU.json @@ -0,0 +1,668 @@ +{ + "common": { + "next": "Далее", + "prev": "Назад", + "finish": "Завершить", + "cancel": "Отмена", + "save": "Сохранить", + "loading": "Загрузка...", + "confirm": "Подтвердить", + "success": "Успех", + "error": "Ошибка", + "delete": "Удалить", + "edit": "Редактировать", + "add": "Добавить", + "search": "Поиск", + "clear": "Очистить", + "close": "Закрыть", + "yes": "Да", + "no": "Нет", + "submit": "Отправить", + "reset": "Сбросить", + "import": "Импорт", + "export": "Экспорт", + "create": "Создать", + "update": "Обновить", + "refresh": "Обновить", + "view": "Просмотр", + "name": "Имя", + "status": "Статус", + "operation": "Операция", + "description": "Описание", + "total": "Всего {{count}} элементов", + "noData": "Нет данных", + "pleaseSelect": "Пожалуйста, выберите", + "pleaseEnter": "Пожалуйста, введите", + "readOnly": "Режим только для чтения", + "none": "Нет", + "more": "Больше" + }, + "oobe": { + "title": "Добро пожаловать в SecScore", + "subtitle": "Эксперт по управлению образовательными баллами", + "step": "Шаг {{current}}/{{total}}", + "skip": "Пропустить", + "steps": { + "entry": { + "title": "Начало работы", + "description": "Выберите процедуру, которую нужно выполнить", + "enterOobe": "Войти в OOBE", + "connectPostgresAutoSync": "Подключиться к PostgreSQL с автоматической синхронизацией", + "skipDirect": "Пропустить OOBE и войти напрямую" + }, + "postgresql": { + "title": "Подключение к PostgreSQL", + "description": "Введите строку подключения к PostgreSQL. Система автоматически подключится и синхронизируется.", + "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", + "autoSyncAndEnter": "Автоматическое подключение и синхронизация" + }, + "language": { + "title": "Выбор языка", + "description": "Пожалуйста, выберите предпочитаемый язык" + }, + "theme": { + "title": "Настройка темы", + "description": "Выберите внешний вид интерфейса", + "mode": "Режим", + "lightMode": "Светлый", + "darkMode": "Темный", + "primaryColor": "Основной цвет", + "backgroundGradient": "Градиент фона" + }, + "password": { + "title": "Настройка пароля", + "description": "Установите пароль для защиты данных (необязательно)", + "adminPassword": "Пароль администратора", + "adminPasswordHint": "Доступ ко всем функциям, 6 цифр", + "pointsPassword": "Пароль баллов", + "pointsPasswordHint": "Только операции с баллами, 6 цифр", + "passwordPlaceholder": "Введите 6 цифр", + "hint": "Оставьте пустым для пропуска, настройте позже в настройках" + }, + "students": { + "title": "Импорт списка студентов", + "description": "Добавьте список студентов, чтобы начать управление баллами", + "import": "Импорт списка", + "manual": "Добавить вручную", + "importHint": "Поддерживает файлы Excel (.xlsx) или JSON", + "manualHint": "Вставьте несколько строк, одно имя студента на строку", + "dragDrop": "Перетащите файлы сюда или нажмите для выбора", + "supportedFormats": "Поддерживаемые форматы: .xlsx, .json", + "studentName": "Имя студента", + "addStudent": "Добавить студента", + "noStudents": "Студентов пока нет, пожалуйста, добавьте или импортируйте", + "studentCount": "Добавлено {{count}} студентов", + "studentExists": "Студент уже существует", + "importSuccess": "Успешно импортировано {{count}} студентов", + "noNewStudents": "Нет новых студентов для импорта", + "parseFailed": "Ошибка анализа файла", + "unsupportedFormat": "Неподдерживаемый формат файла" + }, + "reasons": { + "title": "Настройка причин", + "description": "Добавьте распространенные причины изменения баллов", + "addReason": "Добавить причину", + "reasonName": "Название причины", + "points": "Баллы", + "positive": "Добавить баллы", + "negative": "Снять баллы", + "noReasons": "Причин пока нет, пожалуйста, добавьте распространенные причины", + "reasonCount": "Добавлено {{count}} причин", + "reasonExists": "Причина уже существует" + }, + "start": { + "title": "Готово к использованию", + "description": "Все готово. Давайте начнем использовать SecScore!", + "features": { + "score": "Легко управляйте баллами студентов", + "history": "Полная история изменений баллов", + "settlement": "Поддержка расчета и архивирования" + }, + "startButton": "Начать" + } + } + }, + "settings": { + "title": "Системные настройки", + "tabs": { + "appearance": "Внешний вид", + "security": "Безопасность", + "account": "Учетная запись", + "database": "Подключение к базе данных", + "dataManagement": "Управление данными", + "urlProtocol": "Протокол URL", + "about": "О программе" + }, + "language": "Язык", + "languageHint": "Выберите язык отображения интерфейса", + "fontFamily": "Глобальный шрифт", + "fontFamilyHint": "Выберите шрифт интерфейса (требуется обновление)", + "theme": "Тема", + "password": "Настройка пароля", + "themeMode": "Режим", + "lightMode": "Светлый", + "darkMode": "Темный", + "primaryColor": "Основной цвет", + "backgroundGradient": "Градиент фона", + "searchKeyboard": { + "title": "Клавиатура поиска", + "hint": "Выберите раскладку клавиатуры под полем поиска", + "disableToggle": "Отключить клавиатуру поиска", + "disableHint": "Клавиатура больше не будет отображаться под полем поиска", + "options": { + "qwerty26": "26 клавиш (QWERTY)", + "t9": "9 клавиш (T9)" + } + }, + "interfaceZoom": "Масштаб интерфейса", + "zoomOptions": { + "small70": "Маленький (70%)", + "default100": "По умолчанию (100%)", + "large150": "Большой (150%)" + }, + "general": { + "saved": "Сохранено", + "saveFailed": "Ошибка сохранения" + }, + "zoomHint": "Настройка общего размера интерфейса", + "mobile": { + "navigationTitle": "Входы на страницу", + "bottomNav": { + "label": "Функции нижней навигации", + "hint": "Выберите функции для отображения в нижней панели мобильного устройства. Панель отображает максимум 4 элемента, остальные переходят в «Ещё»." + }, + "dragHint": "Перетащите для изменения порядка. Первые 4 элемента отображаются в нижней панели, остальные в «Ещё».", + "dropHere": "Отпустите здесь", + "bottomSection": "Нижняя панель (макс. 4)", + "moreSection": "Ещё", + "moveToMore": "Переместить в Ещё", + "moveToBottom": "Переместить вниз" + }, + "zoomUpdated": "Масштаб интерфейса обновлен", + "updateFailed": "Ошибка обновления", + "security": { + "title": "Система защиты паролем", + "adminPassword": "Пароль администратора", + "pointsPassword": "Пароль баллов", + "recoveryString": "Строка восстановления", + "set": "Установлено", + "notSet": "Не установлено", + "generated": "Создано", + "notGenerated": "Не создано", + "enterPassword": "Введите 6 цифр (оставьте пустым для пропуска)", + "adminPasswordPlaceholder": "Введите 6 цифр пароля администратора (оставьте пустым для пропуска)", + "pointsPasswordPlaceholder": "Введите 6 цифр пароля баллов (оставьте пустым для пропуска)", + "recoveryPlaceholder": "Введите строку восстановления", + "savePassword": "Сохранить пароль", + "savePasswords": "Сохранить пароли", + "generateRecovery": "Создать строку восстановления", + "clearAllPasswords": "Очистить все пароли", + "recoveryReset": "Сброс строки восстановления", + "enterRecovery": "Введите строку восстановления", + "resetPassword": "Сброс пароля", + "resetHint": "Сброс очистит пароли администратора и баллов и создаст новую строку восстановления.", + "confirmClear": "Подтвердить очистку всех паролей?", + "clearHint": "После очистки защита будет отключена (без пароля по умолчанию - права администратора).", + "saved": "Пароль обновлен", + "saveFailed": "Ошибка обновления", + "generateFailed": "Ошибка создания", + "resetFailed": "Ошибка сброса", + "cleared": "Очищено", + "clearFailed": "Ошибка очистки", + "passwordClearedNewRecovery": "Пароль очищен, новая строка восстановления", + "newRecoveryString": "Новая строка восстановления (пожалуйста, сохраните)" + }, + "account": { + "title": "Учетная запись SECTL Auth", + "notLoggedIn": "Не вошел в SECTL Auth", + "oauthHint": "Войдите в свою учетную запись SECTL Auth для использования единой аутентификации и удаленного выхода", + "loginButton": "Войти через SECTL Auth", + "loginSuccess": "Успешный вход", + "logout": "Выход", + "userId": "ID пользователя", + "permission": "Уровень разрешений" + }, + "database": { + "title": "Подключение к базе данных", + "currentStatus": "Текущий статус базы данных", + "sqliteLocal": "Локальная база данных SQLite", + "postgresqlRemote": "Удаленная база данных PostgreSQL", + "connected": "Подключено", + "disconnected": "Отключено", + "postgresqlConnection": "Удаленное подключение PostgreSQL", + "connectionHint": "Введите строку подключения PostgreSQL для подключения к удаленной базе данных, поддержка синхронизации на нескольких платформах.", + "testConnection": "Тест подключения", + "switchToPostgreSQL": "Переключиться на PostgreSQL", + "switchToSQLite": "Переключиться на локальный SQLite", + "connectionTestSuccess": "Тест подключения успешен", + "connectionTestFailed": "Ошибка теста подключения", + "switchedTo": "Переключено на базу данных {{type}}", + "switchedToSQLite": "Переключено на локальную базу данных SQLite", + "switchFailed": "Ошибка переключения", + "syncDescription": "Инструкции по синхронизации на нескольких платформах", + "syncPoint1": "Использование удаленной базы данных PostgreSQL позволяет синхронизировать данные на нескольких платформах.", + "syncPoint2": "Встроенная очередь операций для обеспечения согласованности данных при одновременных операциях.", + "syncPoint3": "Требуется перезапуск приложения после переключения базы данных.", + "syncPoint4": "Рекомендуемые облачные сервисы баз данных: Neon, Supabase, AWS RDS и др.", + "enterConnectionString": "Пожалуйста, введите строку подключения PostgreSQL", + "connectionExample": "Пример: postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", + "uploadButton": "Загрузить локальные данные на удаленный сервер", + "uploadHint": "После подключения к удаленной базе данных загрузите и объедините локальную историю SQLite на удаленный сервер.", + "uploadNeedRemote": "Сначала переключитесь и подключитесь к удаленной базе данных PostgreSQL", + "noNeedUpload": "Локальные и удаленные данные уже согласованы", + "uploadConfirmTitle": "Подтвердить загрузку локальных данных на удаленный сервер?", + "uploadConfirmContent": "Синхронизация будет отдавать приоритет локальным данным. Обнаружено: только локальные {{localOnly}}, только удаленные {{remoteOnly}}, конфликты {{conflicts}}.", + "uploadSuccess": "Локальные данные загружены на удаленный сервер", + "uploadFailed": "Ошибка загрузки" + }, + "data": { + "title": "Управление данными", + "settlement": "Расчет", + "settlementAndRestart": "Рассчитать и перезапустить", + "settlementHint": "Классифицирует нерассчитанные записи в один этап и сбрасывает все баллы студентов в ноль.", + "importExport": "Импорт / Экспорт", + "exportJson": "Экспорт JSON", + "importJson": "Импорт JSON", + "importHint": "Импорт перезапишет существующих студентов/причины/записи/настройки (настройки безопасности исключены).", + "logs": "Журналы", + "logLevel": "Уровень журнала", + "logOperation": "Операция с журналами", + "viewLogs": "Просмотр журналов", + "exportLogs": "Экспорт журналов", + "clearLogs": "Очистить журналы", + "logsCleared": "Журналы очищены", + "systemLogs": "Системные журналы (последние 200)", + "noLogs": "Нет журналов", + "logLevelUpdated": "Уровень журнала обновлен", + "readLogsFailed": "Ошибка чтения журналов", + "logsExported": "Журналы экспортированы", + "exportFailed": "Ошибка экспорта", + "exportSuccess": "Экспорт успешен", + "importSuccess": "Импорт успешен, обновление", + "importFailed": "Ошибка импорта", + "clearFailed": "Ошибка очистки", + "confirmSettlement": "Подтвердить расчет и перезапуск?", + "settlementConfirm1": "Это архивирует нерассчитанные записи в один этап и сбросит все баллы студентов в ноль.", + "settlementConfirm2": "Список студентов остается без изменений; история расчетов доступна на странице «История расчетов».", + "settlementSuccess": "Расчет успешен, баллы перезапущены", + "settlementFailed": "Ошибка расчета", + "settle": "Рассчитать", + "logLevels": { + "debug": "DEBUG (отладка)", + "info": "INFO (информация)", + "warn": "WARN (предупреждение)", + "error": "ERROR (ошибка)" + } + }, + "url": { + "title": "Протокол URL (secscore://)", + "protocol": "Протокол URL", + "description": "Вы можете вызвать SecScore через URL для выполнения операций, например:", + "register": "Зарегистрировать протокол URL", + "registered": "Протокол URL зарегистрирован", + "registerFailed": "Ошибка регистрации", + "installerRequired": "Требуется установленная версия SecScore, может не работать в режиме разработки." + }, + "mcp": { + "title": "Сервис MCP", + "description": "Управление встроенным HTTP-сервисом MCP для внешних клиентов MCP.", + "running": "Запущен", + "stopped": "Остановлен", + "noUrl": "Нет URL", + "host": "Хост", + "port": "Порт", + "start": "Запустить", + "stop": "Остановить", + "refresh": "Обновить состояние", + "hint": "Рекомендуется привязывать только к 127.0.0.1; после запуска конечная точка - http://host:port/mcp", + "hostRequired": "Пожалуйста, введите хост MCP", + "portInvalid": "Пожалуйста, введите действительный порт (1-65535)", + "startSuccess": "Сервер MCP запущен", + "startFailed": "Ошибка запуска сервера MCP", + "stopSuccess": "Сервер MCP остановлен", + "stopFailed": "Ошибка остановки сервера MCP", + "startTimeout": "Время ожидания запуска сервера MCP истекло, повторите попытку", + "stopTimeout": "Время ожидания остановки сервера MCP истекло, повторите попытку" + }, + "app": { + "title": "Управление приложением", + "description": "Быстро перезапустить или закрыть приложение.", + "restart": "Перезапустить приложение", + "quit": "Закрыть приложение", + "confirmRestartTitle": "Подтвердить перезапуск приложения?", + "confirmRestartContent": "Приложение будет немедленно закрыто и перезапущено.", + "confirmQuitTitle": "Подтвердить закрытие приложения?", + "confirmQuitContent": "Приложение будет немедленно закрыто.", + "restartFailed": "Ошибка перезапуска", + "quitFailed": "Ошибка закрытия" + }, + "about": { + "title": "О программе", + "appName": "Управление образовательными баллами", + "version": "Версия", + "copyright": "Авторские права", + "ipcStatus": "Статус IPC", + "ipcConnected": "Подключено", + "ipcDisconnected": "Отключено (Ошибка предварительной загрузки)", + "environment": "Среда", + "toggleDevTools": "Переключить инструменты разработчика", + "license": "SecScore распространяется по лицензии GPL3.0", + "copyRightText": "CopyRight © 2025-{{year}} SECTL" + } + }, + "sidebar": { + "home": "Главная", + "students": "Управление студентами", + "score": "Операции с баллами", + "boards": "Доски", + "leaderboard": "Таблица лидеров", + "settlements": "История расчетов", + "reasons": "Управление причинами", + "autoScore": "Автоматическое добавление баллов", + "rewardExchange": "Обмен наградами", + "rewardSettings": "Настройки наград", + "settings": "Системные настройки", + "remoteDb": "Удаленная база данных", + "refreshStatus": "Обновить состояние", + "syncNow": "Синхронизировать сейчас", + "syncSuccess": "Синхронизация успешна", + "syncFailed": "Ошибка синхронизации", + "getDbStatusFailed": "Ошибка получения статуса базы данных", + "notRemoteMode": "Режим удаленной базы данных не активирован, перезапустите приложение", + "dbNotConnected": "База данных не подключена" + }, + "auth": { + "unlock": "Разблокировать разрешение", + "unlockHint": "Введите 6-значный пароль: пароль администратора = полный доступ, пароль баллов = только операции с баллами.", + "unlockButton": "Разблокировать", + "unlocked": "Разрешение разблокировано", + "logout": "Переключено в режим только для чтения", + "passwordPlaceholder": "например, 123456", + "passwordError": "Неверный пароль", + "enterPassword": "Введите пароль", + "lock": "Заблокировать" + }, + "languages": { + "zh-CN": "简体中文", + "en-US": "English", + "ja-JP": "日本語", + "fr-FR": "Français", + "es-ES": "Español", + "ko-KR": "한국어", + "ru-RU": "Русский", + "de-DE": "Deutsch", + "pt-BR": "Português", + "ar-SA": "العربية" + }, + "theme": { + "colorAndBackground": "Цвет и фон", + "gradientLabels": { + "blue": "Свежий синий", + "pink": "Нежный розовый", + "cyan": "Голубой", + "purple": "Фиолетовый" + }, + "gradientDirections": { + "vertical": "Вертикально", + "horizontal": "Горизонтально", + "diagonal": "По диагонали" + }, + "generate": "Создать", + "saved": "Сохранено", + "saveFailed": "Ошибка сохранения", + "mode": "Режим", + "myTheme": "Моя тема" + }, + "permissions": { + "admin": "Разрешение администратора", + "points": "Разрешение баллов", + "view": "Только для чтения" + }, + "home": { + "title": "Домашняя страница баллов студентов", + "subtitle": "Всего {{count}} студентов, нажмите на карточку для выполнения операций", + "searchPlaceholder": "Поиск имени/пиньинь...", + "sortBy": { + "alphabet": "Сортировка по имени", + "surname": "Группа по фамилии", + "group": "Сортировка по группе", + "score": "Рейтинг по баллам" + }, + "layoutBy": { + "grouped": "Группированный список", + "squareGrid": "Квадратная сетка" + }, + "noStudents": "Нет данных о студентах, пожалуйста, добавьте в управлении студентами", + "noMatch": "Нет совпадающих студентов", + "clearSearch": "Очистить поиск", + "points": "баллы", + "currentScore": "Текущие баллы", + "quickOptions": "Быстрые опции", + "adjustPoints": "Настройка баллов", + "customPoints": "Пользовательские баллы", + "customPointsHint": "Введите любое значение в поле ввода", + "reason": "Причина", + "reasonPlaceholder": "Введите причину изменения баллов (необязательно)", + "preview": "Предпросмотр изменения", + "noReason": "(Без причины)", + "ungrouped": "Без группы", + "category": { + "others": "Другие" + }, + "scoreAdded": "{{points}} баллов добавлено {{name}}", + "scoreDeducted": "{{points}} баллов снято у {{name}}", + "submitFailed": "Ошибка отправки", + "pleaseSelectPoints": "Пожалуйста, выберите или введите баллы", + "reasonDefault": "{{action}} {{points}} баллов", + "studentCount": "{{count}} студентов", + "operationTitle": "Операция с баллами: {{name}}", + "submitOperation": "Отправить операцию", + "operationTitleBatch": "Операция с баллами: выбрано {{count}}", + "undoLastAction": "Отменить", + "undoLastHint": "Отменить последнее: {{name}} {{delta}}", + "undoUnavailable": "Нет операций с баллами для отмены", + "undoLastSuccess": "Последняя операция с баллами отменена", + "multiSelect": "Множественный выбор", + "batchMode": "Пакетный режим", + "selectAll": "Выбрать все", + "clearSelected": "Очистить выбор", + "batchOperate": "Пакетные баллы", + "selected": "Выбрано", + "selectedCount": "Выбрано {{count}}", + "batchSuccess": "Баллы отправлены для {{count}} студентов", + "batchPartial": "Успешно отправлено {{success}}/{{total}} студентов", + "selectStudentFirst": "Пожалуйста, сначала выберите одного или нескольких студентов", + "addPoints": "Добавить баллы", + "deductPoints": "Снять баллы", + "pointsChange": "Изменение баллов", + "reasonLabel": "Причина: " + }, + "students": { + "title": "Управление студентами", + "importList": "Импорт списка", + "addStudent": "Добавить студента", + "name": "Имя", + "group": "Группа", + "avatar": "Аватар", + "currentScore": "Текущие баллы", + "tags": "Теги", + "noTags": "Нет тегов", + "editTags": "Редактировать теги", + "editAvatar": "Настроить аватар", + "deleteConfirm": "Подтвердить удаление этого студента?", + "addTitle": "Добавить студента", + "addConfirm": "Добавить", + "namePlaceholder": "Введите имя студента", + "groupPlaceholder": "Введите название группы (необязательно)", + "nameRequired": "Пожалуйста, введите имя", + "nameExists": "Имя студента уже существует", + "addSuccess": "Добавлено успешно", + "addFailed": "Ошибка добавления", + "deleteSuccess": "Удалено успешно", + "deleteFailed": "Ошибка удаления", + "tagSaveSuccess": "Теги сохранены успешно", + "tagSaveFailed": "Ошибка сохранения тегов", + "importTitle": "Импорт списка", + "importTextHint": "Вставьте несколько строк, одно имя студента на строку", + "importTextPlaceholder": "Пример:\nИван\nМария\nПётр", + "importByText": "Импорт из текста", + "importByXlsx": "Импорт через xlsx", + "importByBanyou": "Импорт из BanYou", + "xlsxPreview": "Предпросмотр и импорт xlsx", + "file": "Файл", + "selectNameCol": "Нажмите на заголовок для выбора столбца с именами", + "previewRows": "Предпросмотр первых 50 строк", + "importConfirm": "Импорт", + "importComplete": "Импорт завершен: добавлено {{inserted}}, пропущено {{skipped}}", + "workerNotReady": "Рабочий не инициализирован", + "parseXlsxFailed": "Ошибка анализа xlsx", + "selectNameColFirst": "Пожалуйста, сначала выберите «Столбец с именами»", + "noNamesFound": "В выбранном столбце не найдено импортируемых имен", + "importFailed": "Ошибка импорта", + "banyouCookieHint": "После входа в BanYou Web скопируйте полную куку и вставьте ниже.", + "banyouCookiePlaceholder": "uid=...; accessToken=...; ...", + "banyouCookieRequired": "Пожалуйста, сначала вставьте куку", + "banyouFetch": "Получить список классов", + "banyouFetchSuccess": "Получено {{count}} классов", + "banyouFetchFailed": "Ошибка получения классов BanYou", + "banyouCreatedClasses": "Мои созданные классы", + "banyouJoinedClasses": "Мои присоединенные классы", + "banyouNoClasses": "Нет данных о классах" + }, + "boards": { + "title": "Управление досками", + "addBoard": "Добавить доску", + "editBoard": "Редактировать доску", + "deleteBoard": "Удалить доску", + "name": "Название доски", + "description": "Описание", + "visibility": "Видимость", + "public": "Публичная", + "private": "Частная", + "studentsCount": "{{count}} студентов", + "noBoards": "Нет досок", + "addTitle": "Добавить доску", + "editTitle": "Редактировать доску", + "namePlaceholder": "Введите название доски", + "descriptionPlaceholder": "Введите описание", + "confirmDelete": "Подтвердить удаление этой доски?", + "saved": "Сохранено", + "saveFailed": "Ошибка сохранения", + "deleted": "Удалено", + "deleteFailed": "Ошибка удаления", + "boardExists": "Доска уже существует" + }, + "leaderboard": { + "title": "Таблица лидеров", + "sortBy": "Сортировать по", + "score": "Баллы", + "name": "Имя", + "rank": "Ранг", + "student": "Студент", + "points": "Баллы", + "noData": "Нет данных рейтинга", + "topStudents": "Лучшие студенты", + "rankChanged": "Ранг: {{rank}}" + }, + "settlements": { + "title": "История расчетов", + "phase": "Этап", + "date": "Дата", + "studentCount": "Количество студентов", + "totalPoints": "Всего баллов", + "actions": "Действия", + "viewDetails": "Просмотреть детали", + "noSettlements": "Нет расчетов", + "phaseDetail": "Детали этапа {{phase}}", + "settlementDate": "Дата расчета: {{date}}", + "totalStudents": "Всего студентов: {{count}}", + "totalPointsChange": "Общее изменение баллов: {{points}}" + }, + "reasons": { + "title": "Управление причинами", + "addReason": "Добавить причину", + "editReason": "Редактировать причину", + "name": "Название причины", + "points": "Баллы", + "positive": "Положительный", + "negative": "Отрицательный", + "noReasons": "Нет причин", + "addTitle": "Добавить причину", + "editTitle": "Редактировать причину", + "namePlaceholder": "Введите название причины", + "pointsPlaceholder": "Введите баллы", + "confirmDelete": "Подтвердить удаление этой причины?", + "saved": "Сохранено", + "saveFailed": "Ошибка сохранения", + "deleted": "Удалено", + "deleteFailed": "Ошибка удаления", + "reasonExists": "Причина уже существует" + }, + "autoScore": { + "title": "Автоматическое добавление баллов", + "addRule": "Добавить правило", + "editRule": "Редактировать правило", + "deleteRule": "Удалить правило", + "ruleName": "Название правила", + "trigger": "Триггер", + "action": "Действие", + "interval": "Интервал", + "enabled": "Включено", + "noRules": "Нет правил", + "addTitle": "Добавить правило", + "editTitle": "Редактировать правило", + "namePlaceholder": "Введите название правила", + "triggerPlaceholder": "Выберите триггер", + "actionPlaceholder": "Выберите действие", + "intervalPlaceholder": "Выберите интервал", + "confirmDelete": "Подтвердить удаление этого правила?", + "saved": "Сохранено", + "saveFailed": "Ошибка сохранения", + "deleted": "Удалено", + "deleteFailed": "Ошибка удаления", + "ruleExists": "Правило уже существует" + }, + "rewardExchange": { + "title": "Обмен наградами", + "rewards": "Награды", + "exchange": "Обмен", + "history": "История", + "noRewards": "Нет наград", + "noHistory": "Нет истории", + "rewardName": "Название награды", + "cost": "Стоимость", + "stock": "Склад", + "exchangeSuccess": "Обмен успешен", + "exchangeFailed": "Ошибка обмена", + "insufficientPoints": "Недостаточно баллов", + "outOfStock": "Нет в наличии" + }, + "rewardSettings": { + "title": "Настройки наград", + "addReward": "Добавить награду", + "editReward": "Редактировать награду", + "deleteReward": "Удалить награду", + "name": "Название", + "cost": "Стоимость", + "stock": "Склад", + "noRewards": "Нет наград", + "addTitle": "Добавить награду", + "editTitle": "Редактировать награду", + "namePlaceholder": "Введите название", + "costPlaceholder": "Введите стоимость", + "stockPlaceholder": "Введите склад", + "confirmDelete": "Подтвердить удаление этой награды?", + "saved": "Сохранено", + "saveFailed": "Ошибка сохранения", + "deleted": "Удалено", + "deleteFailed": "Ошибка удаления", + "rewardExists": "Награда уже существует" + }, + "recovery": { + "title": "Строка восстановления SecScore", + "saved": "Я сохранил", + "export": "Экспорт текстового файла", + "exportHint": "Рекомендуется экспортировать и хранить в автономном режиме. При потере восстановить невозможно.", + "hint": "Рекомендуется экспортировать и хранить в автономном режиме после экспорта. При потере восстановить невозможно." + } +} diff --git a/src/services/scoreSyncService.ts b/src/services/scoreSyncService.ts new file mode 100644 index 0000000..b31c7e9 --- /dev/null +++ b/src/services/scoreSyncService.ts @@ -0,0 +1,300 @@ +/** + * SECTL 积分数据同步服务 + * 使用 KV 存储同步 SecScore 的积分数据 + */ + +import { sectlKVStorage } from "./sectlKVStorage" +import { sectlAuth } from "./sectlAuth" + +// 数据结构定义 +export interface ScoreData { + studentId: string + studentName: string + currentScore: number + lastUpdated: string +} + +export interface ScoreEvent { + id: string + studentId: string + scoreChange: number + reason: string + createdAt: string +} + +export interface SyncedData { + version: string + lastSyncTime: string + students: ScoreData[] + events: ScoreEvent[] + settings: { + reasons: any[] + tags: any[] + } +} + +// 数据键名 +const DATA_KEYS = { + STUDENTS: "secscore_students", + EVENTS: "secscore_events", + SETTINGS: "secscore_settings", + SYNC_META: "secscore_sync_metadata", +} + +class ScoreSyncService { + /** + * 同步学生数据到云端 + */ + async syncStudents(students: any[]): Promise { + if (!sectlAuth.isAuthenticated()) { + throw new Error("未授权,请先登录") + } + + try { + // 转换数据格式 + const scoreData: ScoreData[] = students.map((student) => ({ + studentId: student.id, + studentName: student.name, + currentScore: student.score || 0, + lastUpdated: new Date().toISOString(), + })) + + // 保存到云端 + await sectlKVStorage.setKV(DATA_KEYS.STUDENTS, scoreData, { + is_json: true, + }) + + console.log("学生数据同步成功") + } catch (error: any) { + console.error("同步学生数据失败:", error) + throw new Error(`同步失败:${error.message}`) + } + } + + /** + * 从云端加载学生数据 + */ + async loadStudents(): Promise { + if (!sectlAuth.isAuthenticated()) { + throw new Error("未授权,请先登录") + } + + try { + const result = await sectlKVStorage.getKV(DATA_KEYS.STUDENTS) + return result.value || [] + } catch (error: any) { + if (error.message.includes("不存在")) { + return [] + } + throw error + } + } + + /** + * 同步积分事件到云端 + */ + async syncEvents(events: any[]): Promise { + if (!sectlAuth.isAuthenticated()) { + throw new Error("未授权,请先登录") + } + + try { + const scoreEvents: ScoreEvent[] = events.map((event) => ({ + id: event.id, + studentId: event.student_id, + scoreChange: event.score_change, + reason: event.reason, + createdAt: event.created_at, + })) + + await sectlKVStorage.setKV(DATA_KEYS.EVENTS, scoreEvents, { + is_json: true, + }) + + console.log("积分事件同步成功") + } catch (error: any) { + console.error("同步积分事件失败:", error) + throw new Error(`同步失败:${error.message}`) + } + } + + /** + * 从云端加载积分事件 + */ + async loadEvents(): Promise { + if (!sectlAuth.isAuthenticated()) { + throw new Error("未授权,请先登录") + } + + try { + const result = await sectlKVStorage.getKV(DATA_KEYS.EVENTS) + return result.value || [] + } catch (error: any) { + if (error.message.includes("不存在")) { + return [] + } + throw error + } + } + + /** + * 同步设置数据(理由、标签等) + */ + async syncSettings(settings: { reasons: any[]; tags: any[] }): Promise { + if (!sectlAuth.isAuthenticated()) { + throw new Error("未授权,请先登录") + } + + try { + await sectlKVStorage.setKV(DATA_KEYS.SETTINGS, settings, { + is_json: true, + }) + + console.log("设置数据同步成功") + } catch (error: any) { + console.error("同步设置数据失败:", error) + throw new Error(`同步失败:${error.message}`) + } + } + + /** + * 从云端加载设置数据 + */ + async loadSettings(): Promise<{ reasons: any[]; tags: any[] }> { + if (!sectlAuth.isAuthenticated()) { + throw new Error("未授权,请先登录") + } + + try { + const result = await sectlKVStorage.getKV(DATA_KEYS.SETTINGS) + return result.value || { reasons: [], tags: [] } + } catch (error: any) { + if (error.message.includes("不存在")) { + return { reasons: [], tags: [] } + } + throw error + } + } + + /** + * 更新同步元数据 + */ + async updateSyncMetadata(): Promise { + const metadata = { + version: "1.0", + lastSyncTime: new Date().toISOString(), + platform: "secscore", + } + + await sectlKVStorage.setKV(DATA_KEYS.SYNC_META, metadata, { + is_json: true, + }) + } + + /** + * 获取同步元数据 + */ + async getSyncMetadata(): Promise<{ + version: string + lastSyncTime: string + platform: string + } | null> { + try { + const result = await sectlKVStorage.getKV(DATA_KEYS.SYNC_META) + return result.value + } catch { + return null + } + } + + /** + * 完整同步(所有数据) + */ + async fullSync(data: { + students: any[] + events: any[] + settings: { reasons: any[]; tags: any[] } + }): Promise { + if (!sectlAuth.isAuthenticated()) { + throw new Error("未授权,请先登录") + } + + try { + await this.syncStudents(data.students) + await this.syncEvents(data.events) + await this.syncSettings(data.settings) + await this.updateSyncMetadata() + + console.log("完整数据同步成功") + } catch (error: any) { + console.error("完整数据同步失败:", error) + throw error + } + } + + /** + * 从云端恢复所有数据 + */ + async restoreFromCloud(): Promise<{ + students: ScoreData[] + events: ScoreEvent[] + settings: { reasons: any[]; tags: any[] } + metadata: any + }> { + if (!sectlAuth.isAuthenticated()) { + throw new Error("未授权,请先登录") + } + + try { + const [students, events, settings, metadata] = await Promise.all([ + this.loadStudents(), + this.loadEvents(), + this.loadSettings(), + this.getSyncMetadata(), + ]) + + return { students, events, settings, metadata } + } catch (error: any) { + console.error("从云端恢复数据失败:", error) + throw error + } + } + + /** + * 检查云端是否有数据 + */ + async hasCloudData(): Promise { + try { + const students = await this.loadStudents() + return students.length > 0 + } catch { + return false + } + } + + /** + * 清除云端数据 + */ + async clearCloudData(): Promise { + if (!sectlAuth.isAuthenticated()) { + throw new Error("未授权,请先登录") + } + + try { + await Promise.all([ + sectlKVStorage.deleteKV(DATA_KEYS.STUDENTS), + sectlKVStorage.deleteKV(DATA_KEYS.EVENTS), + sectlKVStorage.deleteKV(DATA_KEYS.SETTINGS), + sectlKVStorage.deleteKV(DATA_KEYS.SYNC_META), + ]) + + console.log("云端数据已清除") + } catch (error: any) { + console.error("清除云端数据失败:", error) + throw error + } + } +} + +// 导出单例 +export const scoreSyncService = new ScoreSyncService() diff --git a/src/services/sectl.ts b/src/services/sectl.ts new file mode 100644 index 0000000..f6c9ff1 --- /dev/null +++ b/src/services/sectl.ts @@ -0,0 +1,24 @@ +/** + * SECTL 服务统一导出 + */ + +export { sectlAuth, SECTL_CONFIG } from "./sectlAuth" + +export type { TokenData, UserInfo } from "./sectlAuth" + +export { sectlCloudStorage } from "./sectlCloudStorage" + +export type { CloudFile, ShareLink, StorageUsage } from "./sectlCloudStorage" + +export { sectlKVStorage } from "./sectlKVStorage" + +export type { KVData } from "./sectlCloudStorage" + +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 new file mode 100644 index 0000000..77bc9ec --- /dev/null +++ b/src/services/sectlAuth.ts @@ -0,0 +1,388 @@ +/** + * SECTL OAuth 2.0 认证服务 + * 基于 SECTL-One-Stop 项目的 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", + callbackPort: 5173, +} + +// Token 数据类型 +export interface TokenData { + access_token: string + refresh_token: string + token_type: string + expires_in: number + user_id: string +} + +// 用户信息类型 +export interface UserInfo { + user_id: string + email: string + name: string + github_username?: string + permission: string + role: string + avatar_url?: string + background_url?: string + bio: string + tags: string[] + platform_id?: string + login_time?: string +} + +// PKCE 相关工具函数 +function generateCodeVerifier(): string { + 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))) +} + +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) + } + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +class SectlAuthService { + private client: Client + private tokenData: TokenData | null = null + private codeVerifier: string = "" + + 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) + + const params = new URLSearchParams({ + client_id: SECTL_CONFIG.platformId, + redirect_uri: SECTL_CONFIG.callbackUrl, + response_type: "code", + code_challenge: codeChallenge, + code_challenge_method: "S256", + 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 中生成 + + // 构建授权 URL + const authUrl = 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) + } + } + + checkCallback() + }) + } + + /** + * 使用授权码换取 Token + */ + async exchangeCodeForToken(code: string, scope: string[] = ["user:read"]): Promise { + const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token` + + const payload = { + grant_type: "authorization_code", + code, + client_id: SECTL_CONFIG.platformId, + redirect_uri: SECTL_CONFIG.callbackUrl, + code_verifier: this.codeVerifier, + scope: scope.join(" "), + } + + 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.tokenData = data + this.saveToken() + + return data + } catch (error) { + console.error("Token 交换失败:", error) + throw error + } + } + + /** + * 获取用户信息 + */ + async getUserInfo(): Promise { + if (!this.tokenData?.access_token) { + throw new Error("未授权,请先登录") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/oauth/userinfo` + + try { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${this.tokenData.access_token}`, + }, + }) + + 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 + } + } + + /** + * 验证 Token + */ + async introspectToken(token?: string): Promise<{ active: boolean; user_id?: string }> { + const tokenToCheck = token || this.tokenData?.access_token + + if (!tokenToCheck) { + return { active: false } + } + + const url = `${SECTL_CONFIG.baseUrl}/api/oauth/introspect` + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: tokenToCheck, + client_id: SECTL_CONFIG.platformId, + }), + }) + + if (!response.ok) { + return { active: false } + } + + return await response.json() + } catch (error) { + console.error("Token 验证失败:", error) + return { active: false } + } + } + + /** + * 刷新 Token + */ + async refreshAccessToken(): Promise { + if (!this.tokenData?.refresh_token) { + throw new Error("没有刷新令牌") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token` + + const payload = { + grant_type: "refresh_token", + refresh_token: this.tokenData.refresh_token, + client_id: SECTL_CONFIG.platformId, + } + + 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.tokenData = data + this.saveToken() + + return data + } catch (error) { + console.error("Token 刷新失败:", error) + throw error + } + } + + /** + * 登出(撤销 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}`, + }, + }) + } catch (error) { + console.error("登出失败:", error) + } finally { + this.tokenData = 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) + } + } + + /** + * 从本地存储加载 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 || "" + } + } catch (error) { + console.error("加载 Token 失败:", error) + } + } + + /** + * 清除 Token + */ + private clearToken(): void { + localStorage.removeItem("sectl_token") + localStorage.removeItem("sectl_code_verifier") + this.tokenData = null + this.codeVerifier = "" + } +} + +// 导出单例 +export const sectlAuth = new SectlAuthService() diff --git a/src/services/sectlCloudStorage.ts b/src/services/sectlCloudStorage.ts new file mode 100644 index 0000000..365711f --- /dev/null +++ b/src/services/sectlCloudStorage.ts @@ -0,0 +1,596 @@ +/** + * SECTL 云存储服务 + * 提供文件上传、下载、管理、分享等功能 + */ + +import { SECTL_CONFIG, sectlAuth } from "./sectlAuth" + +// 文件信息类型 +export interface CloudFile { + file_id: string + filename: string + extension: string + size: number + size_formatted: string + created_at: string + updated_at: string + url: string + thumbnail_url?: string +} + +// 分享信息类型 +export interface ShareLink { + share_id: string + share_url: string + file_id: string + filename: string + expires_at?: string + has_password: boolean + view_count?: number + download_count?: number + status: "active" | "disabled" | "expired" +} + +// KV 存储类型 +export interface KVData { + kv_id: string + key: string + value: any + is_json: boolean + size: number + created_at: string + updated_at: string +} + +// 存储使用情况 +export interface StorageUsage { + used_storage: number + used_storage_formatted: string + total_storage: number + total_storage_formatted: string + available_storage: number + available_storage_formatted: string + percentage: number + file_count: number +} + +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 + } + } + + /** + * 上传文件 + */ + 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("未授权,请先登录") + } + + const formData = new FormData() + 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) + } + + // 如果是 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 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 + } + } + + /** + * 获取文件预览链接 + */ + async previewFile(fileId: string): Promise<{ view_url: string; expires_at: string }> { + const accessToken = sectlAuth.getAccessToken() + if (!accessToken) { + throw new Error("未授权,请先登录") + } + + const params = new URLSearchParams({ + client_id: SECTL_CONFIG.platformId, + }) + + const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/preview?${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 + } + } + + /** + * 删除文件 + */ + 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<{ + 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 + } + } + + /** + * 获取分享列表 + */ + 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 url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares?${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 + } + } + + /** + * 禁用分享 + */ + async disableShare(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}/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 + } + } + + /** + * 启用分享 + */ + async enableShare(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}/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 + } + } + + /** + * 删除分享 + */ + 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 url = `${SECTL_CONFIG.baseUrl}/api/cloud/storage/usage?${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 + } + } +} + +// 导出单例 +export const sectlCloudStorage = new SectlCloudStorageService() diff --git a/src/services/sectlKVStorage.ts b/src/services/sectlKVStorage.ts new file mode 100644 index 0000000..5220e51 --- /dev/null +++ b/src/services/sectlKVStorage.ts @@ -0,0 +1,256 @@ +/** + * SECTL KV 键值对存储服务 + * 提供键值对存储功能,支持 JSON 格式和字段级操作 + */ + +import { SECTL_CONFIG, sectlAuth } from "./sectlAuth" +import { KVData } from "./sectlCloudStorage" + +class SectlKVStorageService { + /** + * 创建或更新键值对 + */ + async setKV( + key: string, + value: any, + options?: { + is_json?: boolean + } + ): Promise<{ + success: boolean + kv_id: string + key: string + value: any + is_json: boolean + size: number + created_at: string + updated_at: string + }> { + const accessToken = sectlAuth.getAccessToken() + if (!accessToken) { + throw new Error("未授权,请先登录") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv` + + // 自动检测是否为 JSON 格式 + let valueStr: string + let isJson = options?.is_json + + if (typeof value === "object") { + valueStr = JSON.stringify(value) + if (isJson === undefined) { + isJson = true + } + } else { + valueStr = String(value) + if (isJson === undefined) { + // 尝试检测是否为 JSON 字符串 + try { + JSON.parse(valueStr) + isJson = true + } catch { + isJson = false + } + } + } + + 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 || "", + key, + value: valueStr, + is_json: isJson, + }), + }) + + 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 getKV( + key: string, + options?: { + field?: string // 获取 JSON 中的特定字段 + } + ): 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 || "", + }) + + if (options?.field) { + params.append("field", options.field) + } + + const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}?${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 + } + } + + /** + * 获取键值对列表 + */ + async listKV(options?: { + limit?: number + offset?: number + }): Promise<{ kvs: KVData[]; 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), + }) + + const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv?${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 + } + } + + /** + * 更新 JSON 字段 + */ + async updateKVField( + key: string, + field: string, + value: any + ): Promise<{ + success: boolean + key: string + field: string + value: any + updated_at: string + }> { + const accessToken = sectlAuth.getAccessToken() + if (!accessToken) { + throw new Error("未授权,请先登录") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}` + + try { + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: SECTL_CONFIG.platformId, + user_id: sectlAuth.getToken()?.user_id || "", + field, + value, + }), + }) + + 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 deleteKV(key: string): Promise<{ success: boolean; message: string }> { + const accessToken = sectlAuth.getAccessToken() + if (!accessToken) { + throw new Error("未授权,请先登录") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}` + + 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 + } + } +} + +// 导出单例 +export const sectlKVStorage = new SectlKVStorageService() diff --git a/src/services/sectlNotification.ts b/src/services/sectlNotification.ts new file mode 100644 index 0000000..bc26891 --- /dev/null +++ b/src/services/sectlNotification.ts @@ -0,0 +1,225 @@ +/** + * SECTL 通知服务 + * 提供通知的获取、发送、管理等功能 + */ + +import { SECTL_CONFIG, sectlAuth } from "./sectlAuth" + +// 通知类型 +export interface Notification { + $id: string + user_id: string + title: string + content?: string + type: string + source?: string + source_id?: string + is_read: boolean + created_at: string + read_at?: string + action_url?: string + icon?: string + priority: "low" | "normal" | "high" + expires_at?: string +} + +// 发送通知参数 +export interface SendNotificationParams { + user_id: string + title: string + content?: string + type?: string + source_id?: string + action_url?: string + icon?: string + priority?: "low" | "normal" | "high" + expires_at?: string +} + +class SectlNotificationService { + /** + * 获取通知列表 + */ + async getNotifications(options?: { + limit?: number + offset?: number + unread_only?: boolean + }): Promise<{ notifications: Notification[]; total: number }> { + const accessToken = sectlAuth.getAccessToken() + if (!accessToken) { + throw new Error("未授权,请先登录") + } + + const params = new URLSearchParams({ + limit: String(options?.limit || 20), + offset: String(options?.offset || 0), + }) + + if (options?.unread_only) { + params.append("unread_only", "true") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/notifications?${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 + } + } + + /** + * 标记通知为已读 + */ + async markNotificationRead(notificationId: string): Promise<{ + success: boolean + notification: { + $id: string + is_read: boolean + read_at: string + } + }> { + const accessToken = sectlAuth.getAccessToken() + if (!accessToken) { + throw new Error("未授权,请先登录") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/notifications/${notificationId}/read` + + try { + const response = await fetch(url, { + method: "POST", + 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 + } + } + + /** + * 标记所有通知为已读 + */ + async markAllNotificationsRead(): Promise<{ success: boolean; marked_count: number }> { + const accessToken = sectlAuth.getAccessToken() + if (!accessToken) { + throw new Error("未授权,请先登录") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/notifications/read-all` + + try { + const response = await fetch(url, { + method: "POST", + 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 + } + } + + /** + * 删除通知 + */ + async deleteNotification(notificationId: string): Promise<{ success: boolean; message: string }> { + const accessToken = sectlAuth.getAccessToken() + if (!accessToken) { + throw new Error("未授权,请先登录") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/notifications/${notificationId}` + + try { + const response = await fetch(url, { + method: "DELETE", + 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 + } + } + + /** + * 发送通知(需要平台权限) + */ + async sendNotification(params: SendNotificationParams): Promise<{ + success: boolean + notification_id: string + message: string + }> { + const accessToken = sectlAuth.getAccessToken() + if (!accessToken) { + throw new Error("未授权,请先登录") + } + + const url = `${SECTL_CONFIG.baseUrl}/api/notifications/send` + + try { + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...params, + client_id: SECTL_CONFIG.platformId, + }), + }) + + 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 + } + } +} + +// 导出单例 +export const sectlNotification = new SectlNotificationService() diff --git a/src/views/SectlCloudView.tsx b/src/views/SectlCloudView.tsx new file mode 100644 index 0000000..31a0058 --- /dev/null +++ b/src/views/SectlCloudView.tsx @@ -0,0 +1,18 @@ +/** + * SECTL 数据同步视图 + * 提供 SecScore 积分数据的云端同步功能 + */ + +import React from "react" +import { SectlProvider } from "../contexts/SectlContext" +import { SectlSettingsPanel } from "../components/SectlSettingsPanel" + +export const ScoreSyncView: React.FC = () => { + return ( + +
+ +
+
+ ) +} From ee03046bbdfeb8aef2ee3965031236530ed75d8f Mon Sep 17 00:00:00 2001 From: Yukino_fox Date: Sat, 11 Apr 2026 12:21:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/auth.rs | 15 +- src-tauri/src/commands/settings.rs | 4 +- src-tauri/src/services/auto_score.rs | 311 +++++++++++++++------------ src-tauri/src/state.rs | 2 +- 4 files changed, 191 insertions(+), 141 deletions(-) diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 36fbbed..95741fd 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -362,8 +362,11 @@ pub async fn oauth_exchange_code( callback_url: String, state: State<'_, Arc>>, ) -> Result, String> { - println!("[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", code, platform_id, callback_url); - + println!( + "[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", + code, platform_id, callback_url + ); + let state_guard = state.read(); let client = &state_guard.http_client; let params = [ @@ -411,7 +414,7 @@ pub async fn oauth_revoke_token( ) -> Result, String> { let state_guard = state.read(); let client = &state_guard.http_client; - + let mut payload = serde_json::json!({ "token": token, "client_id": platform_id, @@ -449,7 +452,7 @@ pub async fn oauth_introspect_token( ) -> Result, String> { let state_guard = state.read(); let client = &state_guard.http_client; - + let response = client .post("https://sectl.top/api/oauth/introspect") .json(&serde_json::json!({ @@ -484,7 +487,7 @@ pub async fn oauth_get_user_info( ) -> Result, String> { let state_guard = state.read(); let client = &state_guard.http_client; - + let response = client .get("https://sectl.top/api/oauth/userinfo") .header("Authorization", format!("Bearer {}", access_token)) @@ -517,7 +520,7 @@ pub async fn oauth_refresh_token( ) -> Result, String> { let state_guard = state.read(); let client = &state_guard.http_client; - + let response = client .post("https://sectl.top/api/oauth/token") .json(&serde_json::json!({ diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 9e20276..f4704cb 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -2,10 +2,10 @@ use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::BTreeSet; -use std::sync::Arc; -use tauri::{AppHandle, Emitter, State}; #[cfg(any(target_os = "linux", target_os = "macos"))] use std::process::Command; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, State}; #[cfg(target_os = "windows")] use winreg::enums::HKEY_LOCAL_MACHINE; #[cfg(target_os = "windows")] diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index d44326d..de642cc 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Months, Utc}; use sea_orm::{ - ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, - QueryFilter, Set, Statement, TransactionTrait, + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, + Set, Statement, TransactionTrait, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as JsonValue}; @@ -497,7 +497,9 @@ fn normalize_execution_config( Ok(config) } -fn normalize_filter_config(mut config: AutoScoreFilterConfig) -> Result { +fn normalize_filter_config( + mut config: AutoScoreFilterConfig, +) -> Result { config.groups = dedupe_trimmed_strings(config.groups); config.grades = dedupe_trimmed_strings(config.grades); if let (Some(min_score), Some(max_score)) = (config.min_score, config.max_score) { @@ -513,7 +515,10 @@ fn normalize_filter_config(mut config: AutoScoreFilterConfig) -> Result JsonValue { JsonValue::Object( [ - ("id".to_string(), JsonValue::String(Uuid::new_v4().to_string())), + ( + "id".to_string(), + JsonValue::String(Uuid::new_v4().to_string()), + ), ("type".to_string(), JsonValue::String("group".to_string())), ( "properties".to_string(), @@ -588,7 +593,10 @@ fn build_trigger_rule_node(trigger: &AutoScoreTrigger) -> JsonValue { JsonValue::Object( [ - ("id".to_string(), JsonValue::String(Uuid::new_v4().to_string())), + ( + "id".to_string(), + JsonValue::String(Uuid::new_v4().to_string()), + ), ("type".to_string(), JsonValue::String("rule".to_string())), ( "properties".to_string(), @@ -681,7 +689,10 @@ fn normalize_trigger_tree_group(node: JsonValue) -> Result { "not": not, }), ), - ("children1".to_string(), JsonValue::Array(normalized_children)), + ( + "children1".to_string(), + JsonValue::Array(normalized_children), + ), ] .into_iter() .collect(), @@ -817,7 +828,11 @@ fn trigger_from_rule_node(node: &JsonValue) -> Result }; let trigger = AutoScoreTrigger { event: event.to_string(), - value: if score.trim().is_empty() { None } else { Some(score) }, + value: if score.trim().is_empty() { + None + } else { + Some(score) + }, }; normalize_trigger(trigger) } @@ -1003,7 +1018,11 @@ fn parse_positive_i64(value: &JsonValue) -> Option { fn parse_non_negative_i64(value: &JsonValue) -> Option { match value { JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed >= 0), - JsonValue::String(text) => text.trim().parse::().ok().filter(|parsed| *parsed >= 0), + JsonValue::String(text) => text + .trim() + .parse::() + .ok() + .filter(|parsed| *parsed >= 0), _ => None, } } @@ -1060,12 +1079,10 @@ fn stringify_interval_trigger_value(value: &IntervalTriggerValue) -> Option Some(amount.to_string()), - IntervalUnit::Day | IntervalUnit::Month => serde_json::to_string( - &json!({ - "amount": amount, - "unit": unit, - }), - ) + IntervalUnit::Day | IntervalUnit::Month => serde_json::to_string(&json!({ + "amount": amount, + "unit": unit, + })) .ok(), } } @@ -1097,9 +1114,7 @@ fn add_interval_to_time( IntervalUnit::Minute => { base_time.checked_add_signed(chrono::Duration::minutes(*amount)) } - IntervalUnit::Day => { - base_time.checked_add_signed(chrono::Duration::days(*amount)) - } + IntervalUnit::Day => base_time.checked_add_signed(chrono::Duration::days(*amount)), IntervalUnit::Month => { let months = u32::try_from(*amount).ok()?; base_time.checked_add_months(Months::new(months)) @@ -1305,7 +1320,9 @@ fn deserialize_batches(value: &JsonValue) -> Vec { .collect() } -async fn load_batches_from_settings(state: &SafeAppState) -> Result, String> { +async fn load_batches_from_settings( + state: &SafeAppState, +) -> Result, String> { let state_guard = state.read(); let db_conn = state_guard.db.read().clone(); let mut settings = state_guard.settings.write(); @@ -1334,7 +1351,9 @@ async fn save_batches_to_settings( Ok(()) } -pub async fn query_execution_batches(state: &SafeAppState) -> Result, String> { +pub async fn query_execution_batches( + state: &SafeAppState, +) -> Result, String> { let mut batches = load_batches_from_settings(state).await?; batches.sort_by(|a, b| b.run_at.cmp(&a.run_at)); Ok(batches) @@ -1444,7 +1463,9 @@ async fn execute_rule( if let Some(max_runs) = rule.execution.max_runs_per_day { let today_runs = execution_batches .iter() - .filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at)) + .filter(|batch| { + batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at) + }) .count() as i64; if today_runs >= max_runs { return Ok(RuleExecutionStats::default()); @@ -1461,7 +1482,9 @@ async fn execute_rule( let mut daily_score_delta_used: i64 = execution_batches .iter() - .filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at)) + .filter(|batch| { + batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at) + }) .map(|batch| batch.score_delta_total.abs()) .sum(); @@ -1571,9 +1594,9 @@ async fn execute_rule( } fn plan_actions(actions: &[AutoScoreAction]) -> Result, String> { - let mut planned = Vec::new(); - for action in actions { - match action.event.as_str() { + let mut planned = Vec::new(); + for action in actions { + match action.event.as_str() { "add_score" => { let delta = action .value @@ -1594,57 +1617,62 @@ fn plan_actions(actions: &[AutoScoreAction]) -> Result, Strin planned.push(PlannedAction::SettleScore); } _ => {} + } } - } - Ok(planned) + Ok(planned) } async fn execute_settlement(conn: &DatabaseConnection) -> Result<(), String> { - let backend = conn.get_database_backend(); - let count_stmt = Statement::from_string( - backend, - "SELECT COUNT(*) AS cnt FROM score_events WHERE settlement_id IS NULL", - ); - let count_row = conn.query_one(count_stmt).await.map_err(|e| e.to_string())?; - let Some(count_row) = count_row else { - return Ok(()); - }; - let unsettled_count = try_get_i64(&count_row, "cnt").unwrap_or(0); - if unsettled_count <= 0 { - return Ok(()); - } - - let last_end_sql = match backend { - sea_orm::DatabaseBackend::Sqlite => { - "SELECT end_time AS end_time FROM settlements ORDER BY julianday(end_time) DESC LIMIT 1" + let backend = conn.get_database_backend(); + let count_stmt = Statement::from_string( + backend, + "SELECT COUNT(*) AS cnt FROM score_events WHERE settlement_id IS NULL", + ); + let count_row = conn + .query_one(count_stmt) + .await + .map_err(|e| e.to_string())?; + let Some(count_row) = count_row else { + return Ok(()); + }; + let unsettled_count = try_get_i64(&count_row, "cnt").unwrap_or(0); + if unsettled_count <= 0 { + return Ok(()); } - sea_orm::DatabaseBackend::Postgres => { - "SELECT end_time AS end_time FROM settlements ORDER BY end_time DESC LIMIT 1" - } - _ => "SELECT end_time AS end_time FROM settlements ORDER BY end_time DESC LIMIT 1", - }; - let min_event_sql = - "SELECT MIN(event_time) AS min_event_time FROM score_events WHERE settlement_id IS NULL"; - let last_end = conn - .query_one(Statement::from_string(backend, last_end_sql)) - .await - .map_err(|e| e.to_string())? - .and_then(|row| try_get_string(&row, "end_time")); + let last_end_sql = match backend { + sea_orm::DatabaseBackend::Sqlite => { + "SELECT end_time AS end_time FROM settlements ORDER BY julianday(end_time) DESC LIMIT 1" + } + sea_orm::DatabaseBackend::Postgres => { + "SELECT end_time AS end_time FROM settlements ORDER BY end_time DESC LIMIT 1" + } + _ => "SELECT end_time AS end_time FROM settlements ORDER BY end_time DESC LIMIT 1", + }; + let min_event_sql = + "SELECT MIN(event_time) AS min_event_time FROM score_events WHERE settlement_id IS NULL"; - let min_event_time = conn - .query_one(Statement::from_string(backend, min_event_sql)) - .await - .map_err(|e| e.to_string())? - .and_then(|row| try_get_string(&row, "min_event_time")); + let last_end = conn + .query_one(Statement::from_string(backend, last_end_sql)) + .await + .map_err(|e| e.to_string())? + .and_then(|row| try_get_string(&row, "end_time")); - let end_time = now_iso(); - let start_time = last_end.or(min_event_time).unwrap_or_else(|| end_time.clone()); - let created_at = end_time.clone(); + let min_event_time = conn + .query_one(Statement::from_string(backend, min_event_sql)) + .await + .map_err(|e| e.to_string())? + .and_then(|row| try_get_string(&row, "min_event_time")); - match backend { - sea_orm::DatabaseBackend::Postgres => { - let insert_stmt = Statement::from_string( + let end_time = now_iso(); + let start_time = last_end + .or(min_event_time) + .unwrap_or_else(|| end_time.clone()); + let created_at = end_time.clone(); + + match backend { + sea_orm::DatabaseBackend::Postgres => { + let insert_stmt = Statement::from_string( backend, format!( "INSERT INTO settlements (start_time, end_time, created_at) VALUES ('{}', '{}', '{}') RETURNING id", @@ -1653,74 +1681,82 @@ async fn execute_settlement(conn: &DatabaseConnection) -> Result<(), String> { created_at.replace('\'', "''") ), ); - let row = conn - .query_one(insert_stmt) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "Failed to create settlement".to_string())?; - let settlement_id = try_get_i32(&row, "id") - .ok_or_else(|| "Failed to read settlement id".to_string())?; + let row = conn + .query_one(insert_stmt) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Failed to create settlement".to_string())?; + let settlement_id = try_get_i32(&row, "id") + .ok_or_else(|| "Failed to read settlement id".to_string())?; - let update_events_stmt = Statement::from_string( - backend, - format!( - "UPDATE score_events SET settlement_id = {} WHERE settlement_id IS NULL", - settlement_id - ), - ); - conn.execute(update_events_stmt).await.map_err(|e| e.to_string())?; + let update_events_stmt = Statement::from_string( + backend, + format!( + "UPDATE score_events SET settlement_id = {} WHERE settlement_id IS NULL", + settlement_id + ), + ); + conn.execute(update_events_stmt) + .await + .map_err(|e| e.to_string())?; - let update_students_stmt = Statement::from_string( - backend, - format!( - "UPDATE students SET score = 0, updated_at = '{}' ", - end_time.replace('\'', "''") - ), - ); - conn.execute(update_students_stmt).await.map_err(|e| e.to_string())?; - } - _ => { - let insert_stmt = Statement::from_string( - backend, - format!( + let update_students_stmt = Statement::from_string( + backend, + format!( + "UPDATE students SET score = 0, updated_at = '{}' ", + end_time.replace('\'', "''") + ), + ); + conn.execute(update_students_stmt) + .await + .map_err(|e| e.to_string())?; + } + _ => { + let insert_stmt = Statement::from_string( + backend, + format!( "INSERT INTO settlements (start_time, end_time, created_at) VALUES ('{}', '{}', '{}')", start_time.replace('\'', "''"), end_time.replace('\'', "''"), created_at.replace('\'', "''") ), - ); - conn.execute(insert_stmt).await.map_err(|e| e.to_string())?; + ); + conn.execute(insert_stmt).await.map_err(|e| e.to_string())?; - let id_stmt = Statement::from_string(backend, "SELECT last_insert_rowid() AS id"); - let row = conn - .query_one(id_stmt) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "Failed to read settlement id".to_string())?; - let settlement_id = try_get_i32(&row, "id") - .ok_or_else(|| "Failed to read settlement id".to_string())?; + let id_stmt = Statement::from_string(backend, "SELECT last_insert_rowid() AS id"); + let row = conn + .query_one(id_stmt) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Failed to read settlement id".to_string())?; + let settlement_id = try_get_i32(&row, "id") + .ok_or_else(|| "Failed to read settlement id".to_string())?; - let update_events_stmt = Statement::from_string( - backend, - format!( - "UPDATE score_events SET settlement_id = {} WHERE settlement_id IS NULL", - settlement_id - ), - ); - conn.execute(update_events_stmt).await.map_err(|e| e.to_string())?; + let update_events_stmt = Statement::from_string( + backend, + format!( + "UPDATE score_events SET settlement_id = {} WHERE settlement_id IS NULL", + settlement_id + ), + ); + conn.execute(update_events_stmt) + .await + .map_err(|e| e.to_string())?; - let update_students_stmt = Statement::from_string( - backend, - format!( - "UPDATE students SET score = 0, updated_at = '{}'", - end_time.replace('\'', "''") - ), - ); - conn.execute(update_students_stmt).await.map_err(|e| e.to_string())?; + let update_students_stmt = Statement::from_string( + backend, + format!( + "UPDATE students SET score = 0, updated_at = '{}'", + end_time.replace('\'', "''") + ), + ); + conn.execute(update_students_stmt) + .await + .map_err(|e| e.to_string())?; + } } - } - Ok(()) + Ok(()) } #[derive(Debug, Default)] @@ -1729,7 +1765,10 @@ struct TriggerEvalContext { sql_refs_by_query: HashMap, } -fn collect_sql_queries_from_tree(node: &JsonValue, queries: &mut Vec) -> Result<(), String> { +fn collect_sql_queries_from_tree( + node: &JsonValue, + queries: &mut Vec, +) -> Result<(), String> { let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { return Err("Trigger tree node type is required".to_string()); }; @@ -1751,7 +1790,12 @@ fn collect_sql_queries_from_tree(node: &JsonValue, queries: &mut Vec) -> trigger.event.as_str(), "query_sql" | "student_query_sql" | "student_sql" ) { - if let Some(sql) = trigger.value.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + if let Some(sql) = trigger + .value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { queries.push(sql.to_string()); } } @@ -1866,7 +1910,9 @@ fn evaluate_trigger_tree_for_student( .to_string(); ctx.sql_refs_by_query .get(&sql) - .map(|refs| refs.ids.contains(&student.id) || refs.names.contains(&student.name)) + .map(|refs| { + refs.ids.contains(&student.id) || refs.names.contains(&student.name) + }) .unwrap_or(false) } "student_score_gt" => trigger @@ -1890,8 +1936,8 @@ fn evaluate_trigger_tree_for_student( } async fn resolve_target_students( - conn: &DatabaseConnection, - rule: &AutoScoreRule, + conn: &DatabaseConnection, + rule: &AutoScoreRule, ) -> Result, String> { let all_students = students::Entity::find() .all(conn) @@ -1929,7 +1975,6 @@ async fn resolve_target_students( Ok(matched) } - async fn is_student_pass_cooldown( conn: &DatabaseConnection, execution_batches: &[AutoScoreExecutionBatch], @@ -1953,16 +1998,18 @@ async fn is_student_pass_cooldown( let run_at = DateTime::parse_from_rfc3339(batch.run_at.as_str()) .map(|value| value.with_timezone(&Utc)) .unwrap_or_else(|_| Utc::now() - chrono::Duration::days(3650)); - if run_at >= cutoff && batch.affected_student_names.iter().any(|name| name == student_name) + if run_at >= cutoff + && batch + .affected_student_names + .iter() + .any(|name| name == student_name) { return Ok(false); } } let prefix = format!("{}#{}:", AUTO_SCORE_REASON_PREFIX, rule.id); - let since = cutoff - .format("%Y-%m-%dT%H:%M:%S%.3fZ") - .to_string(); + let since = cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); let exists = score_events::Entity::find() .filter(score_events::Column::StudentName.eq(student_name.to_string())) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index db83217..8dffb7c 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -35,7 +35,7 @@ impl AppState { let logger = Arc::new(RwLock::new(LoggerService::new())); let data = Arc::new(RwLock::new(DataService::new())); let db = Arc::new(RwLock::new(None)); - + let http_client = Client::builder() .timeout(std::time::Duration::from_secs(30)) .build()