chore: 完成项目多维度优化与功能迭代

本次提交包含多项改进:
1. 新增Vitest测试配置与相关测试用例
2. 完善Deep Link监听与OAuth回调支持
3. 修复并优化多个React组件的依赖与性能
4. 更新后端API域名与端点地址
5. 优化KV存储服务实现与类型定义
6. 调整用户信息展示与权限相关逻辑
7. 重构部分业务逻辑,使用useCallback优化性能
8. 更新依赖包与Cargo.lock依赖版本
This commit is contained in:
Yukino_fox
2026-07-05 10:28:36 +08:00
parent 6f197babf6
commit 21c1183b1d
32 changed files with 1889 additions and 1280 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -14,7 +14,9 @@
"format": "prettier --write .",
"lint": "eslint --cache .",
"lint:fix": "eslint --cache . --fix",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
@@ -41,6 +43,7 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/ui": "^4.1.9",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-react": "^7.37.5",
@@ -49,6 +52,7 @@
"prettier": "^3.7.4",
"typescript": "^5.9.3",
"typescript-eslint": "^8.32.0",
"vite": "^7.2.6"
"vite": "^7.2.6",
"vitest": "^4.1.9"
}
}
+301
View File
@@ -75,6 +75,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^5.1.1
version: 5.2.0(vite@7.3.1(@types/node@22.19.15))
'@vitest/ui':
specifier: ^4.1.9
version: 4.1.9(vitest@4.1.9)
eslint:
specifier: ^9.39.1
version: 9.39.4
@@ -102,6 +105,9 @@ importers:
vite:
specifier: ^7.2.6
version: 7.3.1(@types/node@22.19.15)
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@22.19.15)(@vitest/ui@4.1.9)(vite@7.3.1(@types/node@22.19.15))
packages:
@@ -459,6 +465,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@rc-component/async-validator@5.1.0':
resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==}
engines: {node: '>=14.x'}
@@ -1052,9 +1061,15 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1148,6 +1163,40 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
'@vitest/expect@4.1.9':
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
'@vitest/mocker@4.1.9':
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.9':
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
'@vitest/runner@4.1.9':
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
'@vitest/snapshot@4.1.9':
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
'@vitest/spy@4.1.9':
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
'@vitest/ui@4.1.9':
resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==}
peerDependencies:
vitest: 4.1.9
'@vitest/utils@4.1.9':
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1209,6 +1258,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -1267,6 +1320,10 @@ packages:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -1382,6 +1439,9 @@ packages:
resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==}
engines: {node: '>= 0.4'}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -1476,10 +1536,17 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1498,6 +1565,9 @@ packages:
picomatch:
optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -1513,6 +1583,9 @@ packages:
flatted@3.4.1:
resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==}
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@@ -1820,6 +1893,9 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1834,6 +1910,10 @@ packages:
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1884,6 +1964,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
obug@2.1.3:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1915,6 +1999,9 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2187,6 +2274,13 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2203,6 +2297,12 @@ packages:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -2248,10 +2348,25 @@ packages:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
@@ -2351,6 +2466,47 @@ packages:
yaml:
optional: true
vitest@4.1.9:
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.9
'@vitest/browser-preview': 4.1.9
'@vitest/browser-webdriverio': 4.1.9
'@vitest/coverage-istanbul': 4.1.9
'@vitest/coverage-v8': 4.1.9
'@vitest/ui': 4.1.9
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
@@ -2376,6 +2532,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
@@ -2729,6 +2890,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@polka/url@1.0.0-next.29': {}
'@rc-component/async-validator@5.1.0':
dependencies:
'@babel/runtime': 7.28.6
@@ -3334,8 +3497,15 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/crypto-js@4.2.2': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14)':
@@ -3464,6 +3634,58 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@4.1.9':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.9(vite@7.3.1(@types/node@22.19.15))':
dependencies:
'@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@22.19.15)
'@vitest/pretty-format@4.1.9':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.9':
dependencies:
'@vitest/utils': 4.1.9
pathe: 2.0.3
'@vitest/snapshot@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
'@vitest/utils': 4.1.9
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.9': {}
'@vitest/ui@4.1.9(vitest@4.1.9)':
dependencies:
'@vitest/utils': 4.1.9
fflate: 0.8.3
flatted: 3.4.2
pathe: 2.0.3
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vitest: 4.1.9(@types/node@22.19.15)(@vitest/ui@4.1.9)(vite@7.3.1(@types/node@22.19.15))
'@vitest/utils@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -3603,6 +3825,8 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
assertion-error@2.0.1: {}
async-function@1.0.0: {}
available-typed-arrays@1.0.7:
@@ -3660,6 +3884,8 @@ snapshots:
adler-32: 1.3.1
crc-32: 1.2.2
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -3830,6 +4056,8 @@ snapshots:
math-intrinsics: 1.1.0
safe-array-concat: 1.1.3
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -3991,8 +4219,14 @@ snapshots:
estraverse@5.3.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
esutils@2.0.3: {}
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
@@ -4003,6 +4237,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fflate@0.8.3: {}
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -4019,6 +4255,8 @@ snapshots:
flatted@3.4.1: {}
flatted@3.4.2: {}
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
@@ -4328,6 +4566,10 @@ snapshots:
dependencies:
yallist: 3.1.1
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
minimatch@10.2.4:
@@ -4340,6 +4582,8 @@ snapshots:
moment@2.30.1: {}
mrmime@2.0.1: {}
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -4393,6 +4637,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
obug@2.1.3: {}
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -4426,6 +4672,8 @@ snapshots:
path-parse@1.0.7: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -4739,6 +4987,14 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
source-map-js@1.2.1: {}
spel2js@0.2.9: {}
@@ -4749,6 +5005,10 @@ snapshots:
dependencies:
frac: 1.1.2
stackback@0.0.2: {}
std-env@4.1.0: {}
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -4812,11 +5072,19 @@ snapshots:
throttle-debounce@5.0.2: {}
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinyrainbow@3.1.0: {}
totalist@3.0.1: {}
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -4906,6 +5174,34 @@ snapshots:
'@types/node': 22.19.15
fsevents: 2.3.3
vitest@4.1.9(@types/node@22.19.15)(@vitest/ui@4.1.9)(vite@7.3.1(@types/node@22.19.15)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@7.3.1(@types/node@22.19.15))
'@vitest/pretty-format': 4.1.9
'@vitest/runner': 4.1.9
'@vitest/snapshot': 4.1.9
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.3
pathe: 2.0.3
picomatch: 4.0.3
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.4
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 7.3.1(@types/node@22.19.15)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.15
'@vitest/ui': 4.1.9(vitest@4.1.9)
transitivePeerDependencies:
- msw
void-elements@3.1.0: {}
which-boxed-primitive@1.1.1:
@@ -4953,6 +5249,11 @@ snapshots:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wmf@1.0.2: {}
word-wrap@1.2.5: {}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -2128,9 +2128,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.11"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [
"memchr",
"serde",
+9 -9
View File
@@ -444,7 +444,7 @@ pub async fn oauth_get_authorization_url(
let code_challenge = generate_code_challenge(&code_verifier);
let url = format!(
"https://sectl.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&state={}&code_challenge={}&code_challenge_method=S256",
"https://sectl.cn/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&state={}&code_challenge={}&code_challenge_method=S256",
platform_id,
urlencoding::encode(&callback_url),
urlencoding::encode(&state),
@@ -490,7 +490,7 @@ pub async fn oauth_exchange_code(
println!("[OAuth] 请求参数:{:?}", payload);
let response = client
.post("https://appwrite.sectl.top/api/oauth/token")
.post("https://appwrite.sectl.cn/api/oauth/token")
.json(&payload)
.send()
.await
@@ -547,7 +547,7 @@ pub async fn oauth_revoke_token(
}
let response = client
.post("https://appwrite.sectl.top/api/oauth/revoke")
.post("https://appwrite.sectl.cn/api/oauth/revoke")
.json(&payload)
.send()
.await
@@ -574,7 +574,7 @@ pub async fn oauth_introspect_token(
let client = &state_guard.http_client;
let response = client
.post("https://appwrite.sectl.top/api/oauth/introspect")
.post("https://appwrite.sectl.cn/api/oauth/introspect")
.json(&serde_json::json!({
"token": token,
"client_id": platform_id,
@@ -613,7 +613,7 @@ pub async fn oauth_get_user_info(
let client = &state_guard.http_client;
let response = client
.get("https://appwrite.sectl.top/api/oauth/userinfo")
.get("https://appwrite.sectl.cn/api/oauth/userinfo")
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await
@@ -664,7 +664,7 @@ pub async fn oauth_refresh_token(
let client = &state_guard.http_client;
let response = client
.post("https://appwrite.sectl.top/api/oauth/token")
.post("https://appwrite.sectl.cn/api/oauth/refresh")
.json(&serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
@@ -719,7 +719,7 @@ pub async fn oauth_get_storage_usage(
let client = &state_guard.http_client;
let response = client
.get("https://appwrite.sectl.top/api/cloud/storage/usage")
.get("https://appwrite.sectl.cn/api/cloud-storage/usage")
.query(&[("client_id", platform_id), ("user_id", user_id)])
.header("Authorization", format!("Bearer {}", access_token))
.send()
@@ -787,7 +787,7 @@ pub async fn oauth_report_online(
}
let response = client
.post("https://appwrite.sectl.top/api/stats/online")
.post("https://appwrite.sectl.cn/api/stats/online")
.json(&payload)
.send()
.await
@@ -911,7 +911,7 @@ pub async fn oauth_refresh_access_token(
println!("[OAuth] 刷新 token 请求参数: {:?}", payload);
let response = client
.post("https://appwrite.sectl.top/api/oauth/token")
.post("https://appwrite.sectl.cn/api/oauth/refresh")
.json(&payload)
.send()
.await
+57 -4
View File
@@ -256,6 +256,57 @@ function MainContent(): React.JSX.Element {
}
}, [refreshPermissionFromAuth])
// 监听 Deep Link 事件(用于 OAuth 回调)
useEffect(() => {
const api = (window as any).api
if (!api || typeof api.onDeepLink !== "function") return
let disposed = false
let unlisten: (() => void) | null = null
api
.onDeepLink((url: string) => {
console.log("[DeepLink] Received URL:", url)
if (url.startsWith("secscore://oauth")) {
// 解析 OAuth 回调参数
const urlObj = new URL(url)
const code = urlObj.searchParams.get("code")
const state = urlObj.searchParams.get("state")
const error = urlObj.searchParams.get("error")
if (error) {
console.error("[DeepLink] OAuth error:", error)
messageApi.error("登录失败: " + error)
return
}
if (code && state) {
// 发送自定义事件,让 OAuthCallback 组件处理
window.dispatchEvent(
new CustomEvent("ss:oauth-deep-link", {
detail: { code, state },
})
)
}
}
})
.then((fn: () => void) => {
if (disposed) {
fn()
return
}
unlisten = fn
})
.catch((err: any) => {
console.error("[DeepLink] Failed to listen:", err)
})
return () => {
disposed = true
if (unlisten) unlisten()
}
}, [messageApi])
useEffect(() => {
const api = (window as any).api
if (!api || typeof api.onSettingChanged !== "function") return
@@ -531,10 +582,12 @@ function MainContent(): React.JSX.Element {
}, [messageApi, refreshPermissionFromAuth, t])
const handleOAuthSuccess = (userInfo: {
user_id: string
email: string
name: string
github_username?: string
user_id?: string
id?: string
email?: string
name?: string
avatar?: string
avatar_url?: string
}) => {
// OAuth 登录仅用于云服务,不修改本地权限
setOAuthUserName(userInfo.name || null)
+4 -1
View File
@@ -31,7 +31,10 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
}) => {
const { t } = useTranslation()
const fallbackDraft = useMemo(() => createDefaultActionDraft(), [])
const safeValue = value.length > 0 ? value : [fallbackDraft]
const safeValue = useMemo(
() => (value.length > 0 ? value : [fallbackDraft]),
[value, fallbackDraft]
)
useEffect(() => {
if (value.length === 0) {
+15 -18
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import {
Alert,
Button,
@@ -156,7 +156,7 @@ const buildOfflineBackfillPlan = (
}
function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element {
const { t, i18n } = useTranslation()
const { t } = useTranslation()
const [form] = Form.useForm<RuleFormValues>()
const [messageApi, contextHolder] = message.useMessage()
const [tags, setTags] = useState<TagItem[]>([])
@@ -183,10 +183,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
[rewards]
)
const triggerConfig = useMemo(
() => createTriggerQueryConfig(t, tagOptions),
[i18n.resolvedLanguage, i18n.language, tagOptions]
)
const triggerConfig = useMemo(() => createTriggerQueryConfig(t, tagOptions), [t, tagOptions])
const [triggerTree, setTriggerTree] = useState<ImmutableTree>(() =>
createEmptyTriggerTree(triggerConfig)
)
@@ -282,7 +279,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } }))
}
const fetchStudents = async () => {
const fetchStudents = useCallback(async () => {
const api = (window as any).api
if (!api || !canEdit) return
@@ -294,9 +291,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
} catch {
void 0
}
}
}, [canEdit])
const fetchTags = async () => {
const fetchTags = useCallback(async () => {
if (!canEdit) return
try {
@@ -307,9 +304,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
} catch {
void 0
}
}
}, [canEdit])
const fetchRewards = async () => {
const fetchRewards = useCallback(async () => {
const api = (window as any).api
if (!api || !canEdit) return
@@ -321,9 +318,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
} catch {
void 0
}
}
}, [canEdit])
const fetchRules = async () => {
const fetchRules = useCallback(async () => {
const api = (window as any).api
if (!api || !canEdit) return
@@ -340,9 +337,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
} finally {
setLoading(false)
}
}
}, [canEdit, messageApi, t])
const fetchBatches = async () => {
const fetchBatches = useCallback(async () => {
const api = (window as any).api
if (!api || !canEdit) return
try {
@@ -353,7 +350,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
} catch {
void 0
}
}
}, [canEdit])
useEffect(() => {
if (!canEdit) return
@@ -362,7 +359,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
fetchStudents().catch(() => void 0)
fetchRules().catch(() => void 0)
fetchBatches().catch(() => void 0)
}, [canEdit])
}, [canEdit, fetchTags, fetchRewards, fetchStudents, fetchRules, fetchBatches])
useEffect(() => {
const api = (window as any).api
@@ -426,7 +423,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
}
},
})
}, [backfillPrompted, canEdit, messageApi, rules, t])
}, [backfillPrompted, canEdit, messageApi, rules, t, fetchRules, fetchBatches])
const handleSubmit = async () => {
const api = (window as any).api
+64 -51
View File
@@ -25,7 +25,14 @@ import { useTranslation } from "react-i18next"
type BoardStudentViewMode = "list" | "card" | "grid" | "largeAvatar"
type BoardScoreDisplayMode = "total" | "split"
type SplitDirection = "horizontal" | "vertical"
type BoardTimeRange = "last7d" | "last30d" | "thisWeek" | "lastWeek" | "thisMonth" | "lastMonth" | "custom"
type BoardTimeRange =
| "last7d"
| "last30d"
| "thisWeek"
| "lastWeek"
| "thisMonth"
| "lastMonth"
| "custom"
type BoardReasonMode = "all" | "selected" | "keyword"
type BoardScoreDirection = "all" | "add" | "deduct"
type BoardMetric = "addScore" | "deductScore" | "netChange" | "addCount" | "eventCount"
@@ -177,9 +184,7 @@ const normalizeRule = (input: unknown): BoardQueryRuleConfig => {
const fallback = createDefaultRule()
const raw = typeof input === "object" && input ? (input as Record<string, unknown>) : {}
const reasonValues = Array.isArray(raw.reasonValues)
? raw.reasonValues
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean)
? raw.reasonValues.map((item) => (typeof item === "string" ? item.trim() : "")).filter(Boolean)
: []
const allowedTimeRange = new Set<BoardTimeRange>([
@@ -216,7 +221,9 @@ const normalizeRule = (input: unknown): BoardQueryRuleConfig => {
scoreDirection: allowedScoreDirection.has(raw.scoreDirection as BoardScoreDirection)
? (raw.scoreDirection as BoardScoreDirection)
: fallback.scoreDirection,
metric: allowedMetric.has(raw.metric as BoardMetric) ? (raw.metric as BoardMetric) : fallback.metric,
metric: allowedMetric.has(raw.metric as BoardMetric)
? (raw.metric as BoardMetric)
: fallback.metric,
sortDirection: allowedSortDirection.has(raw.sortDirection as BoardSortDirection)
? (raw.sortDirection as BoardSortDirection)
: fallback.sortDirection,
@@ -324,7 +331,11 @@ const buildSqlFromRule = (ruleInput: BoardQueryRuleConfig): string | null => {
const currentStartMs = new Date(currentStart).getTime()
const currentEndMs = new Date(currentEnd).getTime()
if (!Number.isFinite(currentStartMs) || !Number.isFinite(currentEndMs) || currentEndMs <= currentStartMs) {
if (
!Number.isFinite(currentStartMs) ||
!Number.isFinite(currentEndMs) ||
currentEndMs <= currentStartMs
) {
return null
}
@@ -358,8 +369,7 @@ const buildSqlFromRule = (ruleInput: BoardQueryRuleConfig): string | null => {
)
}
const baseWhereSql =
baseWhereClauses.length > 0 ? `AND ${baseWhereClauses.join(" AND ")}` : ""
const baseWhereSql = baseWhereClauses.length > 0 ? `AND ${baseWhereClauses.join(" AND ")}` : ""
const metricExpression = getMetricExpression(rule.metric)
const topN = clampTopN(rule.topN)
const filterClauses: string[] = []
@@ -560,24 +570,23 @@ const normalizeBoards = (input: unknown): BoardConfig[] => {
const boards = input
.map((board: any) => {
const lists = Array.isArray(board?.lists)
? board.lists
.map((list: any) => ({
id: typeof list?.id === "string" && list.id.trim() ? list.id : makeId(),
name:
typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表",
rule: normalizeRule(list?.rule),
viewMode:
list?.viewMode === "list" ||
list?.viewMode === "card" ||
list?.viewMode === "grid" ||
list?.viewMode === "largeAvatar"
? list.viewMode
: "card",
scoreDisplayMode:
list?.scoreDisplayMode === "total" || list?.scoreDisplayMode === "split"
? list.scoreDisplayMode
: "total",
}))
? board.lists.map((list: any) => ({
id: typeof list?.id === "string" && list.id.trim() ? list.id : makeId(),
name:
typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表",
rule: normalizeRule(list?.rule),
viewMode:
list?.viewMode === "list" ||
list?.viewMode === "card" ||
list?.viewMode === "grid" ||
list?.viewMode === "largeAvatar"
? list.viewMode
: "card",
scoreDisplayMode:
list?.scoreDisplayMode === "total" || list?.scoreDisplayMode === "split"
? list.scoreDisplayMode
: "total",
}))
: []
const normalizedLists = lists.length > 0 ? lists : [createDefaultList()]
@@ -965,7 +974,7 @@ export const BoardManager: React.FC<BoardManagerProps> = ({ canManage }) => {
}
const firstLeafId = getFirstLeafId(activeBoard.layout)
setSelectedLeafNodeId((prev) => prev || firstLeafId)
}, [activeBoard?.id, activeBoard?.layout])
}, [activeBoard?.id, activeBoard?.layout, activeBoard])
useEffect(() => {
if (!activeBoard) return
@@ -973,7 +982,7 @@ export const BoardManager: React.FC<BoardManagerProps> = ({ canManage }) => {
runAllInBoard(activeBoard).catch(() => void 0)
}, 260)
return () => window.clearTimeout(timer)
}, [activeBoard?.id, listConfigSignature, runAllInBoard])
}, [activeBoard?.id, listConfigSignature, runAllInBoard, activeBoard])
useEffect(() => {
const onDataUpdated = (e: Event) => {
@@ -1235,27 +1244,27 @@ export const BoardManager: React.FC<BoardManagerProps> = ({ canManage }) => {
? item.metricValue !== undefined
? { label: metricTitle, value: item.metricValue }
: item.addScore !== undefined && item.addScore !== 0
? { label: t("board.metrics.addScore"), value: item.addScore }
: item.deductScore !== undefined && item.deductScore !== 0
? { label: t("board.metrics.deductScore"), value: item.deductScore }
: item.addScore !== undefined
? { label: t("board.metrics.addScore"), value: item.addScore }
: item.deductScore !== undefined
? { label: t("board.metrics.deductScore"), value: item.deductScore }
: item.score !== undefined
? { label: t("board.metrics.totalScore"), value: item.score }
: item.rewardPoints !== undefined
? { label: t("board.metrics.rewardPoints"), value: item.rewardPoints }
: item.weekChange !== undefined
? { label: t("board.metrics.weekChange"), value: item.weekChange }
: item.weekDeducted !== undefined
? { label: t("board.metrics.weekDeducted"), value: item.weekDeducted }
: item.answeredCount !== undefined
? {
label: t("board.metrics.todayAnswered"),
value: item.answeredCount,
}
: null
? { label: t("board.metrics.addScore"), value: item.addScore }
: item.deductScore !== undefined && item.deductScore !== 0
? { label: t("board.metrics.deductScore"), value: item.deductScore }
: item.addScore !== undefined
? { label: t("board.metrics.addScore"), value: item.addScore }
: item.deductScore !== undefined
? { label: t("board.metrics.deductScore"), value: item.deductScore }
: item.score !== undefined
? { label: t("board.metrics.totalScore"), value: item.score }
: item.rewardPoints !== undefined
? { label: t("board.metrics.rewardPoints"), value: item.rewardPoints }
: item.weekChange !== undefined
? { label: t("board.metrics.weekChange"), value: item.weekChange }
: item.weekDeducted !== undefined
? { label: t("board.metrics.weekDeducted"), value: item.weekDeducted }
: item.answeredCount !== undefined
? {
label: t("board.metrics.todayAnswered"),
value: item.answeredCount,
}
: null
: item.score !== undefined
? item.metricValue !== undefined
? { label: metricTitle, value: item.metricValue }
@@ -1594,8 +1603,12 @@ export const BoardManager: React.FC<BoardManagerProps> = ({ canManage }) => {
</Tag>
)}
{item.metricDelta !== undefined && (
<Tag color={item.metricDelta >= 0 ? "success" : "error"} style={{ margin: 0 }}>
: {item.metricDelta > 0 ? `+${item.metricDelta}` : item.metricDelta}
<Tag
color={item.metricDelta >= 0 ? "success" : "error"}
style={{ margin: 0 }}
>
:{" "}
{item.metricDelta > 0 ? `+${item.metricDelta}` : item.metricDelta}
</Tag>
)}
{rankChangeText && (
+14 -11
View File
@@ -553,7 +553,7 @@ export const Home: React.FC<HomeProps> = ({
return a.localeCompare(b, "zh-CN")
})
.map(([key, students]) => ({ key, students }))
}, [sortedStudents, sortType, searchKeyword, layoutType, getGroupName, t])
}, [sortedStudents, sortType, searchKeyword, getGroupName, t])
const firstStudentIdByGroup = useMemo(() => {
const result = new Map<string, number>()
@@ -577,7 +577,7 @@ export const Home: React.FC<HomeProps> = ({
if (b === t("home.category.others")) return -1
return a.localeCompare(b, "zh-CN")
})
}, [reasons])
}, [reasons, t])
const selectedStudents = useMemo(() => {
if (selectedStudentIds.length === 0) return []
@@ -2374,18 +2374,21 @@ export const Home: React.FC<HomeProps> = ({
document.addEventListener("mouseup", onGlobalMouseUp)
}
const onGlobalMouseMove = (e: MouseEvent) => {
if (isNavDragging.current) {
if (e.cancelable) e.preventDefault()
handleNavAction(e.clientY)
}
}
const onGlobalMouseMove = useCallback(
(e: MouseEvent) => {
if (isNavDragging.current) {
if (e.cancelable) e.preventDefault()
handleNavAction(e.clientY)
}
},
[handleNavAction]
)
const onGlobalMouseUp = () => {
const onGlobalMouseUp = useCallback(() => {
setNavDraggingState(false)
document.removeEventListener("mousemove", onGlobalMouseMove)
document.removeEventListener("mouseup", onGlobalMouseUp)
}
}, [onGlobalMouseMove, setNavDraggingState])
const onNavTouchStart = (e: React.TouchEvent) => {
navHapticIndexRef.current = null
@@ -2421,7 +2424,7 @@ export const Home: React.FC<HomeProps> = ({
document.body.style.userSelect = bodyUserSelectRef.current
document.body.style.webkitUserSelect = bodyWebkitUserSelectRef.current
}
}, [])
}, [onGlobalMouseMove, onGlobalMouseUp])
useEffect(() => {
if (!groupedStudents.length) {
+43 -23
View File
@@ -1,35 +1,44 @@
import { useEffect } from "react"
import { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { sectlAuth } from "../../services/sectlAuth"
export function OAuthCallback() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [status, setStatus] = useState<"processing" | "success" | "error">("processing")
const [errorMessage, setErrorMessage] = useState<string>("")
useEffect(() => {
const code = searchParams.get("code")
const error = searchParams.get("error")
const errorDescription = searchParams.get("error_description")
const handleCallback = async () => {
const code = searchParams.get("code")
const error = searchParams.get("error")
const errorDescription = searchParams.get("error_description")
if (error) {
window.postMessage(
{
error: errorDescription || error,
},
window.location.origin
)
navigate("/")
return
if (error) {
setStatus("error")
setErrorMessage(errorDescription || error)
setTimeout(() => navigate("/"), 3000)
return
}
if (code) {
try {
await sectlAuth.exchangeCodeForToken(code)
setStatus("success")
setTimeout(() => navigate("/"), 1500)
} catch (err: any) {
setStatus("error")
setErrorMessage(err.message || "Token 交换失败")
setTimeout(() => navigate("/"), 3000)
}
} else {
setStatus("error")
setErrorMessage("未收到授权码")
setTimeout(() => navigate("/"), 3000)
}
}
if (code) {
window.postMessage(
{
code,
},
window.location.origin
)
navigate("/")
}
handleCallback()
}, [searchParams, navigate])
return (
@@ -43,7 +52,18 @@ export function OAuthCallback() {
color: "var(--ss-text-main)",
}}
>
<div>...</div>
<div style={{ textAlign: "center" }}>
{status === "processing" && <div>...</div>}
{status === "success" && <div>...</div>}
{status === "error" && (
<div>
<div style={{ color: "#ff4d4f" }}></div>
<div style={{ fontSize: "12px", marginTop: "8px", color: "var(--ss-text-secondary)" }}>
{errorMessage}
</div>
</div>
)}
</div>
</div>
)
}
+50 -251
View File
@@ -1,243 +1,71 @@
import { Button, message, Modal, Space, Spin } from "antd"
import { useCallback, useEffect, useRef, useState } from "react"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { open } from "@tauri-apps/plugin-shell"
import { listen, UnlistenFn } from "@tauri-apps/api/event"
import { sectlAuth, SECTL_CONFIG } from "../../services/sectlAuth"
interface OAuthLoginProps {
visible: boolean
onClose: () => void
onSuccess: (userInfo: {
user_id: string
email: string
name: string
github_username?: string
user_id?: string
id?: string
email?: string
name?: string
avatar?: string
avatar_url?: string
}) => void
}
interface OAuthConfig {
platform_id: string
}
interface OAuthCallbackResult {
code?: string
state?: string
error?: string
error_description?: string
}
const OAUTH_EXPECTED_STATE_KEY = "oauth_expected_state"
const OAUTH_CODE_VERIFIER_KEY = "oauth_code_verifier"
const OAUTH_CALLBACK_URL_KEY = "oauth_callback_url"
const DEFAULT_CALLBACK_URL = "http://127.0.0.1:16888/oauth/callback"
const getExpectedState = () => sessionStorage.getItem(OAUTH_EXPECTED_STATE_KEY)
const setExpectedState = (state: string) => sessionStorage.setItem(OAUTH_EXPECTED_STATE_KEY, state)
const clearExpectedState = () => sessionStorage.removeItem(OAUTH_EXPECTED_STATE_KEY)
const getCodeVerifier = () => sessionStorage.getItem(OAUTH_CODE_VERIFIER_KEY)
const setCodeVerifier = (codeVerifier: string) =>
sessionStorage.setItem(OAUTH_CODE_VERIFIER_KEY, codeVerifier)
const clearCodeVerifier = () => sessionStorage.removeItem(OAUTH_CODE_VERIFIER_KEY)
const getCallbackUrl = () => sessionStorage.getItem(OAUTH_CALLBACK_URL_KEY) || DEFAULT_CALLBACK_URL
const setCallbackUrl = (callbackUrl: string) =>
sessionStorage.setItem(OAUTH_CALLBACK_URL_KEY, callbackUrl)
const clearCallbackUrl = () => sessionStorage.removeItem(OAUTH_CALLBACK_URL_KEY)
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const isProcessingCallbackRef = useRef(false)
const getOAuthConfig = (): OAuthConfig | null => {
const api = (window as any).api
if (!api) return null
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
if (!platformId) {
return null
}
return {
platform_id: platformId,
}
}
const stopCallbackServer = useCallback(async () => {
const api = (window as any).api
if (!api?.oauthStopCallbackServer) return
try {
await api.oauthStopCallbackServer()
} catch (error) {
console.error("[OAuth] 停止回调服务器失败:", error)
}
}, [])
const clearOAuthTempState = useCallback(() => {
clearExpectedState()
clearCodeVerifier()
clearCallbackUrl()
}, [])
const handleOAuthCallback = useCallback(
async (result: OAuthCallbackResult) => {
if (isProcessingCallbackRef.current) {
console.log("[OAuth] 回调正在处理中,跳过")
return
}
console.log("[OAuth] 收到回调:", result)
const config = getOAuthConfig()
if (!config) {
console.error("[OAuth] 配置不存在")
message.error("OAuth 配置未设置")
setLoading(false)
return
}
const api = (window as any).api
if (!api) {
message.error("API 不可用")
setLoading(false)
return
}
try {
isProcessingCallbackRef.current = true
if (result.error) {
console.error("[OAuth] 错误:", result.error, result.error_description)
message.error(result.error_description || result.error || "授权失败")
return
}
if (!result.code) return
console.log("[OAuth] 授权码:", result.code)
console.log("[OAuth] State:", result.state, "期望:", getExpectedState())
const expectedState = getExpectedState()
if (expectedState && result.state !== expectedState) {
message.error("安全验证失败:state 不匹配")
return
}
const codeVerifier = getCodeVerifier()
if (!codeVerifier) {
message.error("安全验证失败:缺少 code_verifier")
return
}
const callbackUrl = getCallbackUrl()
console.log("[OAuth] 开始换取 token...")
const tokenRes = await api.oauthExchangeCode(
result.code,
config.platform_id,
callbackUrl,
codeVerifier
)
console.log("[OAuth] token 响应:", tokenRes)
if (!tokenRes.success) {
message.error(tokenRes.message || "获取访问令牌失败")
return
}
console.log("[OAuth] 开始获取用户信息...")
const userRes = await api.oauthGetUserInfo(tokenRes.data.access_token)
console.log("[OAuth] 用户信息响应:", userRes)
if (!userRes.success) {
message.error(userRes.message || "获取用户信息失败")
return
}
console.log("[OAuth] 登录成功,保存登录状态...")
const loginState = {
access_token: tokenRes.data.access_token,
refresh_token: tokenRes.data.refresh_token,
token_type: tokenRes.data.token_type,
expires_in: tokenRes.data.expires_in,
user_id: userRes.data.user_id,
email: userRes.data.email,
name: userRes.data.name,
github_username: userRes.data.github_username,
login_time: new Date().toISOString(),
}
try {
await api.oauthSaveLoginState(loginState)
console.log("[OAuth] 登录状态已保存")
} catch (saveError) {
console.error("[OAuth] 保存登录状态失败:", saveError)
}
window.dispatchEvent(
new CustomEvent("ss:oauth-user-updated", {
detail: { user: userRes.data },
})
)
console.log("[OAuth] 调用 onSuccess...")
onSuccess(userRes.data)
console.log("[OAuth] 调用 onClose...")
onClose()
} catch (error: any) {
console.error("[OAuth] 处理回调时发生错误:", error)
message.error(error.message || "登录失败")
} finally {
await stopCallbackServer()
clearOAuthTempState()
setLoading(false)
window.setTimeout(() => {
isProcessingCallbackRef.current = false
console.log("[OAuth] 局部锁已释放")
}, 300)
}
},
[clearOAuthTempState, onClose, onSuccess, stopCallbackServer]
)
useEffect(() => {
if (!visible) return
let unlisten: UnlistenFn | null = null
let disposed = false
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
if (platformId) {
// 使用 deep link 回调地址
sectlAuth.initialize(platformId, "secscore://oauth")
SECTL_CONFIG.platformId = platformId
SECTL_CONFIG.callbackUrl = "secscore://oauth"
}
}, [visible])
// 监听 Deep Link 回调
useEffect(() => {
if (!visible || !loading) return
const handleDeepLink = async (event: Event) => {
const customEvent = event as CustomEvent<{ code: string; state: string }>
const { code, state } = customEvent.detail
console.log("[OAuthLogin] Received deep link callback:", { code, state })
const setupListener = async () => {
try {
unlisten = await listen<OAuthCallbackResult>("oauth-callback", async (event) => {
console.log("[OAuth] Event listener 收到 payload:", event.payload)
if (event.payload) {
await handleOAuthCallback(event.payload)
}
})
if (disposed) {
unlisten?.()
unlisten = null
// 使用 code 交换 token
const result = await sectlAuth.exchangeCode(code, state)
if (result) {
const userInfo = await sectlAuth.getUserInfo()
onSuccess(userInfo)
onClose()
}
} catch (error) {
console.error("Failed to setup OAuth callback listener:", error)
} catch (error: any) {
message.error(error.message || "登录失败")
} finally {
setLoading(false)
}
}
void setupListener()
window.addEventListener("ss:oauth-deep-link", handleDeepLink)
return () => {
disposed = true
if (unlisten) {
unlisten()
unlisten = null
}
window.removeEventListener("ss:oauth-deep-link", handleDeepLink)
}
}, [handleOAuthCallback, visible])
}, [visible, loading, onClose, onSuccess])
const handleOAuthLogin = async () => {
const config = getOAuthConfig()
if (!config) {
if (!SECTL_CONFIG.platformId) {
message.error("OAuth 配置未设置")
return
}
@@ -245,35 +73,17 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
setLoading(true)
try {
const api = (window as any).api
const authUrl = await sectlAuth.getAuthorizationUrl()
// 使用 Tauri shell 打开系统浏览器
await open(authUrl)
const serverRes = await api.oauthStartCallbackServer()
if (!serverRes.success) {
message.error(serverRes.message || "启动回调服务器失败")
setLoading(false)
return
}
const callbackUrl = serverRes.data.url
setCallbackUrl(callbackUrl)
const state = generateRandomState()
console.log("[OAuth] 生成 state:", state)
setExpectedState(state)
console.log("[OAuth] state 已设置:", getExpectedState())
const urlRes = await api.oauthGetAuthorizationUrl(config.platform_id, callbackUrl, state)
if (!urlRes.success) {
message.error(urlRes.message || "获取授权链接失败")
setLoading(false)
return
}
setCodeVerifier(urlRes.data.code_verifier)
console.log("[OAuth] code_verifier 已存储")
await open(urlRes.data.url)
// 设置超时
setTimeout(() => {
if (loading) {
setLoading(false)
message.warning("登录超时,请重试")
}
}, 300000)
} catch (error: any) {
message.error(error.message || "登录失败")
setLoading(false)
@@ -282,20 +92,9 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
const handleModalClose = () => {
setLoading(false)
clearOAuthTempState()
void stopCallbackServer()
onClose()
}
const generateRandomState = (): string => {
const array = new Uint8Array(32)
window.crypto.getRandomValues(array)
return btoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "")
}
return (
<Modal
title={t("auth.oauthLogin", "SECTL Auth 登录")}
+15 -12
View File
@@ -137,16 +137,19 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
const primaryColor = workingTheme?.config?.tdesign?.brandColor || "#1677FF"
const isDark = workingTheme?.mode === "dark"
const showOobeMessage = (type: "success" | "error" | "warning" | "info", content: string) => {
messageApi.open({
type,
content,
style: {
marginTop: 8,
zIndex: 12000,
},
})
}
const showOobeMessage = useCallback(
(type: "success" | "error" | "warning" | "info", content: string) => {
messageApi.open({
type,
content,
style: {
marginTop: 8,
zIndex: 12000,
},
})
},
[messageApi]
)
useEffect(() => {
if (!currentTheme) return
@@ -157,7 +160,7 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
: { ...base, id: "custom-default", name: t("theme.myTheme") }
setWorkingTheme(editable)
setPrimaryInput(editable.config?.tdesign?.brandColor || "")
}, [currentTheme])
}, [currentTheme, t])
useEffect(() => {
if (!workingTheme) return
@@ -331,7 +334,7 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
showOobeMessage("error", t("oobe.steps.students.unsupportedFormat"))
}
},
[students, messageApi]
[students, showOobeMessage, t]
)
const handleDrop = useCallback(
+4 -4
View File
@@ -3,7 +3,7 @@
* 提供云端数据同步功能
*/
import React, { useState, useEffect } from "react"
import React, { useState, useEffect, useCallback } from "react"
import { Card, Button, Space, Alert, Descriptions, message, Modal, Typography, Divider } from "antd"
import {
CloudSyncOutlined,
@@ -43,7 +43,7 @@ export const ScoreSyncPanel: React.FC<SyncPanelProps> = ({ onGetLocalData, onRes
const [hasData, setHasData] = useState(false)
// 检查云端数据状态
const checkCloudStatus = async () => {
const checkCloudStatus = useCallback(async () => {
if (!isAuthenticated) return
try {
@@ -60,13 +60,13 @@ export const ScoreSyncPanel: React.FC<SyncPanelProps> = ({ onGetLocalData, onRes
} finally {
setLoading(false)
}
}
}, [isAuthenticated])
useEffect(() => {
if (isAuthenticated) {
checkCloudStatus()
}
}, [isAuthenticated])
}, [isAuthenticated, checkCloudStatus])
// 上传数据到云端
const handleSyncToCloud = async () => {
+16 -10
View File
@@ -2,7 +2,7 @@
* SECTL 云存储管理组件
*/
import React, { useState, useEffect } from "react"
import React, { useState, useEffect, useCallback } from "react"
import {
Card,
Table,
@@ -45,7 +45,7 @@ export const SectlCloudStorageManager: React.FC = () => {
const [newFilename, setNewFilename] = useState("")
// 加载文件列表
const loadFiles = async () => {
const loadFiles = useCallback(async () => {
if (!isAuthenticated) return
try {
@@ -57,10 +57,10 @@ export const SectlCloudStorageManager: React.FC = () => {
} finally {
setLoading(false)
}
}
}, [isAuthenticated])
// 加载存储使用情况
const loadStorageUsage = async () => {
const loadStorageUsage = useCallback(async () => {
if (!isAuthenticated) return
try {
@@ -69,14 +69,14 @@ export const SectlCloudStorageManager: React.FC = () => {
} catch (error: any) {
console.error("加载存储使用情况失败:", error)
}
}
}, [isAuthenticated])
useEffect(() => {
if (isAuthenticated) {
loadFiles()
loadStorageUsage()
}
}, [isAuthenticated])
}, [isAuthenticated, loadFiles, loadStorageUsage])
// 处理文件上传
const handleUpload = async (file: UploadFile): Promise<void> => {
@@ -95,11 +95,17 @@ export const SectlCloudStorageManager: React.FC = () => {
}
}
// 处理文件下载
const handleDownload = async (file: CloudFile) => {
try {
const { download_url } = await sectlCloudStorage.downloadFile(file.file_id)
window.open(download_url, "_blank")
const blob = await sectlCloudStorage.downloadFile(file.file_id)
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = file.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
message.success("开始下载")
} catch (error: any) {
message.error(`下载失败:${error.message}`)
@@ -137,7 +143,7 @@ export const SectlCloudStorageManager: React.FC = () => {
const handleCreateShare = async (file: CloudFile) => {
try {
const share = await sectlCloudStorage.createShare(file.file_id, {
expires_in: 86400, // 1 天
expiresIn: 86400,
})
// 复制到剪贴板
+31 -28
View File
@@ -3,7 +3,7 @@
* 基于 SECTL-One-Stop SDK 规范实现
*/
import React, { useState, useEffect } from "react"
import React, { useState, useEffect, useCallback } from "react"
import {
Card,
Table,
@@ -61,42 +61,45 @@ export const SectlKVStorageManager: React.FC = () => {
const [form] = Form.useForm()
// 加载 KV 列表
const loadKVList = async (resetOffset = false) => {
if (!isAuthenticated) return
const loadKVList = useCallback(
async (resetOffset = false) => {
if (!isAuthenticated) return
try {
setLoading(true)
const currentOffset = resetOffset ? 0 : offset
const options: ListKVOptions = {
limit: 20,
offset: currentOffset,
}
if (prefix) {
options.prefix = prefix
}
try {
setLoading(true)
const currentOffset = resetOffset ? 0 : offset
const options: ListKVOptions = {
limit: 20,
offset: currentOffset,
}
if (prefix) {
options.prefix = prefix
}
const result = await sectlKVStorage.listKV(options)
setKvList(resetOffset ? result.kv_list : [...kvList, ...result.kv_list])
setTotal(result.total)
setHasMore(result.has_more)
if (resetOffset) {
setOffset(20)
} else {
setOffset(currentOffset + 20)
const result = await sectlKVStorage.listKV(options)
setKvList(resetOffset ? result.kv_list : [...kvList, ...result.kv_list])
setTotal(result.total)
setHasMore(result.has_more)
if (resetOffset) {
setOffset(20)
} else {
setOffset(currentOffset + 20)
}
} catch (error: any) {
message.error(`加载键值对列表失败:${error.message}`)
} finally {
setLoading(false)
}
} catch (error: any) {
message.error(`加载键值对列表失败:${error.message}`)
} finally {
setLoading(false)
}
}
},
[isAuthenticated, offset, prefix, kvList]
)
// 初始加载
useEffect(() => {
if (isAuthenticated) {
loadKVList(true)
}
}, [isAuthenticated, prefix])
}, [isAuthenticated, prefix, loadKVList])
// 处理创建/更新
const handleSave = async (values: {
+13 -5
View File
@@ -82,13 +82,21 @@ export const SectlSettingsPanel: React.FC = () => {
{isAuthenticated && userInfo && (
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="用户 ID">{userInfo.user_id}</Descriptions.Item>
<Descriptions.Item label="用户 ID">
{userInfo.user_id || userInfo.id}
</Descriptions.Item>
<Descriptions.Item label="用户名">{userInfo.name}</Descriptions.Item>
<Descriptions.Item label="邮箱">{userInfo.email}</Descriptions.Item>
<Descriptions.Item label="权限">{userInfo.permission}</Descriptions.Item>
<Descriptions.Item label="角色">{userInfo.role}</Descriptions.Item>
{userInfo.github_username && (
<Descriptions.Item label="GitHub">{userInfo.github_username}</Descriptions.Item>
{userInfo.status && (
<Descriptions.Item label="状态">{userInfo.status}</Descriptions.Item>
)}
{userInfo.created_at && (
<Descriptions.Item label="注册时间">{userInfo.created_at}</Descriptions.Item>
)}
{userInfo.email_verified !== undefined && (
<Descriptions.Item label="邮箱验证">
{userInfo.email_verified ? "已验证" : "未验证"}
</Descriptions.Item>
)}
</Descriptions>
)}
+65 -63
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Tabs,
Card,
@@ -221,10 +221,12 @@ export const Settings: React.FC<{
const [oauthLoginVisible, setOAuthLoginVisible] = useState(false)
const [oauthUserInfo, setOAuthUserInfo] = useState<{
user_id: string
email: string
name: string
github_username?: string
user_id?: string
id?: string
email?: string
name?: string
avatar?: string
avatar_url?: string
} | null>(null)
const permissionTag = useMemo(() => {
@@ -264,58 +266,61 @@ export const Settings: React.FC<{
}
}
const loadSystemFonts = async (selectedFontValue?: string) => {
setIsLoadingFonts(true)
const loadSystemFonts = useCallback(
async (selectedFontValue?: string) => {
setIsLoadingFonts(true)
const applySelectedFont = (options: FontOption[]) => {
const current = findFontOption(options, selectedFontValue || settings.font_family)
if (current) applyFontFamily(current.fontFamily)
}
const applySelectedFont = (options: FontOption[]) => {
const current = findFontOption(options, selectedFontValue || settings.font_family)
if (current) applyFontFamily(current.fontFamily)
}
const api = (window as any).api
if (!api?.getSystemFonts) {
setFontOptions(defaultFontOptions)
applySelectedFont(defaultFontOptions)
setIsLoadingFonts(false)
return
}
try {
const res = await api.getSystemFonts()
if (res.success && Array.isArray(res.data)) {
const remoteOptions = res.data
.map((name: string) => String(name || "").trim())
.filter((name: string) => Boolean(name))
.map((name: string) => {
const label = formatFontDisplayName(name)
return {
value: buildSystemFontValue(name),
label,
fontFamily: buildSystemFontFamily(name),
searchText: buildFontSearchText(name, label),
} satisfies FontOption
})
.sort((a: FontOption, b: FontOption) =>
a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin")
)
const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions])
setFontOptions(mergedOptions)
applySelectedFont(mergedOptions)
} else {
const api = (window as any).api
if (!api?.getSystemFonts) {
setFontOptions(defaultFontOptions)
applySelectedFont(defaultFontOptions)
setIsLoadingFonts(false)
return
}
} catch (error) {
console.error("Failed to load system fonts:", error)
setFontOptions(defaultFontOptions)
applySelectedFont(defaultFontOptions)
} finally {
setIsLoadingFonts(false)
}
}
const loadAll = async () => {
try {
const res = await api.getSystemFonts()
if (res.success && Array.isArray(res.data)) {
const remoteOptions = res.data
.map((name: string) => String(name || "").trim())
.filter((name: string) => Boolean(name))
.map((name: string) => {
const label = formatFontDisplayName(name)
return {
value: buildSystemFontValue(name),
label,
fontFamily: buildSystemFontFamily(name),
searchText: buildFontSearchText(name, label),
} satisfies FontOption
})
.sort((a: FontOption, b: FontOption) =>
a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin")
)
const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions])
setFontOptions(mergedOptions)
applySelectedFont(mergedOptions)
} else {
setFontOptions(defaultFontOptions)
applySelectedFont(defaultFontOptions)
}
} catch (error) {
console.error("Failed to load system fonts:", error)
setFontOptions(defaultFontOptions)
applySelectedFont(defaultFontOptions)
} finally {
setIsLoadingFonts(false)
}
},
[settings.font_family]
)
const loadAll = useCallback(async () => {
const api = (window as any).api
if (!api) {
setFontOptions(defaultFontOptions)
@@ -345,7 +350,6 @@ export const Settings: React.FC<{
user_id: oauthStateRes.data.user_id,
email: oauthStateRes.data.email,
name: oauthStateRes.data.name,
github_username: oauthStateRes.data.github_username,
})
} else {
setOAuthUserInfo(null)
@@ -375,7 +379,7 @@ export const Settings: React.FC<{
// ignore
}
}
}
}, [settings, loadSystemFonts])
const handleOAuthLogout = async () => {
const api = (window as any).api
@@ -564,7 +568,7 @@ export const Settings: React.FC<{
disposed = true
if (unlisten) unlisten()
}
}, [])
}, [loadAll])
useEffect(() => {
const handleOAuthUserUpdated = (event: Event) => {
@@ -582,7 +586,6 @@ export const Settings: React.FC<{
user_id: user.user_id,
email: user.email,
name: user.name,
github_username: user.github_username,
})
} else {
setOAuthUserInfo(null)
@@ -1289,19 +1292,18 @@ export const Settings: React.FC<{
{oauthUserInfo ? (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<Avatar size={64} style={{ backgroundColor: "#1890ff" }}>
{oauthUserInfo.name.charAt(0).toUpperCase()}
<Avatar
size={64}
src={oauthUserInfo.avatar || oauthUserInfo.avatar_url}
style={{ backgroundColor: "#1890ff" }}
>
{(oauthUserInfo.name || "U").charAt(0).toUpperCase()}
</Avatar>
<div>
<div style={{ fontSize: "18px", fontWeight: 600 }}>{oauthUserInfo.name}</div>
<div style={{ fontSize: "14px", color: "var(--ss-text-secondary)" }}>
{oauthUserInfo.email}
</div>
{oauthUserInfo.github_username && (
<div style={{ fontSize: "13px", color: "var(--ss-text-secondary)" }}>
GitHub: @{oauthUserInfo.github_username}
</div>
)}
</div>
</div>
@@ -1310,7 +1312,7 @@ export const Settings: React.FC<{
<div>
<Text type="secondary">{t("settings.account.userId")}:</Text>
<Text code style={{ marginLeft: "8px" }}>
{oauthUserInfo.user_id}
{oauthUserInfo.user_id || oauthUserInfo.id}
</Text>
</div>
+2 -2
View File
@@ -51,7 +51,7 @@ export const SettlementHistory: React.FC = () => {
} finally {
setLoading(false)
}
}, [messageApi])
}, [messageApi, t])
useEffect(() => {
fetchSettlements()
@@ -103,7 +103,7 @@ export const SettlementHistory: React.FC = () => {
render: (score: number) => <span style={{ fontWeight: "bold" }}>{score}</span>,
},
],
[]
[t]
)
if (selectedId !== null && selectedSettlement) {
+18 -12
View File
@@ -18,7 +18,7 @@ import { UploadOutlined, MoreOutlined, PlusOutlined, CopyOutlined } from "@ant-d
import { useTranslation } from "react-i18next"
import { TagEditorDialog } from "./TagEditorDialog"
import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar"
import { useResponsive, useIsMobile, useIsTablet } from "../hooks/useResponsive"
import { useIsMobile } from "../hooks/useResponsive"
const createXlsxWorker = () => {
return new Worker(new URL("../workers/xlsxWorker.ts", import.meta.url), {
@@ -157,8 +157,6 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const addFormGroupName = Form.useWatch("group_name", form)
const editFormGroupName = Form.useWatch("group_name", groupForm)
const isMobile = useIsMobile()
const isTablet = useIsTablet()
const breakpoint = useResponsive()
useEffect(() => {
xlsxWorkerRef.current = createXlsxWorker()
@@ -291,7 +289,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
}
const handleDelete = async (id: number) => {
const handleDelete = useCallback(async (id: number) => {
if (!(window as any).api) return
if (!canEdit) {
messageApi.error(t("common.readOnly"))
@@ -305,18 +303,18 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} else {
messageApi.error(res.message || t("students.deleteFailed"))
}
}
}, [canEdit, messageApi, t, fetchStudents])
const handleOpenTagEditor = (student: student) => {
const handleOpenTagEditor = useCallback((student: student) => {
if (!canEdit) {
messageApi.error(t("common.readOnly"))
return
}
setEditingStudent(student)
setTagEditVisible(true)
}
}, [canEdit, messageApi, t])
const handleOpenAvatarEditor = (student: student) => {
const handleOpenAvatarEditor = useCallback((student: student) => {
if (!canEdit) {
messageApi.error(t("common.readOnly"))
return
@@ -324,9 +322,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
setAvatarStudent(student)
setAvatarValue(student.avatarUrl || null)
setAvatarVisible(true)
}
}, [canEdit, messageApi, t])
const handleOpenGroupEditor = (student: student) => {
const handleOpenGroupEditor = useCallback((student: student) => {
if (!canEdit) {
messageApi.error(t("common.readOnly"))
return
@@ -334,7 +332,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
setGroupEditStudent(student)
groupForm.setFieldsValue({ group_name: student.group_name || "" })
setGroupEditVisible(true)
}
}, [canEdit, messageApi, t, groupForm])
const handleSaveGroup = async () => {
if (!(window as any).api || !groupEditStudent) return
@@ -1370,7 +1368,15 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
return baseColumns
}, [t, canEdit, isMobile, isTablet, breakpoint])
}, [
t,
canEdit,
isMobile,
handleDelete,
handleOpenAvatarEditor,
handleOpenGroupEditor,
handleOpenTagEditor,
])
const existingGroupNames = useMemo(() => {
const groups = new Set<string>()
+1 -1
View File
@@ -97,7 +97,7 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
: { ...base, id: "custom-default", name: t("theme.myTheme") }
setWorkingTheme(editable)
setPrimaryInput(editable.config?.tdesign?.brandColor || "")
}, [currentTheme])
}, [currentTheme, t])
// Real-time theme preview
useEffect(() => {
+5 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"
import { useState, useEffect, useMemo } from "react"
export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl"
@@ -22,7 +22,10 @@ const defaultBreakpoints: Breakpoints = {
export function useResponsive(customBreakpoints?: Partial<Breakpoints>): Breakpoint {
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg")
const breakpoints = { ...defaultBreakpoints, ...customBreakpoints }
const breakpoints = useMemo(
() => ({ ...defaultBreakpoints, ...customBreakpoints }),
[customBreakpoints]
)
useEffect(() => {
const updateBreakpoint = () => {
+5
View File
@@ -642,6 +642,11 @@ const api = {
callback(event.payload || {})
})
},
onDeepLink: (callback: (url: string) => void): Promise<UnlistenFn> => {
return listen<string>("deep-link://new-url", (event) => {
callback(event.payload)
})
},
// Logger
queryLogs: (
+3 -4
View File
@@ -4,21 +4,20 @@
export { sectlAuth, SECTL_CONFIG } from "./sectlAuth"
export type { TokenData, UserInfo } from "./sectlAuth"
export type { TokenData, TokenIntrospection, UserInfo } from "./sectlAuth"
export { sectlCloudStorage } from "./sectlCloudStorage"
export type { CloudFile, ShareLink, StorageUsage } from "./sectlCloudStorage"
export type { CloudFile, ShareLink, KVData, StorageUsage } from "./sectlCloudStorage"
export { sectlKVStorage } from "./sectlKVStorage"
export type { KVData } from "./sectlCloudStorage"
export type { KVItem, ListKVOptions } from "./sectlKVStorage"
export { sectlNotification } from "./sectlNotification"
export type { Notification, SendNotificationParams } from "./sectlNotification"
// 积分数据同步服务
export { scoreSyncService } from "./scoreSyncService"
export type { ScoreData, ScoreEvent, SyncedData } from "./scoreSyncService"
+303 -192
View File
@@ -1,181 +1,203 @@
/**
* SECTL OAuth 2.0
* SECTL-One-Stop OAuth API
* SECTL-One-Stop SDK OAuth API
* PKCE (Proof Key for Code Exchange)
*/
import { Client, Account } from "appwrite"
import CryptoJS from "crypto-js"
// SECTL API 配置
export const SECTL_CONFIG = {
baseUrl: "https://appwrite.sectl.top",
authUrl: "https://sectl.top",
platformId: "", // 需要在设置中配置
callbackUrl: "http://localhost:5173/auth/callback",
baseUrl: "https://appwrite.sectl.cn",
authUrl: "https://sectl.cn",
platformId: "",
callbackUrl: "secscore://oauth",
callbackPort: 5173,
}
// Token 数据类型
// Token 数据类型 (与 SDK TokenData 对齐)
export interface TokenData {
access_token: string
refresh_token: string
token_type: string
expires_in: number
user_id: string
refresh_token?: string
token_type?: string
expires_in?: number
scope?: string
user_id?: string
}
// 用户信息类型
// Token 验证结果 (与 SDK TokenIntrospection 对齐)
export interface TokenIntrospection {
active: boolean
scope?: string
user_id?: string
client_id?: string
exp?: number
iat?: number
}
// 用户信息类型 (与 SDK UserInfoData 对齐)
export interface UserInfo {
user_id: string
email: string
name: string
github_username?: string
permission: string
role: string
user_id?: string
id?: string
email?: string
name?: string
avatar?: string
avatar_url?: string
background_url?: string
bio: string
tags: string[]
platform_id?: string
login_time?: string
created_at?: string
updated_at?: string
email_verified?: boolean
status?: string
metadata?: Record<string, unknown>
}
// PKCE 相关工具函数
function generateCodeVerifier(): string {
async function generateCodeVerifier(): Promise<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)))
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest("SHA-256", data)
return base64URLEncode(new Uint8Array(digest))
}
function base64URLEncode(buffer: Uint8Array | CryptoJS.lib.WordArray): string {
let base64: string
if (buffer instanceof Uint8Array) {
base64 = btoa(String.fromCharCode(...buffer))
} else {
base64 = buffer.toString(CryptoJS.enc.Base64)
function base64URLEncode(buffer: Uint8Array): string {
let binary = ""
for (let i = 0; i < buffer.length; i++) {
binary += String.fromCharCode(buffer[i])
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function extractUserIdFromJwt(accessToken: string): string | null {
try {
const parts = accessToken.split(".")
if (parts.length !== 3) return null
let payload = parts[1]
const padding = 4 - (payload.length % 4)
if (padding !== 4) payload += "=".repeat(padding)
const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"))
const claims = JSON.parse(decoded)
return claims.user_id || null
} catch {
return null
}
}
function extractPlatformIdFromJwt(accessToken: string): string | null {
try {
const parts = accessToken.split(".")
if (parts.length !== 3) return null
let payload = parts[1]
const padding = 4 - (payload.length % 4)
if (padding !== 4) payload += "=".repeat(padding)
const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"))
const claims = JSON.parse(decoded)
return claims.platform_id || null
} catch {
return null
}
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
class SectlAuthService {
private client: Client
private tokenData: TokenData | null = null
private codeVerifier: string = ""
private accessToken: string | null = null
private refreshToken: string | null = null
private tokenExpiresAt: number | null = null
private userId: string | null = null
private codeVerifier: string | null = null
constructor() {
this.client = new Client()
new Account(this.client)
this.loadToken()
}
/**
*
*/
initialize(platformId: string, callbackUrl?: string) {
SECTL_CONFIG.platformId = platformId
if (callbackUrl) {
SECTL_CONFIG.callbackUrl = callbackUrl
}
this.client.setEndpoint(SECTL_CONFIG.baseUrl).setProject("sectl-cloud") // 项目 ID
return this
}
/**
* URL
*/
getAuthorizationUrl(scope: string[] = ["user:read"]): string {
this.codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(this.codeVerifier)
async getAuthorizationUrl(scope?: string[]): Promise<string> {
const state = this.generateRandomState()
this.codeVerifier = await generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(this.codeVerifier)
// 保存 code_verifier 到 localStorage,以便 deep link 回调时使用
localStorage.setItem("sectl_code_verifier", this.codeVerifier)
const params = new URLSearchParams({
client_id: SECTL_CONFIG.platformId,
redirect_uri: SECTL_CONFIG.callbackUrl,
response_type: "code",
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
scope: scope.join(" "),
})
if (scope && scope.length > 0) {
params.set("scope", scope.join(" "))
}
return `${SECTL_CONFIG.authUrl}/oauth/authorize?${params.toString()}`
}
/**
* OAuth
*/
async authorize(scope: string[] = ["user:read"]): Promise<TokenData> {
// 生成 PKCE 参数
this.codeVerifier = generateCodeVerifier()
// codeChallenge 在 getAuthorizationUrl 中生成
async authorize(scope?: string[]): Promise<TokenData> {
this.codeVerifier = await generateCodeVerifier()
// 构建授权 URL
const authUrl = this.getAuthorizationUrl(scope)
// 在新窗口中打开授权页面
const authUrl = await this.getAuthorizationUrl(scope)
const authWindow = window.open(authUrl, "_blank", "width=600,height=700")
// 等待回调
return new Promise((resolve, reject) => {
// 监听授权回调
const checkCallback = async () => {
try {
// 检查是否有授权码(通过 postMessage 或 URL 参数)
window.addEventListener("message", async (event) => {
if (event.data.type === "oauth-callback" && event.data.code) {
try {
const token = await this.exchangeCodeForToken(event.data.code, scope)
authWindow?.close()
resolve(token)
} catch (error) {
reject(error)
}
}
})
// 轮询检查窗口关闭
const checkInterval = setInterval(() => {
if (authWindow?.closed) {
clearInterval(checkInterval)
reject(new Error("授权窗口已关闭"))
}
}, 1000)
} catch (error) {
reject(error)
window.addEventListener("message", async (event) => {
if (event.data.type === "oauth-callback" && event.data.code) {
try {
const token = await this.exchangeCodeForToken(event.data.code, scope)
authWindow?.close()
resolve(token)
} catch (error) {
reject(error)
}
}
}
})
checkCallback()
const checkInterval = setInterval(() => {
if (authWindow?.closed) {
clearInterval(checkInterval)
reject(new Error("授权窗口已关闭"))
}
}, 1000)
})
}
/**
* 使 Token
*/
async exchangeCodeForToken(code: string, scope: string[] = ["user:read"]): Promise<TokenData> {
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
async exchangeCodeForToken(code: string, scope?: string[]): Promise<TokenData> {
if (!this.codeVerifier) {
throw new Error("缺少 code_verifier,请先调用 getAuthorizationUrl()")
}
const payload = {
const deviceUuid = this.generateDeviceUuid()
const payload: Record<string, unknown> = {
grant_type: "authorization_code",
code,
client_id: SECTL_CONFIG.platformId,
redirect_uri: SECTL_CONFIG.callbackUrl,
code_verifier: this.codeVerifier,
scope: scope.join(" "),
device_uuid: deviceUuid,
ip_address: "127.0.0.1",
}
if (scope && scope.length > 0) {
payload.scope = scope.join(" ")
}
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
@@ -185,9 +207,55 @@ class SectlAuthService {
}
const data: TokenData = await response.json()
this.tokenData = data
this.saveToken()
this.saveToken(data)
return data
} catch (error) {
console.error("Token 交换失败:", error)
throw error
}
}
// 用于 Deep Link 回调的 code 交换
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async exchangeCode(code: string, _state: string): Promise<TokenData> {
// 从 localStorage 恢复 code_verifier
const savedVerifier = localStorage.getItem("sectl_code_verifier")
if (savedVerifier) {
this.codeVerifier = savedVerifier
}
if (!this.codeVerifier) {
throw new Error("缺少 code_verifier,请重新发起登录")
}
const deviceUuid = this.generateDeviceUuid()
const payload = {
grant_type: "authorization_code",
code,
client_id: SECTL_CONFIG.platformId,
redirect_uri: SECTL_CONFIG.callbackUrl,
code_verifier: this.codeVerifier,
device_uuid: deviceUuid,
ip_address: "127.0.0.1",
}
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "Token 交换失败")
}
const data: TokenData = await response.json()
this.saveToken(data)
return data
} catch (error) {
console.error("Token 交换失败:", error)
@@ -195,11 +263,8 @@ class SectlAuthService {
}
}
/**
*
*/
async getUserInfo(): Promise<UserInfo> {
if (!this.tokenData?.access_token) {
if (!this.accessToken) {
throw new Error("未授权,请先登录")
}
@@ -207,9 +272,7 @@ class SectlAuthService {
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${this.tokenData.access_token}`,
},
headers: { Authorization: `Bearer ${this.accessToken}` },
})
if (!response.ok) {
@@ -224,12 +287,8 @@ class SectlAuthService {
}
}
/**
* Token
*/
async introspectToken(token?: string): Promise<{ active: boolean; user_id?: string }> {
const tokenToCheck = token || this.tokenData?.access_token
async introspectToken(token?: string): Promise<TokenIntrospection> {
const tokenToCheck = token || this.accessToken
if (!tokenToCheck) {
return { active: false }
}
@@ -239,9 +298,7 @@ class SectlAuthService {
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: tokenToCheck,
client_id: SECTL_CONFIG.platformId,
@@ -253,34 +310,32 @@ class SectlAuthService {
}
return await response.json()
} catch (error) {
console.error("Token 验证失败:", error)
} catch {
return { active: false }
}
}
/**
* Token
*/
async refreshAccessToken(): Promise<TokenData> {
if (!this.tokenData?.refresh_token) {
throw new Error("没有刷新令牌")
if (!this.refreshToken) {
throw new Error("没有 refresh_token,无法刷新")
}
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
const deviceUuid = this.generateDeviceUuid()
const payload = {
grant_type: "refresh_token",
refresh_token: this.tokenData.refresh_token,
refresh_token: this.refreshToken,
client_id: SECTL_CONFIG.platformId,
device_uuid: deviceUuid,
ip_address: "127.0.0.1",
}
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/refresh`
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
@@ -290,9 +345,7 @@ class SectlAuthService {
}
const data: TokenData = await response.json()
this.tokenData = data
this.saveToken()
this.saveToken(data)
return data
} catch (error) {
console.error("Token 刷新失败:", error)
@@ -300,89 +353,147 @@ class SectlAuthService {
}
}
/**
* Token
*/
async logout(): Promise<void> {
if (!this.tokenData?.access_token) {
return
}
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/logout`
try {
await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${this.tokenData.access_token}`,
},
})
if (this.accessToken) {
await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ client_id: SECTL_CONFIG.platformId }),
})
}
} catch (error) {
console.error("登出失败:", error)
} finally {
this.tokenData = null
this.accessToken = null
this.refreshToken = null
this.tokenExpiresAt = null
this.userId = null
this.clearToken()
}
}
/**
* Token
*/
getToken(): TokenData | null {
return this.tokenData
}
/**
* Access Token
*/
getAccessToken(): string | null {
return this.tokenData?.access_token || null
}
/**
*
*/
isAuthenticated(): boolean {
return !!this.tokenData?.access_token
}
/**
* Token
*/
private saveToken(): void {
if (this.tokenData) {
localStorage.setItem("sectl_token", JSON.stringify(this.tokenData))
localStorage.setItem("sectl_code_verifier", this.codeVerifier)
private saveToken(tokenData: TokenData): void {
const rawAccessToken = tokenData.access_token
if (rawAccessToken.includes("|")) {
const parts = rawAccessToken.split("|")
this.accessToken = parts[0]
this.refreshToken = tokenData.refresh_token || parts[1] || null
} else {
this.accessToken = rawAccessToken
this.refreshToken = tokenData.refresh_token || null
}
this.userId = tokenData.user_id || extractUserIdFromJwt(this.accessToken)
const jwtPlatformId = extractPlatformIdFromJwt(this.accessToken)
if (jwtPlatformId && !SECTL_CONFIG.platformId) {
SECTL_CONFIG.platformId = jwtPlatformId
}
if (tokenData.expires_in) {
this.tokenExpiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in
}
const storableData: TokenData = {
access_token: rawAccessToken,
refresh_token: tokenData.refresh_token,
token_type: tokenData.token_type,
expires_in: tokenData.expires_in,
scope: tokenData.scope,
user_id: this.userId || undefined,
}
localStorage.setItem("sectl_token", JSON.stringify(storableData))
// 登录成功后清除 code_verifier
localStorage.removeItem("sectl_code_verifier")
}
/**
* Token
*/
private loadToken(): void {
try {
const tokenStr = localStorage.getItem("sectl_token")
const codeVerifier = localStorage.getItem("sectl_code_verifier")
if (tokenStr) {
this.tokenData = JSON.parse(tokenStr)
this.codeVerifier = codeVerifier || ""
const tokenData: TokenData = JSON.parse(tokenStr)
const rawAccessToken = tokenData.access_token
if (rawAccessToken.includes("|")) {
const parts = rawAccessToken.split("|")
this.accessToken = parts[0]
this.refreshToken = tokenData.refresh_token || parts[1] || null
} else {
this.accessToken = rawAccessToken
this.refreshToken = tokenData.refresh_token || null
}
this.userId = tokenData.user_id || extractUserIdFromJwt(this.accessToken)
if (tokenData.expires_in) {
this.tokenExpiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in
}
}
} catch (error) {
console.error("加载 Token 失败:", error)
}
}
/**
* Token
*/
private clearToken(): void {
localStorage.removeItem("sectl_token")
localStorage.removeItem("sectl_code_verifier")
this.tokenData = null
this.codeVerifier = ""
this.accessToken = null
this.refreshToken = null
this.tokenExpiresAt = null
this.userId = null
this.codeVerifier = null
}
getToken(): TokenData | null {
if (!this.accessToken) return null
return {
access_token: this.accessToken,
refresh_token: this.refreshToken || undefined,
user_id: this.userId || undefined,
}
}
getAccessToken(): string | null {
return this.accessToken
}
getUserId(): string | null {
return this.userId
}
isAuthenticated(): boolean {
return !!this.accessToken
}
isTokenExpired(): boolean {
if (!this.tokenExpiresAt) return false
return Date.now() / 1000 >= this.tokenExpiresAt
}
private generateRandomState(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return base64URLEncode(array)
}
private generateDeviceUuid(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
}
const array = new Uint8Array(16)
crypto.getRandomValues(array)
array[6] = (array[6] & 0x0f) | 0x40
array[8] = (array[8] & 0x3f) | 0x80
const hex = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("")
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
}
// 导出单例
export const sectlAuth = new SectlAuthService()
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { sectlCloudStorage } from "./sectlCloudStorage"
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
// Mock fetch and sectlAuth
global.fetch = vi.fn()
vi.mock("./sectlAuth", () => ({
SECTL_CONFIG: {
baseUrl: "https://api.sectl.cn",
platformId: "test-platform-id",
},
sectlAuth: {
getAccessToken: vi.fn(),
getUserId: vi.fn(),
},
}))
describe("sectlCloudStorage", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(sectlAuth.getAccessToken).mockReturnValue("test-access-token")
vi.mocked(sectlAuth.getUserId).mockReturnValue("test-user-id")
})
describe("uploadFile", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { success: true, file_id: "file-123" }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
const file = new File(["test"], "test.txt", { type: "text/plain" })
await sectlCloudStorage.uploadFile(file, { filename: "test.txt" })
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/upload`,
expect.objectContaining({
method: "POST",
})
)
})
})
describe("listFiles", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { files: [], total: 0 }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlCloudStorage.listFiles({ limit: 10, offset: 0 })
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(`${SECTL_CONFIG.baseUrl}/api/cloud/files`),
expect.any(Object)
)
})
})
describe("getFileInfo", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { file_id: "file-123", name: "test.txt" }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlCloudStorage.getFileInfo("file-123")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/files/file-123`,
expect.any(Object)
)
})
})
describe("downloadFile", () => {
it("should call correct API endpoint", async () => {
const mockBlob = new Blob(["test"])
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
blob: async () => mockBlob,
} as Response)
await sectlCloudStorage.downloadFile("file-123")
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(`${SECTL_CONFIG.baseUrl}/api/cloud/files/file-123/download`),
expect.any(Object)
)
})
})
describe("renameFile", () => {
it("should call correct API endpoint with PUT method", async () => {
const mockResponse = { file_id: "file-123", filename: "new.txt" }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlCloudStorage.renameFile("file-123", "new.txt")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/files/file-123`,
expect.objectContaining({
method: "PUT",
})
)
})
})
describe("createShare", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { share_id: "share-123", share_url: "https://example.com" }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlCloudStorage.createShare("file-123")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/share`,
expect.objectContaining({
method: "POST",
})
)
})
})
describe("disableShare", () => {
it("should call correct API endpoint", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
} as Response)
await sectlCloudStorage.disableShare("share-123")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/shares/share-123/disable`,
expect.objectContaining({
method: "POST",
})
)
})
})
describe("enableShare", () => {
it("should call correct API endpoint", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
} as Response)
await sectlCloudStorage.enableShare("share-123")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/shares/share-123/enable`,
expect.objectContaining({
method: "POST",
})
)
})
})
})
+295 -488
View File
@@ -1,11 +1,10 @@
/**
* SECTL
*
* SECTL-One-Stop SDK API
*/
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
// 文件信息类型
export interface CloudFile {
file_id: string
filename: string
@@ -18,7 +17,6 @@ export interface CloudFile {
thumbnail_url?: string
}
// 分享信息类型
export interface ShareLink {
share_id: string
share_url: string
@@ -31,18 +29,16 @@ export interface ShareLink {
status: "active" | "disabled" | "expired"
}
// KV 存储类型
export interface KVData {
kv_id: string
key: string
value: any
value: unknown
is_json: boolean
size: number
created_at: string
updated_at: string
}
// 存储使用情况
export interface StorageUsage {
used_storage: number
used_storage_formatted: string
@@ -55,542 +51,353 @@ export interface StorageUsage {
}
class SectlCloudStorageService {
/**
*
*/
async listFiles(options?: {
folder_id?: string
limit?: number
offset?: number
}): Promise<{ files: CloudFile[]; total: number; has_more: boolean }> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const params = new URLSearchParams({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
limit: String(options?.limit || 100),
offset: String(options?.offset || 0),
})
if (options?.folder_id) {
params.append("folder_id", options.folder_id)
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files?${params.toString()}`
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "获取文件列表失败")
}
return await response.json()
} catch (error) {
console.error("获取文件列表失败:", error)
throw error
}
private getAccessToken(): string {
const token = sectlAuth.getAccessToken()
if (!token) throw new Error("未授权,请先登录")
return token
}
private getAuthHeaders(): Record<string, string> {
return { Authorization: `Bearer ${this.getAccessToken()}` }
}
private buildParams(extra?: Record<string, string>): URLSearchParams {
const params = new URLSearchParams({ client_id: SECTL_CONFIG.platformId })
const userId = sectlAuth.getUserId()
if (userId) params.append("user_id", userId)
if (extra) {
for (const [key, value] of Object.entries(extra)) {
params.append(key, value)
}
}
return params
}
/**
*
*/
async uploadFile(
file: File | Blob,
options?: {
folder_id?: string
filename?: string
}
): Promise<{
success: boolean
file_id: string
filename: string
size: number
size_formatted: string
}> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
options?: { filename?: string; description?: string; tags?: string[] }
): Promise<CloudFile> {
const formData = new FormData()
formData.append("file", file, options?.filename)
formData.append("client_id", SECTL_CONFIG.platformId)
formData.append("user_id", sectlAuth.getToken()?.user_id || "")
if (options?.folder_id) {
formData.append("folder_id", options.folder_id)
}
const userId = sectlAuth.getUserId()
if (userId) formData.append("user_id", userId)
if (options?.description) formData.append("description", options.description)
if (options?.tags) formData.append("tags", JSON.stringify(options.tags))
// 如果是 File 对象,直接使用;否则需要指定文件名
if (file instanceof File) {
formData.append("file", file)
} else {
const filename = options?.filename || "upload_" + Date.now()
formData.append("file", file, filename)
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/upload`
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: formData,
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "文件上传失败")
}
return await response.json()
} catch (error) {
console.error("文件上传失败:", error)
throw error
}
}
/**
* Base64
*/
async uploadFileBase64(
base64Data: string,
filename: string,
mimeType: string = "application/octet-stream",
options?: {
folder_id?: string
}
): Promise<{
success: boolean
file_id: string
filename: string
size: number
}> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/upload`
const payload = {
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
filename,
mime_type: mimeType,
file: base64Data,
folder_id: options?.folder_id,
}
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "文件上传失败")
}
return await response.json()
} catch (error) {
console.error("文件上传失败:", error)
throw error
}
}
/**
*
*/
async downloadFile(fileId: string): Promise<{ download_url: string; expires_at: string }> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const params = new URLSearchParams({
client_id: SECTL_CONFIG.platformId,
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/upload`, {
method: "POST",
headers: this.getAuthHeaders(),
body: formData,
})
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/download?${params.toString()}`
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "获取下载链接失败")
}
return await response.json()
} catch (error) {
console.error("获取下载链接失败:", error)
throw error
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "文件上传失败")
}
return response.json()
}
/**
*
*/
async previewFile(fileId: string): Promise<{ view_url: string; expires_at: string }> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
async listFiles(options?: {
folder?: string
limit?: number
offset?: number
}): Promise<{ files: CloudFile[]; total: number }> {
const params = this.buildParams()
if (options?.folder) params.append("folder", options.folder)
if (options?.limit) params.append("limit", String(options.limit))
if (options?.offset) params.append("offset", String(options.offset))
const params = new URLSearchParams({
client_id: SECTL_CONFIG.platformId,
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files?${params}`, {
headers: this.getAuthHeaders(),
})
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/preview?${params.toString()}`
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "获取文件列表失败")
}
return response.json()
}
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
async getFileInfo(fileId: string): Promise<CloudFile> {
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`, {
headers: this.getAuthHeaders(),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "获取预览链接失败")
}
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "获取文件信息失败")
}
return response.json()
}
return await response.json()
} catch (error) {
console.error("获取预览链接失败:", error)
throw error
async downloadFile(fileId: string): Promise<Blob> {
const params = this.buildParams()
const response = await fetch(
`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/download?${params}`,
{ headers: this.getAuthHeaders() }
)
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "下载文件失败")
}
return response.blob()
}
async previewFile(fileId: string): Promise<CloudFile> {
const params = this.buildParams()
const response = await fetch(
`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/preview?${params}`,
{ headers: this.getAuthHeaders() }
)
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "预览文件失败")
}
return response.json()
}
async deleteFile(fileId: string, userId?: string): Promise<void> {
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`, {
method: "DELETE",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
...(userId ? { user_id: userId } : {}),
}),
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "删除文件失败")
}
}
/**
*
*/
async deleteFile(fileId: string): Promise<{ success: boolean; message: string }> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`
try {
const response = await fetch(url, {
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "删除文件失败")
}
return await response.json()
} catch (error) {
console.error("删除文件失败:", error)
throw error
async renameFile(fileId: string, newFilename: string): Promise<CloudFile> {
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
filename: newFilename,
}),
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "重命名文件失败")
}
return response.json()
}
/**
*
*/
async renameFile(
fileId: string,
newFilename: string
): Promise<{
file_id: string
filename: string
updated_at: string
}> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`
try {
const response = await fetch(url, {
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
filename: newFilename,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "重命名文件失败")
}
return await response.json()
} catch (error) {
console.error("重命名文件失败:", error)
throw error
}
}
/**
*
*/
async createShare(
fileId: string,
options?: {
expires_in?: number // 秒,0 表示永久
password?: string
}
): Promise<{
share_id: string
share_url: string
file_id: string
filename: string
expires_at?: string
has_password: boolean
}> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/share`
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
file_id: fileId,
expires_in: options?.expires_in || 86400, // 默认 1 天
password: options?.password,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "创建分享失败")
}
return await response.json()
} catch (error) {
console.error("创建分享失败:", error)
throw error
options?: { expiresIn?: number; password?: string; userId?: string }
): Promise<ShareLink> {
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/share`, {
method: "POST",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
file_id: fileId,
...(options?.expiresIn ? { expires_in: options.expiresIn } : {}),
...(options?.password ? { password: options.password } : {}),
...(options?.userId ? { user_id: options.userId } : {}),
}),
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "创建分享链接失败")
}
return response.json()
}
/**
*
*/
async listShares(): Promise<{ shares: ShareLink[]; total: number }> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const params = new URLSearchParams({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
const params = this.buildParams()
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares?${params}`, {
headers: this.getAuthHeaders(),
})
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares?${params.toString()}`
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "获取分享列表失败")
}
return response.json()
}
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
async disableShare(shareId: string, userId?: string): Promise<void> {
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/disable`, {
method: "POST",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
...(userId ? { user_id: userId } : {}),
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "获取分享列表失败")
}
return await response.json()
} catch (error) {
console.error("获取分享列表失败:", error)
throw error
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "禁用分享失败")
}
}
/**
*
*/
async disableShare(shareId: string): Promise<{ success: boolean; message: string }> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
async enableShare(shareId: string, userId?: string): Promise<void> {
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/enable`, {
method: "POST",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
...(userId ? { user_id: userId } : {}),
}),
})
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/disable`
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "禁用分享失败")
}
return await response.json()
} catch (error) {
console.error("禁用分享失败:", error)
throw error
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "启用分享失败")
}
}
/**
*
*/
async enableShare(shareId: string): Promise<{ success: boolean; message: string }> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
async deleteShare(shareId: string, userId?: string): Promise<void> {
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}`, {
method: "DELETE",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
...(userId ? { user_id: userId } : {}),
}),
})
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/enable`
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "启用分享失败")
}
return await response.json()
} catch (error) {
console.error("启用分享失败:", error)
throw error
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "删除分享失败")
}
}
/**
*
*/
async deleteShare(shareId: string): Promise<{ success: boolean; message: string }> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}`
try {
const response = await fetch(url, {
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "删除分享失败")
}
return await response.json()
} catch (error) {
console.error("删除分享失败:", error)
throw error
}
}
/**
* 使
*/
async getStorageUsage(): Promise<StorageUsage> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const params = new URLSearchParams({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
const params = this.buildParams()
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/storage/usage?${params}`, {
headers: this.getAuthHeaders(),
})
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/storage/usage?${params.toString()}`
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "获取存储使用情况失败")
}
return response.json()
}
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
async setKV(
key: string,
value: string | Record<string, unknown>,
options?: { expiresIn?: number; userId?: string; description?: string }
): Promise<KVData> {
const isJson = typeof value === "object"
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv`, {
method: "POST",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
key,
value: isJson ? JSON.stringify(value) : value,
is_json: isJson,
...(options?.expiresIn ? { expires_in: options.expiresIn } : {}),
...(options?.userId ? { user_id: options.userId } : {}),
...(options?.description ? { description: options.description } : {}),
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "获取存储使用情况失败")
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "设置 KV 失败")
}
return response.json()
}
async getKV(key: string): Promise<KVData> {
const response = await fetch(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`,
{
method: "GET",
headers: this.getAuthHeaders(),
}
)
return await response.json()
} catch (error) {
console.error("获取存储使用情况失败:", error)
throw error
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "获取 KV 失败")
}
return response.json()
}
async updateKVField(
key: string,
field: string,
value: unknown,
userId?: string
): Promise<KVData> {
const response = await fetch(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
field,
value: typeof value === "object" ? JSON.stringify(value) : value,
...(userId ? { user_id: userId } : {}),
}),
}
)
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "更新 KV 字段失败")
}
return response.json()
}
async listKV(options?: {
prefix?: string
limit?: number
offset?: number
}): Promise<{ data: KVData[]; total: number }> {
const params = this.buildParams()
if (options?.prefix) params.append("prefix", options.prefix)
if (options?.limit) params.append("limit", String(options.limit))
if (options?.offset) params.append("offset", String(options.offset))
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv?${params}`, {
headers: this.getAuthHeaders(),
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "获取 KV 列表失败")
}
return response.json()
}
async deleteKV(key: string, userId?: string): Promise<void> {
const response = await fetch(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`,
{
method: "DELETE",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
...(userId ? { user_id: userId } : {}),
}),
}
)
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "删除 KV 失败")
}
}
}
// 导出单例
export const sectlCloudStorage = new SectlCloudStorageService()
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { sectlKVStorage } from "./sectlKVStorage"
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
// Mock fetch and sectlAuth
global.fetch = vi.fn()
vi.mock("./sectlAuth", () => ({
SECTL_CONFIG: {
baseUrl: "https://api.sectl.cn",
platformId: "test-platform-id",
},
sectlAuth: {
getAccessToken: vi.fn(),
getUserId: vi.fn(),
},
}))
describe("sectlKVStorage", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(sectlAuth.getAccessToken).mockReturnValue("test-access-token")
vi.mocked(sectlAuth.getUserId).mockReturnValue("test-user-id")
})
describe("setKV", () => {
it("should call correct API endpoint for string value", async () => {
const mockResponse = { success: true }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlKVStorage.setKV("test-key", "test-value")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv`,
expect.objectContaining({
method: "POST",
body: expect.stringContaining('"key":"test-key"'),
})
)
})
it("should call correct API endpoint for object value", async () => {
const mockResponse = { success: true }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlKVStorage.setKV("test-key", { foo: "bar" })
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv`,
expect.objectContaining({
method: "POST",
body: expect.stringContaining('"key":"test-key"'),
})
)
})
})
describe("getKV", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { value: "test-value" }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlKVStorage.getKV("test-key")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/test-key?client_id=test-platform-id&user_id=test-user-id`,
expect.any(Object)
)
})
})
describe("deleteKV", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { success: true }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlKVStorage.deleteKV("test-key")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/test-key`,
expect.objectContaining({
method: "DELETE",
})
)
})
})
describe("updateKVField", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { success: true }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlKVStorage.updateKVField("test-key", "field", "value")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/test-key`,
expect.objectContaining({
method: "PATCH",
})
)
})
})
})
+131 -83
View File
@@ -1,3 +1,8 @@
/**
* SECTL KV
* SECTL-One-Stop SDK KV API
*/
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
export interface KVItem {
@@ -16,58 +21,26 @@ export interface ListKVOptions {
}
class SectlKVStorageService {
private get platformId(): string {
return SECTL_CONFIG.platformId
private getAccessToken(): string {
const token = sectlAuth.getAccessToken()
if (!token) throw new Error("未授权,请先登录")
return token
}
private getAuthHeaders(): Record<string, string> {
const token = sectlAuth.getAccessToken()
if (!token) throw new Error("未授权,请先登录")
return { Authorization: `Bearer ${token}` }
return { Authorization: `Bearer ${this.getAccessToken()}` }
}
private async makeRequest<T>(
method: string,
endpoint: string,
options: { jsonData?: Record<string, unknown>; params?: Record<string, unknown> } = {}
): Promise<T> {
const headers: Record<string, string> = {
...this.getAuthHeaders(),
"Content-Type": "application/json",
}
let url = `${SECTL_CONFIG.baseUrl}${endpoint}`
if (options.params) {
const searchParams = new URLSearchParams()
for (const [k, v] of Object.entries(options.params)) {
if (v !== undefined && v !== null) searchParams.set(k, String(v))
private buildParams(extra?: Record<string, string>): URLSearchParams {
const params = new URLSearchParams({ client_id: SECTL_CONFIG.platformId })
const userId = sectlAuth.getUserId()
if (userId) params.append("user_id", userId)
if (extra) {
for (const [key, value] of Object.entries(extra)) {
params.append(key, value)
}
const qs = searchParams.toString()
if (qs) url += `?${qs}`
}
const init: RequestInit = { method, headers }
if (options.jsonData && method !== "GET") {
init.body = JSON.stringify(options.jsonData)
}
const response = await fetch(url, init)
if (!response.ok) {
let errorData: { error?: string; error_description?: string; message?: string } = {}
try {
errorData = await response.json()
} catch {
// ignore
}
const desc = errorData.error_description || errorData.message || `HTTP ${response.status}`
const err = new Error(desc) as Error & { status?: number; error?: string }
err.status = response.status
err.error = errorData.error
throw err
}
return response.json() as Promise<T>
return params
}
async setKV(
@@ -83,67 +56,142 @@ class SectlKVStorageService {
updated_at?: string
message: string
}> {
const jsonData: Record<string, unknown> = {
client_id: this.platformId,
const isJson = typeof value === "object" && value !== null
const body: Record<string, unknown> = {
client_id: SECTL_CONFIG.platformId,
key,
value,
}
if (ttl !== undefined) jsonData.ttl = ttl
const userId = sectlAuth.getUserId()
if (userId) body.user_id = userId
if (ttl) body.ttl = ttl
return this.makeRequest("POST", "/api/cloud/kv", { jsonData })
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv`, {
method: "POST",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify(body),
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "设置 KV 失败")
}
const result = await response.json()
return {
success: result.success ?? true,
key: result.key ?? key,
size: result.size ?? 0,
is_json: result.is_json ?? isJson,
created_at: result.created_at,
updated_at: result.updated_at,
message: result.message ?? "OK",
}
}
async getKV<T = unknown>(
key: string,
field?: string
): Promise<
| (KVItem & { key: string; value: T })
| { key: string; field: string; value: T; is_json: boolean }
> {
const params: Record<string, string> = { client_id: this.platformId }
if (field) params.field = field
async getKV<T = unknown>(key: string): Promise<KVItem & { key: string; value: T }> {
const params = this.buildParams()
const response = await fetch(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}?${params}`,
{
headers: this.getAuthHeaders(),
}
)
return this.makeRequest("GET", `/api/cloud/kv/${encodeURIComponent(key)}`, { params })
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "获取 KV 失败")
}
const result = await response.json()
return {
key: result.key ?? key,
value: result.value as T,
size: result.size ?? 0,
is_json: result.is_json ?? false,
created_at: result.created_at ?? "",
updated_at: result.updated_at ?? "",
}
}
async listKV(
options: ListKVOptions = {}
): Promise<{ kv_list: KVItem[]; total: number; has_more: boolean }> {
const params: Record<string, unknown> = {
client_id: this.platformId,
limit: Math.min(options.limit || 100, 1000),
offset: options.offset || 0,
}
if (options.prefix) params.prefix = options.prefix
const params = this.buildParams({
limit: String(options.limit ?? 100),
offset: String(options.offset ?? 0),
})
if (options.prefix) params.append("prefix", options.prefix)
return this.makeRequest("GET", "/api/cloud/kv", { params })
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv?${params}`, {
headers: this.getAuthHeaders(),
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "获取 KV 列表失败")
}
const result = await response.json()
return {
kv_list: result.kv_list ?? [],
total: result.total ?? 0,
has_more: result.has_more ?? false,
}
}
async updateKVField(
key: string,
field: string,
value: unknown
): Promise<{
success: boolean
key: string
field: string
size: number
updated_at: string
message: string
}> {
return this.makeRequest("PATCH", `/api/cloud/kv/${encodeURIComponent(key)}`, {
jsonData: { client_id: this.platformId, field, value },
})
): Promise<{ success: boolean; message: string }> {
const body: Record<string, unknown> = {
client_id: SECTL_CONFIG.platformId,
field,
value,
}
const userId = sectlAuth.getUserId()
if (userId) body.user_id = userId
const response = await fetch(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify(body),
}
)
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "更新 KV 字段失败")
}
return { success: true, message: "OK" }
}
async deleteKV(key: string): Promise<{ success: boolean; message: string }> {
return this.makeRequest("DELETE", `/api/cloud/kv/${encodeURIComponent(key)}`, {
jsonData: { client_id: this.platformId },
})
}
const body = {
client_id: SECTL_CONFIG.platformId,
}
const userId = sectlAuth.getUserId()
if (userId) (body as Record<string, unknown>).user_id = userId
getPlatformId(): string {
return this.platformId
const response = await fetch(
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`,
{
method: "DELETE",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify(body),
}
)
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.error_description || "删除 KV 失败")
}
return { success: true, message: "OK" }
}
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { sectlNotification } from "./sectlNotification"
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
// Mock fetch and sectlAuth
global.fetch = vi.fn()
vi.mock("./sectlAuth", () => ({
SECTL_CONFIG: {
baseUrl: "https://api.sectl.cn",
platformId: "test-platform-id",
},
sectlAuth: {
getAccessToken: vi.fn(),
getUserId: vi.fn(),
},
}))
describe("sectlNotification", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(sectlAuth.getAccessToken).mockReturnValue("test-access-token")
vi.mocked(sectlAuth.getUserId).mockReturnValue("test-user-id")
})
describe("getNotifications", () => {
it("should call correct API endpoint without client_id", async () => {
const mockResponse = { notifications: [], total: 0 }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlNotification.getNotifications({ limit: 10, offset: 0 })
const calledUrl = vi.mocked(global.fetch).mock.calls[0][0] as string
expect(calledUrl).toContain(`${SECTL_CONFIG.baseUrl}/api/notifications?`)
expect(calledUrl).toContain("limit=10")
expect(calledUrl).toContain("offset=0")
expect(calledUrl).not.toContain("client_id")
})
})
describe("markNotificationRead", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { success: true, notification: { $id: "notif-123", is_read: true } }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlNotification.markNotificationRead("notif-123")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/notifications/notif-123/read`,
expect.objectContaining({
method: "POST",
})
)
})
})
describe("markAllNotificationsRead", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { success: true, marked_count: 5 }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlNotification.markAllNotificationsRead()
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/notifications/read-all`,
expect.objectContaining({
method: "POST",
})
)
})
})
describe("deleteNotification", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { success: true, message: "deleted" }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlNotification.deleteNotification("notif-123")
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/notifications/notif-123`,
expect.objectContaining({
method: "DELETE",
})
)
})
})
describe("sendNotification", () => {
it("should call correct API endpoint", async () => {
const mockResponse = { success: true, notification_id: "notif-123" }
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockResponse,
} as Response)
await sectlNotification.sendNotification({
user_id: "user-123",
title: "Test",
content: "Test content",
})
expect(global.fetch).toHaveBeenCalledWith(
`${SECTL_CONFIG.baseUrl}/api/notifications/send`,
expect.objectContaining({
method: "POST",
})
)
})
})
})
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
})