mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 06:04:22 +08:00
feat(auth): 添加 SECTL Auth OAuth 登录功能
- 新增 OAuth 登录组件和回调页面 - 集成 Tauri 深度链接插件处理 OAuth 回调 - 添加账户设置页面显示登录信息 - 更新 CI 配置添加 OAuth 环境变量 - 扩展 API 接口支持 OAuth 相关操作 - 添加国际化支持
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -72,6 +72,13 @@ jobs:
|
||||
- name: 应用版本号
|
||||
run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }}
|
||||
|
||||
- name: 设置 OAuth 环境变量
|
||||
run: |
|
||||
echo "VITE_OAUTH_PLATFORM_ID=${{ secrets.OAUTH_PLATFORM_ID }}" >> $env:GITHUB_ENV
|
||||
echo "VITE_OAUTH_PLATFORM_SECRET=${{ secrets.OAUTH_PLATFORM_SECRET }}" >> $env:GITHUB_ENV
|
||||
echo "VITE_OAUTH_CALLBACK_URL=secscore://oauth/callback" >> $env:GITHUB_ENV
|
||||
shell: pwsh
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -108,6 +115,12 @@ jobs:
|
||||
- name: 应用版本号
|
||||
run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }}
|
||||
|
||||
- name: 设置 OAuth 环境变量
|
||||
run: |
|
||||
echo "VITE_OAUTH_PLATFORM_ID=${{ secrets.OAUTH_PLATFORM_ID }}" >> $GITHUB_ENV
|
||||
echo "VITE_OAUTH_PLATFORM_SECRET=${{ secrets.OAUTH_PLATFORM_SECRET }}" >> $GITHUB_ENV
|
||||
echo "VITE_OAUTH_CALLBACK_URL=secscore://oauth/callback" >> $GITHUB_ENV
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -149,6 +162,12 @@ jobs:
|
||||
- name: 应用版本号
|
||||
run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }}
|
||||
|
||||
- name: 设置 OAuth 环境变量
|
||||
run: |
|
||||
echo "VITE_OAUTH_PLATFORM_ID=${{ secrets.OAUTH_PLATFORM_ID }}" >> $GITHUB_ENV
|
||||
echo "VITE_OAUTH_PLATFORM_SECRET=${{ secrets.OAUTH_PLATFORM_SECRET }}" >> $GITHUB_ENV
|
||||
echo "VITE_OAUTH_CALLBACK_URL=secscore://oauth/callback" >> $GITHUB_ENV
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# AGENTS 协作规范
|
||||
|
||||
## 代码规范
|
||||
|
||||
- 所有代码必须符合 Rust 语言规范。
|
||||
- 所有代码跑`pnpm run typecheck` or `cargo typecheck` 必须通过。
|
||||
|
||||
## 提交与推送要求
|
||||
|
||||
- 每次代码修改后,除非用户说明进行一次 Git 提交(commit)并推送(git push)否则不进行。
|
||||
- 用户只说 `cp` 时,表示执行一次 Git 提交(commit)并推送(git push)。
|
||||
- 提交信息必须使用中文。
|
||||
|
||||
+922
@@ -0,0 +1,922 @@
|
||||
# SECTL Auth API 参考
|
||||
|
||||
本文档详细说明 SECTL Auth 提供的所有 API 接口。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- **基础 URL**: `https://sectl.top`
|
||||
- **认证方式**: OAuth 2.0 + Bearer Token
|
||||
- **数据格式**: JSON
|
||||
|
||||
## 接口概览
|
||||
|
||||
| 接口 | 方法 | 说明 | 对应页面 |
|
||||
| ----------------------- | ---- | ------------------- | ------------------------ |
|
||||
| `/oauth/authorize` | GET | 获取授权码 | OAuthAuthorizeView.vue |
|
||||
| `/api/oauth/token` | POST | 换取/刷新令牌 | AuthCallbackView.vue |
|
||||
| `/api/oauth/userinfo` | GET | 获取用户信息 | PlatformUsersView.vue |
|
||||
| `/api/oauth/config` | POST | 配置 Token 过期时间 | PlatformSettingsView.vue |
|
||||
| `/api/oauth/revoke` | POST | 撤销令牌 | SecurityView.vue |
|
||||
| `/api/oauth/introspect` | POST | 验证令牌 | - |
|
||||
|
||||
---
|
||||
|
||||
## OAuth 接口
|
||||
|
||||
### 1. 获取授权码
|
||||
|
||||
引导用户跳转到授权页面。
|
||||
|
||||
```http
|
||||
GET /oauth/authorize
|
||||
```
|
||||
|
||||
**请求参数:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------------- | ------ | ---- | ---------------- |
|
||||
| client_id | string | ✅ | 平台 ID |
|
||||
| redirect_uri | string | ✅ | 回调 URL |
|
||||
| response_type | string | ✅ | 固定值 `code` |
|
||||
| state | string | ❌ | 防 CSRF 状态参数 |
|
||||
|
||||
**示例请求:**
|
||||
|
||||
```
|
||||
https://sectl.top/oauth/authorize?client_id=pf_AHmIhAiptyLFxk8x&redirect_uri=http://localhost:5000/callback&response_type=code
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
|
||||
- 重定向到 `redirect_uri?code={authorization_code}&state={state}`
|
||||
|
||||
**错误响应:**
|
||||
|
||||
- 重定向到 `redirect_uri?error={error_code}&error_description={description}`
|
||||
|
||||
**错误码:**
|
||||
|
||||
| 错误码 | 说明 |
|
||||
| ------------------------- | ------------------ |
|
||||
| invalid_request | 请求参数缺失或无效 |
|
||||
| unauthorized_client | 客户端未授权 |
|
||||
| access_denied | 用户拒绝授权 |
|
||||
| unsupported_response_type | 不支持的响应类型 |
|
||||
| invalid_scope | 无效的权限范围 |
|
||||
| server_error | 服务器内部错误 |
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - 授权页面
|
||||
- [LoginView.vue](../../src/views/LoginView.vue) - 登录页面
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取访问令牌
|
||||
|
||||
使用授权码换取访问令牌。
|
||||
|
||||
```http
|
||||
POST /api/oauth/token
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------------- | ------ | ---- | ------------------------------------------------ |
|
||||
| grant_type | string | ✅ | `authorization_code` 或 `refresh_token` |
|
||||
| code | string | 条件 | 授权码(grant_type=authorization_code 时必填) |
|
||||
| refresh_token | string | 条件 | 刷新令牌(grant_type=refresh_token 时必填) |
|
||||
| client_id | string | ✅ | 平台 ID |
|
||||
| client_secret | string | ✅ | 平台密钥 |
|
||||
| redirect_uri | string | 条件 | 回调地址(grant_type=authorization_code 时必填) |
|
||||
|
||||
**授权码模式示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "NjliNDIyOTYwMDE4znLUNeWM6J8ye0MC",
|
||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
||||
"client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1",
|
||||
"redirect_uri": "http://localhost:5000/callback"
|
||||
}
|
||||
```
|
||||
|
||||
**刷新令牌模式示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
||||
"client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------- | ------ | ----------------------- |
|
||||
| access_token | string | 访问令牌(JWT) |
|
||||
| refresh_token | string | 刷新令牌 |
|
||||
| token_type | string | 令牌类型,固定为 Bearer |
|
||||
| expires_in | number | 过期时间(秒) |
|
||||
|
||||
**错误响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Authorization code has expired"
|
||||
}
|
||||
```
|
||||
|
||||
**错误码:**
|
||||
|
||||
| 错误码 | HTTP 状态 | 说明 |
|
||||
| ---------------------- | --------- | ------------------------ |
|
||||
| invalid_request | 400 | 请求格式错误 |
|
||||
| invalid_client | 401 | 客户端认证失败 |
|
||||
| invalid_grant | 400 | 授权码无效或过期 |
|
||||
| unauthorized_client | 400 | 客户端无权使用此授权类型 |
|
||||
| unsupported_grant_type | 400 | 不支持的授权类型 |
|
||||
| invalid_scope | 400 | 无效的权限范围 |
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 回调处理
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取用户信息
|
||||
|
||||
使用访问令牌获取用户信息。
|
||||
|
||||
```http
|
||||
GET /api/oauth/userinfo
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "69bd422960018cf4d0e5",
|
||||
"email": "user@example.com",
|
||||
"name": "张三",
|
||||
"github_username": "zhangsan",
|
||||
"permission": 1
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------------- | ------ | --------------------- |
|
||||
| user_id | string | 用户唯一标识 |
|
||||
| email | string | 用户邮箱 |
|
||||
| name | string | 用户名称 |
|
||||
| github_username | string | GitHub 用户名(如有) |
|
||||
| permission | number | 用户权限等级 |
|
||||
|
||||
**错误响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid_token",
|
||||
"error_description": "The access token is invalid or has expired"
|
||||
}
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue) - 平台用户列表
|
||||
|
||||
---
|
||||
|
||||
### 4. 配置 Token 过期时间
|
||||
|
||||
配置平台的 access_token 过期时间(需要管理员权限)。
|
||||
|
||||
```http
|
||||
POST /api/oauth/config
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------------ | ------ | ---- | ------------------------------------------- |
|
||||
| platform_id | string | ✅ | 平台 ID |
|
||||
| expires_in | number | ✅ | 过期时间(秒),-1 表示永不过期,最小 60 秒 |
|
||||
| admin_secret | string | ✅ | 管理员密钥 |
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"platform_id": "pf_AHmIhAiptyLFxk8x",
|
||||
"expires_in": 86400,
|
||||
"admin_secret": "your-admin-secret"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Token expiration time updated",
|
||||
"expires_in": 86400
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "unauthorized",
|
||||
"error_description": "Invalid admin secret"
|
||||
}
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置
|
||||
|
||||
---
|
||||
|
||||
### 5. 撤销令牌
|
||||
|
||||
撤销指定的访问令牌或刷新令牌。
|
||||
|
||||
```http
|
||||
POST /api/oauth/revoke
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| --------------- | ------ | ---- | --------------------------------- |
|
||||
| token | string | ✅ | 要撤销的令牌 |
|
||||
| token_type_hint | string | ❌ | `access_token` 或 `refresh_token` |
|
||||
| client_id | string | ✅ | 平台 ID |
|
||||
| client_secret | string | ✅ | 平台密钥 |
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"token_type_hint": "access_token",
|
||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
||||
"client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Token revoked successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) - 安全设置
|
||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表
|
||||
|
||||
---
|
||||
|
||||
### 6. 验证令牌
|
||||
|
||||
验证访问令牌的有效性和获取令牌信息。
|
||||
|
||||
```http
|
||||
POST /api/oauth/introspect
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------------- | ------ | ---- | ------------ |
|
||||
| token | string | ✅ | 要验证的令牌 |
|
||||
| client_id | string | ✅ | 平台 ID |
|
||||
| client_secret | string | ✅ | 平台密钥 |
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
||||
"client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1"
|
||||
}
|
||||
```
|
||||
|
||||
**活跃令牌响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"active": true,
|
||||
"user_id": "69bd422960018cf4d0e5",
|
||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
||||
"exp": 1704783600,
|
||||
"iat": 1704780000
|
||||
}
|
||||
```
|
||||
|
||||
**非活跃令牌响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"active": false
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | ------- | ------------------------ |
|
||||
| active | boolean | 令牌是否有效 |
|
||||
| user_id | string | 用户 ID(仅活跃令牌) |
|
||||
| client_id | string | 平台 ID(仅活跃令牌) |
|
||||
| exp | number | 过期时间戳(仅活跃令牌) |
|
||||
| iat | number | 签发时间戳(仅活跃令牌) |
|
||||
|
||||
---
|
||||
|
||||
## 平台管理 API
|
||||
|
||||
以下 API 用于管理平台信息,需要通过 Appwrite SDK 调用。
|
||||
|
||||
### 获取平台列表
|
||||
|
||||
```javascript
|
||||
// 使用 Appwrite SDK
|
||||
const result = await databases.listDocuments({
|
||||
databaseId: "69bd89d8000304c37368",
|
||||
collectionId: "platforms",
|
||||
queries: [Query.orderDesc("$createdAt"), Query.limit(100)],
|
||||
})
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)
|
||||
|
||||
### 获取平台详情
|
||||
|
||||
```javascript
|
||||
const platform = await databases.getDocument({
|
||||
databaseId: "69bd89d8000304c37368",
|
||||
collectionId: "platforms",
|
||||
documentId: "platform-document-id",
|
||||
})
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue)
|
||||
|
||||
### 更新平台信息
|
||||
|
||||
```javascript
|
||||
await databases.updateDocument({
|
||||
databaseId: "69bd89d8000304c37368",
|
||||
collectionId: "platforms",
|
||||
documentId: "platform-document-id",
|
||||
data: {
|
||||
callback_url: "http://new-callback.com",
|
||||
token_expires_in: 7200,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue)
|
||||
|
||||
### 申请创建平台
|
||||
|
||||
```javascript
|
||||
await databases.createDocument({
|
||||
databaseId: "69bd89d8000304c37368",
|
||||
collectionId: "platform_applications",
|
||||
documentId: ID.unique(),
|
||||
data: {
|
||||
name: "我的应用",
|
||||
description: "应用描述",
|
||||
callback_url: "http://localhost:5000/callback",
|
||||
owner_id: currentUser.$id,
|
||||
status: "pending",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [PlatformApplyView.vue](../../src/views/dashboard/form/PlatformApplyView.vue)
|
||||
|
||||
---
|
||||
|
||||
## 用户管理 API
|
||||
|
||||
### 获取用户授权的平台列表
|
||||
|
||||
```javascript
|
||||
const result = await databases.listDocuments({
|
||||
databaseId: "69bd89d8000304c37368",
|
||||
collectionId: "oauth_tokens",
|
||||
queries: [Query.equal("user_id", currentUser.$id), Query.equal("revoked", false)],
|
||||
})
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue)
|
||||
|
||||
### 获取平台授权用户列表
|
||||
|
||||
```javascript
|
||||
const result = await databases.listDocuments({
|
||||
databaseId: "69bd89d8000304c37368",
|
||||
collectionId: "oauth_tokens",
|
||||
queries: [Query.equal("platform_id", "pf_xxx"), Query.equal("revoked", false)],
|
||||
})
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue)
|
||||
|
||||
---
|
||||
|
||||
## 云存储 API
|
||||
|
||||
云存储 API 提供文件上传、下载、管理等功能。需要通过 OAuth access_token 进行认证。
|
||||
|
||||
### 接口概览
|
||||
|
||||
| 接口 | 方法 | 说明 | SDK 方法 |
|
||||
| ------------------------------- | ------ | ------------------------ | ------------------------------- |
|
||||
| `/api/cloud/upload` | POST | 上传文件 | `uploadFile()` |
|
||||
| `/api/cloud/files/{file_id}` | GET | 获取文件信息 | `getFile()` |
|
||||
| `/api/cloud/download/{file_id}` | GET | 获取下载链接 | `downloadFile()` |
|
||||
| `/api/cloud/view/{file_id}` | GET | 查看文件内容 | `viewFile()` |
|
||||
| `/api/cloud/preview/{file_id}` | GET | 预览文件(支持图片处理) | `previewFile()` |
|
||||
| `/api/cloud/info` | GET | 获取存储信息 | `getStorageInfo()` |
|
||||
| `/api/cloud/files` | GET | 列出/搜索文件 | `listFiles()` / `searchFiles()` |
|
||||
| `/api/cloud/files/{file_id}` | PUT | 更新文件(重命名) | `updateFile()` |
|
||||
| `/api/cloud/files/{file_id}` | DELETE | 删除文件 | `deleteFile()` |
|
||||
|
||||
---
|
||||
|
||||
### 1. 上传文件
|
||||
|
||||
上传文件到云存储,系统会自动验证文件格式和检查存储容量。
|
||||
|
||||
```http
|
||||
POST /api/cloud/upload
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Authorization: Bearer {access_token}
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**请求参数:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------ | ---- | ------------ |
|
||||
| file | File | ✅ | 要上传的文件 |
|
||||
| platform_id | string | ✅ | 平台 ID |
|
||||
|
||||
**请求示例(JavaScript):**
|
||||
|
||||
```javascript
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileInput.files[0])
|
||||
formData.append("platform_id", "pf_AHmIhAiptyLFxk8x")
|
||||
|
||||
const response = await fetch("https://sectl.top/api/cloud/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer eyJhbGciOiJIUzI1NiIs...",
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"file_id": "cf_xxxxxxxx",
|
||||
"filename": "example.jpg",
|
||||
"size": 1024567,
|
||||
"url": "https://cloud.appwrite.io/v1/storage/buckets/69c869010007b752a9b1/files/xxx/view",
|
||||
"storage": {
|
||||
"used": "50MB",
|
||||
"limit": "100MB",
|
||||
"available": "50MB",
|
||||
"percentage": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应 - 格式不允许:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid_format",
|
||||
"error_description": "File format 'exe' is not allowed",
|
||||
"allowed_formats": ["jpg", "png", "pdf"]
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应 - 超出容量:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "storage_exceeded",
|
||||
"error_description": "Upload would exceed storage limit",
|
||||
"storage": {
|
||||
"used": "95MB",
|
||||
"limit": "100MB",
|
||||
"available": "5MB",
|
||||
"requested": "10MB"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [CloudServiceView.vue](../../src/views/dashboard/CloudServiceView.vue) - 云服务总览
|
||||
|
||||
---
|
||||
|
||||
### 2. 下载文件
|
||||
|
||||
获取文件的下载链接。
|
||||
|
||||
```http
|
||||
GET /api/cloud/download/{file_id}
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
**路径参数:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------- | ------ | ---- | ------- |
|
||||
| file_id | string | ✅ | 文件 ID |
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"file_id": "cf_xxxxxxxx",
|
||||
"filename": "example.jpg",
|
||||
"download_url": "https://cloud.appwrite.io/v1/storage/buckets/69c869010007b752a9b1/files/xxx/download",
|
||||
"expires_at": "2025-01-01T01:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取存储信息
|
||||
|
||||
获取用户在该平台的存储使用情况和配置信息。
|
||||
|
||||
```http
|
||||
GET /api/cloud/info?platform_id={platform_id}
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
**查询参数:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------ | ---- | ------- |
|
||||
| platform_id | string | ✅ | 平台 ID |
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"platform_id": "pf_xxx",
|
||||
"tier": "free",
|
||||
"storage": {
|
||||
"used": 52428800,
|
||||
"used_formatted": "50MB",
|
||||
"limit": 104857600,
|
||||
"limit_formatted": "100MB",
|
||||
"available": 52428800,
|
||||
"available_formatted": "50MB",
|
||||
"percentage": 50
|
||||
},
|
||||
"allowed_formats": ["jpg", "png", "pdf"],
|
||||
"max_file_size": 10485760
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 列出文件
|
||||
|
||||
获取用户在该平台的文件列表。
|
||||
|
||||
```http
|
||||
GET /api/cloud/files?platform_id={platform_id}&limit=20&offset=0
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
**查询参数:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------ | ---- | ----------------- |
|
||||
| platform_id | string | ✅ | 平台 ID |
|
||||
| limit | number | ❌ | 每页数量,默认 20 |
|
||||
| offset | number | ❌ | 偏移量,默认 0 |
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"files": [
|
||||
{
|
||||
"file_id": "cf_xxx",
|
||||
"filename": "example.jpg",
|
||||
"size": 1024567,
|
||||
"size_formatted": "1MB",
|
||||
"created_at": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 10,
|
||||
"limit": 20,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 删除文件
|
||||
|
||||
删除指定的文件。
|
||||
|
||||
```http
|
||||
DELETE /api/cloud/files/{file_id}
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
**路径参数:**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------- | ------ | ---- | ------- |
|
||||
| file_id | string | ✅ | 文件 ID |
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "File deleted successfully",
|
||||
"storage": {
|
||||
"used": "45MB",
|
||||
"limit": "100MB",
|
||||
"available": "55MB"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 云存储 SDK
|
||||
|
||||
我们提供了 JavaScript SDK 简化云存储 API 的调用。
|
||||
|
||||
### 安装
|
||||
|
||||
```html
|
||||
<script src="https://sectl.top/platform-sdk/cloud-storage.js"></script>
|
||||
```
|
||||
|
||||
或使用 ES6 模块:
|
||||
|
||||
```javascript
|
||||
import CloudStorage from "./cloud-storage.js"
|
||||
```
|
||||
|
||||
### 初始化
|
||||
|
||||
```javascript
|
||||
const cloud = new CloudStorage({
|
||||
baseUrl: "https://sectl.top",
|
||||
platformId: "your_platform_id",
|
||||
accessToken: "your_access_token",
|
||||
})
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```javascript
|
||||
// 上传文件
|
||||
try {
|
||||
const result = await cloud.uploadFile(fileInput.files[0])
|
||||
console.log("文件上传成功:", result.file_id)
|
||||
console.log("存储使用情况:", result.storage.percentage + "%")
|
||||
} catch (error) {
|
||||
if (error.code === "invalid_format") {
|
||||
console.error("不支持的文件格式:", error.data.allowed_formats)
|
||||
} else if (error.code === "storage_exceeded") {
|
||||
console.error("存储空间不足")
|
||||
}
|
||||
}
|
||||
|
||||
// 获取存储信息
|
||||
const info = await cloud.getStorageInfo()
|
||||
console.log("已用空间:", info.storage.used_formatted)
|
||||
console.log("允许格式:", info.allowed_formats)
|
||||
|
||||
// 列出文件
|
||||
const files = await cloud.listFiles({ limit: 10 })
|
||||
files.files.forEach((file) => {
|
||||
console.log(file.filename, file.size_formatted)
|
||||
})
|
||||
|
||||
// 下载文件
|
||||
const download = await cloud.downloadFile("cf_xxx")
|
||||
window.location.href = download.download_url
|
||||
|
||||
// 删除文件
|
||||
await cloud.deleteFile("cf_xxx")
|
||||
```
|
||||
|
||||
**相关文件:**
|
||||
|
||||
- [cloud-storage.js](../../platform-sdk/cloud-storage.js) - SDK 源码
|
||||
|
||||
---
|
||||
|
||||
## 远程退登 API
|
||||
|
||||
### 创建退登事件
|
||||
|
||||
当用户退出登录时,系统会自动创建退登事件。
|
||||
|
||||
```javascript
|
||||
await databases.createDocument({
|
||||
databaseId: "69bd89d8000304c37368",
|
||||
collectionId: "logout_events",
|
||||
documentId: ID.unique(),
|
||||
data: {
|
||||
user_id: currentUser.$id,
|
||||
platform_id: null, // null 表示全局退出
|
||||
global: true,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 订阅退登事件
|
||||
|
||||
使用 Appwrite Realtime 订阅退登事件:
|
||||
|
||||
```javascript
|
||||
import { Client } from "appwrite"
|
||||
|
||||
const client = new Client()
|
||||
.setEndpoint("https://cloud.appwrite.io/v1")
|
||||
.setProject("your-project-id")
|
||||
|
||||
const unsubscribe = client.subscribe(
|
||||
"databases.69bd89d8000304c37368.collections.logout_events.documents",
|
||||
(response) => {
|
||||
const event = response.payload
|
||||
if (event.user_id === currentUserId) {
|
||||
// 执行本地退出
|
||||
localLogout()
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue)
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### HTTP 状态码
|
||||
|
||||
| 状态码 | 说明 |
|
||||
| ------ | ------------------ |
|
||||
| 200 | 请求成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权,认证失败 |
|
||||
| 403 | 禁止访问,权限不足 |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
### 错误响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "error_code",
|
||||
"error_description": "详细的错误描述",
|
||||
"error_uri": "https://docs.example.com/error/error_code"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **使用 HTTPS** - 所有 API 通信必须使用 HTTPS
|
||||
2. **保护密钥** - 不要在客户端代码中暴露 client_secret
|
||||
3. **验证回调地址** - 确保 redirect_uri 与注册时一致
|
||||
4. **使用状态参数** - 添加 state 参数防止 CSRF 攻击
|
||||
5. **安全存储令牌** - Token 应安全存储,避免泄露
|
||||
6. **及时撤销** - 用户退出时及时撤销令牌
|
||||
|
||||
---
|
||||
|
||||
## 相关页面
|
||||
|
||||
- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - OAuth 授权页面
|
||||
- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 认证回调
|
||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) - 平台管理
|
||||
- [PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue) - 平台详情
|
||||
- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置
|
||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) - 安全设置
|
||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
# 平台接入指南
|
||||
|
||||
本文档介绍如何将第三方平台与 SECTL Auth 集成,实现统一认证和远程退登功能。
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [快速开始](#快速开始)
|
||||
- [管理后台功能](#管理后台功能)
|
||||
- [OAuth 集成](#oauth-集成)
|
||||
- [远程退登](#远程退登)
|
||||
- [代码示例](#代码示例)
|
||||
- [常见问题](#常见问题)
|
||||
|
||||
## 概述
|
||||
|
||||
SECTL Auth 提供以下核心功能:
|
||||
|
||||
1. **统一认证** - 用户使用 SECTL Auth 账号登录第三方平台
|
||||
2. **远程退登** - 用户在 SECTL Auth 中心退出后,平台自动退出
|
||||
3. **用户管理** - 查看已授权用户、统计信息等
|
||||
|
||||
### 工作流程
|
||||
|
||||
```
|
||||
┌─────────────┐ 登录请求 ┌─────────────┐
|
||||
│ 用户 │ ─────────────> │ SECTL Auth │
|
||||
│ │ │ │
|
||||
│ 平台A │ <───────────── │ 认证中心 │
|
||||
│ │ Access Token │ │
|
||||
└──────┬──────┘ └──────┬──────┘
|
||||
│ │
|
||||
│ 远程退登通知 │
|
||||
│ <─────────────────────────── │
|
||||
│ (Realtime) │
|
||||
▼ ▼
|
||||
自动退出登录 用户点击退出
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 注册平台
|
||||
|
||||
在 SECTL Auth 平台管理页面 ([PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)) 申请接入,获取:
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
| -------------- | ------------ | ------------------------------------- |
|
||||
| platform_id | 平台唯一标识 | `pf_AHmIhAiptyLFxk8x` |
|
||||
| platform_token | 平台认证密钥 | `sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1` |
|
||||
| callback_url | 回调地址 | `http://localhost:5000/callback` |
|
||||
|
||||
### 2. 平台状态说明
|
||||
|
||||
平台在管理后台有以下状态:
|
||||
|
||||
| 状态 | 说明 | 可操作 |
|
||||
| -------- | ------ | -------- |
|
||||
| active | 运行中 | 正常授权 |
|
||||
| pending | 审核中 | 等待审核 |
|
||||
| disabled | 已停用 | 无法授权 |
|
||||
|
||||
### 3. 查看统计信息
|
||||
|
||||
在平台管理页面可以查看:
|
||||
|
||||
- **总平台数** - 已创建的平台数量
|
||||
- **运行中** - 活跃平台数量
|
||||
- **审核中** - 待审核申请数量
|
||||
- **总用户数** - 授权用户总数
|
||||
|
||||
## 管理后台功能
|
||||
|
||||
### 平台列表 ([PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue))
|
||||
|
||||
平台管理页面提供以下功能:
|
||||
|
||||
#### 1. 平台卡片展示
|
||||
|
||||
每个平台显示:
|
||||
|
||||
- 平台名称和描述
|
||||
- 状态标签(运行中/审核中/已停用)
|
||||
- 创建时间
|
||||
- 授权用户数
|
||||
- 管理按钮
|
||||
|
||||
#### 2. 搜索和过滤
|
||||
|
||||
- 按名称搜索平台
|
||||
- 按状态过滤
|
||||
- 分页展示
|
||||
|
||||
#### 3. 快捷操作
|
||||
|
||||
- **新建平台** - 跳转到申请页面
|
||||
- **我的申请** - 查看自己的平台申请
|
||||
- **审核申请** - 审核其他用户的申请(需 >=18 权限)
|
||||
|
||||
### 平台详情 ([PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue))
|
||||
|
||||
查看平台详细信息:
|
||||
|
||||
#### 基本信息
|
||||
|
||||
- 平台 ID (Client ID)
|
||||
- 平台密钥 (Client Secret)
|
||||
- 回调地址
|
||||
- 创建时间
|
||||
|
||||
#### 安全设置
|
||||
|
||||
- Token 过期时间配置
|
||||
- 密钥重置
|
||||
- 回调地址修改
|
||||
|
||||
#### 用户统计
|
||||
|
||||
- 授权用户列表
|
||||
- 登录时间
|
||||
- 最后活跃时间
|
||||
|
||||
### 平台申请 ([PlatformApplyView.vue](../../src/views/dashboard/form/PlatformApplyView.vue))
|
||||
|
||||
申请创建新平台:
|
||||
|
||||
```javascript
|
||||
// 申请参数
|
||||
{
|
||||
name: "我的应用", // 平台名称
|
||||
description: "应用描述", // 平台描述
|
||||
callback_url: "http://..." // 回调地址
|
||||
}
|
||||
```
|
||||
|
||||
申请后状态为 `pending`,等待管理员审核。
|
||||
|
||||
### 申请审核 ([PlatformApplicationsView.vue](../../src/views/dashboard/manage/PlatformApplicationsView.vue))
|
||||
|
||||
管理员审核平台申请:
|
||||
|
||||
- 查看申请详情
|
||||
- 批准申请(状态变为 `active`)
|
||||
- 拒绝申请(状态变为 `disabled`)
|
||||
|
||||
## OAuth 集成
|
||||
|
||||
### 授权流程
|
||||
|
||||
#### 1. 引导用户到授权页面
|
||||
|
||||
```
|
||||
GET https://sectl.top/oauth/authorize
|
||||
```
|
||||
|
||||
**参数:**
|
||||
|
||||
| 参数名 | 必填 | 说明 |
|
||||
| ------------- | ---- | ------------- |
|
||||
| client_id | ✅ | 平台 ID |
|
||||
| redirect_uri | ✅ | 回调地址 |
|
||||
| response_type | ✅ | 固定值 `code` |
|
||||
|
||||
**示例:**
|
||||
|
||||
```
|
||||
https://sectl.top/oauth/authorize?client_id=pf_xxx&redirect_uri=http://localhost:5000/callback&response_type=code
|
||||
```
|
||||
|
||||
#### 2. 处理回调
|
||||
|
||||
用户授权后,会重定向到:
|
||||
|
||||
```
|
||||
{callback_url}?code={authorization_code}
|
||||
```
|
||||
|
||||
#### 3. 换取访问令牌
|
||||
|
||||
```http
|
||||
POST https://sectl.top/api/oauth/token
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "授权码",
|
||||
"client_id": "pf_xxx",
|
||||
"client_secret": "sk_xxx",
|
||||
"redirect_uri": "http://localhost:5000/callback"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"refresh_token": "xxx",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 获取用户信息
|
||||
|
||||
```http
|
||||
GET https://sectl.top/api/oauth/userinfo
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "69bd422960018cf4d0e5",
|
||||
"email": "user@example.com",
|
||||
"name": "张三",
|
||||
"github_username": "zhangsan"
|
||||
}
|
||||
```
|
||||
|
||||
## 远程退登
|
||||
|
||||
### 实现原理
|
||||
|
||||
1. 用户在 SECTL Auth 点击退出登录
|
||||
2. 系统创建 `logout_events` 文档
|
||||
3. 第三方应用通过 Appwrite Realtime 订阅该集合
|
||||
4. 收到匹配的事件后执行本地退出
|
||||
|
||||
### 集成步骤
|
||||
|
||||
#### 1. 安装 Appwrite SDK
|
||||
|
||||
**C#**
|
||||
|
||||
```bash
|
||||
dotnet add package Appwrite
|
||||
```
|
||||
|
||||
**Python**
|
||||
|
||||
```bash
|
||||
pip install appwrite
|
||||
```
|
||||
|
||||
#### 2. 配置 Realtime 订阅
|
||||
|
||||
**C# 示例:**
|
||||
|
||||
```csharp
|
||||
using Appwrite;
|
||||
using Appwrite.Services;
|
||||
|
||||
var client = new Client()
|
||||
.SetEndpoint("https://cloud.appwrite.io/v1")
|
||||
.SetProject("your-project-id");
|
||||
|
||||
var realtime = new Realtime(client);
|
||||
|
||||
// 订阅 logout_events 集合
|
||||
realtime.Subscribe("databases.{databaseId}.collections.logout_events.documents",
|
||||
callback: (message) => {
|
||||
var logoutEvent = message.Payload;
|
||||
if (logoutEvent.UserId == currentUserId) {
|
||||
// 执行本地退出
|
||||
LocalLogout();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Python 示例:**
|
||||
|
||||
```python
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.realtime import Realtime
|
||||
|
||||
client = Client()
|
||||
client.set_endpoint('https://cloud.appwrite.io/v1')
|
||||
client.set_project('your-project-id')
|
||||
|
||||
realtime = Realtime(client)
|
||||
|
||||
def on_logout_event(message):
|
||||
event = message['payload']
|
||||
if event['user_id'] == current_user_id:
|
||||
# 执行本地退出
|
||||
local_logout()
|
||||
|
||||
# 订阅 logout_events 集合
|
||||
realtime.subscribe(
|
||||
'databases.{database_id}.collections.logout_events.documents',
|
||||
callback=on_logout_event
|
||||
)
|
||||
```
|
||||
|
||||
### 数据结构
|
||||
|
||||
**logout_events 集合字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----------- | ------- | ------------------ |
|
||||
| user_id | String | 退出登录的用户ID |
|
||||
| platform_id | String | 特定平台ID(可选) |
|
||||
| global | Boolean | 是否全局退出 |
|
||||
| created_at | String | 创建时间 |
|
||||
|
||||
## 代码示例
|
||||
|
||||
### C# 完整示例
|
||||
|
||||
详见 [csharp-example](./csharp-example/) 目录。
|
||||
|
||||
```csharp
|
||||
// SectlAuthClient.cs
|
||||
public class SectlAuthClient
|
||||
{
|
||||
private readonly string _platformId;
|
||||
private readonly string _platformSecret;
|
||||
private readonly string _callbackUrl;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public SectlAuthClient(string platformId, string platformSecret, string callbackUrl)
|
||||
{
|
||||
_platformId = platformId;
|
||||
_platformSecret = platformSecret;
|
||||
_callbackUrl = callbackUrl;
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
// 获取授权 URL
|
||||
public string GetAuthorizationUrl()
|
||||
{
|
||||
return $"https://sectl.top/oauth/authorize?" +
|
||||
$"client_id={_platformId}&" +
|
||||
$"redirect_uri={Uri.EscapeDataString(_callbackUrl)}&" +
|
||||
$"response_type=code";
|
||||
}
|
||||
|
||||
// 换取访问令牌
|
||||
public async Task<TokenResponse> ExchangeCodeAsync(string code)
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
grant_type = "authorization_code",
|
||||
code = code,
|
||||
client_id = _platformId,
|
||||
client_secret = _platformSecret,
|
||||
redirect_uri = _callbackUrl
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"https://sectl.top/api/oauth/token", request);
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TokenResponse>();
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
public async Task<UserInfo> GetUserInfoAsync(string accessToken)
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var response = await _httpClient.GetAsync(
|
||||
"https://sectl.top/api/oauth/userinfo");
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<UserInfo>();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Python 完整示例
|
||||
|
||||
详见 [python-example](./python-example/) 目录。
|
||||
|
||||
```python
|
||||
# sectl_auth/client.py
|
||||
import requests
|
||||
|
||||
class SectlAuthClient:
|
||||
BASE_URL = "https://sectl.top"
|
||||
AUTH_URL = "https://sectl.top"
|
||||
|
||||
def __init__(self, platform_id: str, platform_secret: str, callback_url: str):
|
||||
self.platform_id = platform_id
|
||||
self.platform_secret = platform_secret
|
||||
self.callback_url = callback_url
|
||||
|
||||
def get_authorization_url(self) -> str:
|
||||
"""获取授权 URL"""
|
||||
return (
|
||||
f"{self.AUTH_URL}/oauth/authorize?"
|
||||
f"client_id={self.platform_id}&"
|
||||
f"redirect_uri={self.callback_url}&"
|
||||
f"response_type=code"
|
||||
)
|
||||
|
||||
def exchange_code(self, code: str) -> dict:
|
||||
"""用授权码换取访问令牌"""
|
||||
response = requests.post(
|
||||
f"{self.BASE_URL}/api/oauth/token",
|
||||
json={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": self.platform_id,
|
||||
"client_secret": self.platform_secret,
|
||||
"redirect_uri": self.callback_url
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_user_info(self, access_token: str) -> dict:
|
||||
"""获取用户信息"""
|
||||
response = requests.get(
|
||||
f"{self.BASE_URL}/api/oauth/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何获取 platform_id 和 platform_token?
|
||||
|
||||
A: 在 SECTL Auth 平台管理页面申请创建平台,审核通过后会获得:
|
||||
|
||||
- platform_id (Client ID)
|
||||
- platform_token (Client Secret)
|
||||
|
||||
### Q: 授权码有效期多久?
|
||||
|
||||
A: 授权码有效期为 10 分钟,且只能使用一次。
|
||||
|
||||
### Q: Access Token 过期后怎么办?
|
||||
|
||||
A: 使用 refresh_token 换取新的 access_token:
|
||||
|
||||
```http
|
||||
POST /api/oauth/token
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "your-refresh-token",
|
||||
"client_id": "your-platform-id",
|
||||
"client_secret": "your-platform-secret"
|
||||
}
|
||||
```
|
||||
|
||||
### Q: 如何配置 Token 过期时间?
|
||||
|
||||
A: 在平台设置页面 ([PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue)) 可以配置:
|
||||
|
||||
- access_token 过期时间(默认 3600 秒)
|
||||
- 设置为 -1 表示永不过期
|
||||
|
||||
### Q: 远程退登不生效怎么办?
|
||||
|
||||
A: 检查以下几点:
|
||||
|
||||
1. 是否正确订阅了 `logout_events` 集合
|
||||
2. 检查 user_id 是否匹配
|
||||
3. 确认 Appwrite Realtime 连接正常
|
||||
4. 查看浏览器控制台是否有错误
|
||||
|
||||
### Q: 平台状态显示"审核中"怎么办?
|
||||
|
||||
A: 新创建的平台需要管理员审核。审核通过后状态会变为 `active`。
|
||||
|
||||
## 相关页面
|
||||
|
||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) - 平台管理
|
||||
- [PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue) - 平台详情
|
||||
- [PlatformApplyView.vue](../../src/views/dashboard/form/PlatformApplyView.vue) - 申请平台
|
||||
- [PlatformApplicationsView.vue](../../src/views/dashboard/manage/PlatformApplicationsView.vue) - 审核申请
|
||||
- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置
|
||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [OAuth 2.0 流程](./OAUTH_FLOW.MD)
|
||||
- [API 参考](./API_REFERENCE.md)
|
||||
- [故障排查](../oauth-flow/TROUBLESHOOTING.md)
|
||||
@@ -0,0 +1,311 @@
|
||||
# OAuth 2.0 授权流程文档
|
||||
|
||||
本文档详细说明 SECTL Auth 的 OAuth 2.0 授权流程,帮助第三方应用开发者接入统一认证服务。
|
||||
|
||||
## 概述
|
||||
|
||||
SECTL Auth 实现了标准的 OAuth 2.0 授权码流程(Authorization Code Flow),允许第三方应用使用 SECTL Auth 账户进行用户认证。
|
||||
|
||||
### 流程特点
|
||||
|
||||
- **安全性高** - 使用授权码模式,避免 Token 暴露在浏览器端
|
||||
- **标准化** - 遵循 OAuth 2.0 RFC 6749 规范
|
||||
- **远程退登** - 支持通过 Realtime 实现跨平台同步退出
|
||||
- **可配置** - 支持自定义 Token 过期时间
|
||||
|
||||
## 授权流程
|
||||
|
||||
### 流程图
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────────┐
|
||||
│ 用户 │ │ 第三方应用 │
|
||||
└────┬────┘ └──────┬──────┘
|
||||
│ │
|
||||
│ 1. 访问应用 │
|
||||
│───────────────────────────────────────────────>│
|
||||
│ │
|
||||
│ 2. 重定向到授权页面 │
|
||||
│<───────────────────────────────────────────────│
|
||||
│ │
|
||||
│ 3. 登录并授权 (LoginView.vue / OAuthAuthorizeView.vue)
|
||||
│───────────────────────────────────────────────>│
|
||||
│ │
|
||||
│ 4. 返回授权码 │
|
||||
│<───────────────────────────────────────────────│
|
||||
│ │
|
||||
│ 5. 用授权码换取 Token │
|
||||
│ │──────┐
|
||||
│ │ │
|
||||
│ 6. 返回 Access Token │<─────┘
|
||||
│ │
|
||||
│ 7. 使用 Token 访问用户资源 │
|
||||
│<───────────────────────────────────────────────│
|
||||
```
|
||||
|
||||
## 详细步骤
|
||||
|
||||
### 1. 引导用户到授权页面
|
||||
|
||||
当用户需要登录时,第三方应用应将用户重定向到 SECTL Auth 的授权页面。
|
||||
|
||||
**请求地址:**
|
||||
|
||||
```
|
||||
GET https://sectl.top/oauth/authorize
|
||||
```
|
||||
|
||||
**请求参数:**
|
||||
|
||||
| 参数名 | 必填 | 类型 | 说明 |
|
||||
| ------------- | ---- | ------ | --------------------------------- |
|
||||
| client_id | ✅ | string | 平台 ID,如 `pf_AHmIhAiptyLFxk8x` |
|
||||
| redirect_uri | ✅ | string | 回调地址,必须与平台设置匹配 |
|
||||
| response_type | ✅ | string | 固定值 `code` |
|
||||
|
||||
**示例 URL:**
|
||||
|
||||
```
|
||||
https://sectl.top/oauth/authorize?client_id=pf_AHmIhAiptyLFxk8x&redirect_uri=http://localhost:5000/callback&response_type=code
|
||||
```
|
||||
|
||||
**相关代码:** [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue)
|
||||
|
||||
### 2. 用户登录与授权
|
||||
|
||||
用户将被引导至 SECTL Auth 登录页面(如果未登录):
|
||||
|
||||
1. **登录页面** ([LoginView.vue](../../src/views/LoginView.vue))
|
||||
- 用户输入邮箱密码或使用 GitHub 登录
|
||||
- 支持记住我功能
|
||||
- 检测 OAuth 参数,登录后自动跳转授权页
|
||||
|
||||
2. **授权确认页** ([OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue))
|
||||
- 显示应用名称和图标
|
||||
- 列出请求的权限范围
|
||||
- 用户可选择"授权"或"拒绝"
|
||||
|
||||
**权限范围:**
|
||||
|
||||
- 访问用户基本信息(用户名、邮箱、头像)
|
||||
- 验证用户身份
|
||||
|
||||
### 3. 获取授权码
|
||||
|
||||
用户授权后,页面将重定向到指定的回调地址:
|
||||
|
||||
```
|
||||
{redirect_uri}?code={authorization_code}
|
||||
```
|
||||
|
||||
**示例:**
|
||||
|
||||
```
|
||||
http://localhost:5000/callback?code=NjliNDIyOTYwMDE4znLUNeWM6J8ye0MC
|
||||
```
|
||||
|
||||
**授权码特性:**
|
||||
|
||||
- 有效期:10 分钟
|
||||
- 一次性使用
|
||||
- 与 `redirect_uri` 绑定
|
||||
|
||||
### 4. 换取访问令牌
|
||||
|
||||
第三方应用后端使用授权码向 SECTL Auth 换取访问令牌。
|
||||
|
||||
**请求地址:**
|
||||
|
||||
```
|
||||
POST https://sectl.top/api/oauth/token
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "NjliNDIyOTYwMDE4znLUNeWM6J8ye0MC",
|
||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
||||
"client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1",
|
||||
"redirect_uri": "http://localhost:5000/callback"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
|
||||
| 参数名 | 必填 | 说明 |
|
||||
| ------------- | ---- | --------------------------- |
|
||||
| grant_type | ✅ | 固定值 `authorization_code` |
|
||||
| code | ✅ | 上一步获取的授权码 |
|
||||
| client_id | ✅ | 平台 ID |
|
||||
| client_secret | ✅ | 平台密钥 |
|
||||
| redirect_uri | ✅ | 必须与授权时一致 |
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
```
|
||||
|
||||
**Token 信息:**
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ------------- | ----------------------------------- |
|
||||
| access_token | 访问令牌,用于调用 API |
|
||||
| refresh_token | 刷新令牌,用于获取新的 access_token |
|
||||
| token_type | 令牌类型,固定为 Bearer |
|
||||
| expires_in | 过期时间(秒) |
|
||||
|
||||
### 5. 使用访问令牌
|
||||
|
||||
获取 access_token 后,可用于调用 SECTL Auth API 获取用户信息。
|
||||
|
||||
**获取用户信息:**
|
||||
|
||||
```http
|
||||
GET https://sectl.top/api/oauth/userinfo
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "69bd422960018cf4d0e5",
|
||||
"email": "user@example.com",
|
||||
"name": "张三",
|
||||
"github_username": "zhangsan"
|
||||
}
|
||||
```
|
||||
|
||||
## Token 管理
|
||||
|
||||
### 过期时间配置
|
||||
|
||||
平台可配置 Token 过期时间:
|
||||
|
||||
| 令牌类型 | 默认有效期 | 说明 |
|
||||
| ------------- | --------------- | ---------------------- |
|
||||
| access_token | 3600 秒 (1小时) | 短期有效,用于访问资源 |
|
||||
| refresh_token | 长期有效 | 用于刷新 access_token |
|
||||
| 授权码 | 600 秒 (10分钟) | 一次性使用 |
|
||||
|
||||
**修改过期时间:**
|
||||
|
||||
```http
|
||||
POST https://sectl.top/api/oauth/config
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expires_in": 86400,
|
||||
"admin_secret": "your-admin-secret"
|
||||
}
|
||||
```
|
||||
|
||||
### 刷新 Token
|
||||
|
||||
当 access_token 过期时,使用 refresh_token 获取新的令牌:
|
||||
|
||||
```http
|
||||
POST https://sectl.top/api/oauth/token
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "your-refresh-token",
|
||||
"client_id": "your-platform-id",
|
||||
"client_secret": "your-platform-secret"
|
||||
}
|
||||
```
|
||||
|
||||
## 远程退登
|
||||
|
||||
SECTL Auth 支持远程退登功能,当用户在 SECTL Auth 中心退出登录时,已授权的第三方应用会收到通知。
|
||||
|
||||
### 实现原理
|
||||
|
||||
1. 用户在 SECTL Auth 点击退出登录
|
||||
2. 系统创建 `logout_events` 文档
|
||||
3. 第三方应用通过 Appwrite Realtime 订阅该集合
|
||||
4. 收到退登事件后,应用执行本地退出操作
|
||||
|
||||
### 集成方式
|
||||
|
||||
详见 [平台接入指南](../platform-integration/README.md)。
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 授权错误
|
||||
|
||||
当授权失败时,将重定向到回调地址并附带错误信息:
|
||||
|
||||
```
|
||||
{redirect_uri}?error=access_denied&error_description=user+denied+authorization
|
||||
```
|
||||
|
||||
**常见错误码:**
|
||||
|
||||
| 错误码 | 说明 |
|
||||
| ------------------------- | ------------------ |
|
||||
| invalid_request | 请求参数缺失或无效 |
|
||||
| unauthorized_client | 客户端未授权 |
|
||||
| access_denied | 用户拒绝授权 |
|
||||
| unsupported_response_type | 不支持的响应类型 |
|
||||
| invalid_scope | 无效的权限范围 |
|
||||
| server_error | 服务器内部错误 |
|
||||
|
||||
### Token 错误
|
||||
|
||||
换取 Token 时的错误响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Authorization code has expired"
|
||||
}
|
||||
```
|
||||
|
||||
**常见错误码:**
|
||||
|
||||
| 错误码 | 说明 |
|
||||
| ---------------------- | ------------------------ |
|
||||
| invalid_request | 请求格式错误 |
|
||||
| invalid_client | 客户端认证失败 |
|
||||
| invalid_grant | 授权码无效或过期 |
|
||||
| unauthorized_client | 客户端无权使用此授权类型 |
|
||||
| unsupported_grant_type | 不支持的授权类型 |
|
||||
| invalid_scope | 无效的权限范围 |
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **使用 HTTPS** - 所有 OAuth 通信必须使用 HTTPS
|
||||
2. **保护 client_secret** - 不要在客户端代码中暴露 client_secret
|
||||
3. **验证 redirect_uri** - 确保回调地址与注册时一致
|
||||
4. **使用状态参数** - 建议添加 `state` 参数防止 CSRF 攻击
|
||||
5. **及时清理** - 使用后的授权码应立即作废
|
||||
6. **安全存储** - Token 应安全存储,避免泄露
|
||||
|
||||
## 相关页面
|
||||
|
||||
- [LoginView.vue](../../src/views/LoginView.vue) - 登录页面
|
||||
- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - OAuth 授权页面
|
||||
- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 认证回调处理
|
||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [平台接入指南](../platform-integration/README.md)
|
||||
- [API 参考](../platform-integration/API_REFERENCE.md)
|
||||
- [故障排查](./TROUBLESHOOTING.md)
|
||||
@@ -0,0 +1,476 @@
|
||||
# OAuth 流程故障排查指南
|
||||
|
||||
本文档汇总了 SECTL Auth OAuth 流程中的常见问题及解决方案。
|
||||
|
||||
## 目录
|
||||
|
||||
- [授权阶段问题](#授权阶段问题)
|
||||
- [Token 换取问题](#token-换取问题)
|
||||
- [用户信息获取问题](#用户信息获取问题)
|
||||
- [远程退登问题](#远程退登问题)
|
||||
- [平台管理问题](#平台管理问题)
|
||||
- [网络与 CORS 问题](#网络与-cors-问题)
|
||||
|
||||
---
|
||||
|
||||
## 授权阶段问题
|
||||
|
||||
### 1. 授权页面无法打开
|
||||
|
||||
**现象:** 访问授权 URL 返回 404 或空白页面
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查 URL 格式是否正确
|
||||
```
|
||||
https://auth.sectl.top/oauth/authorize?client_id=xxx&redirect_uri=xxx&response_type=code
|
||||
```
|
||||
2. 确认必要参数是否齐全
|
||||
- `client_id` - 平台 ID
|
||||
- `redirect_uri` - 回调地址
|
||||
- `response_type` - 固定值 `code`
|
||||
3. 检查平台是否存在且状态为 `active`
|
||||
- 登录 SECTL Auth 管理后台
|
||||
- 查看 [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)
|
||||
- 确认平台状态不是 `pending` 或 `disabled`
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue)
|
||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)
|
||||
|
||||
---
|
||||
|
||||
### 2. 授权后没有记录到登录平台
|
||||
|
||||
**现象:** 用户授权后,在"登录平台"页面 ([LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue)) 看不到记录
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查浏览器控制台是否有错误
|
||||
2. 检查 `user_data` 集合是否有该用户的记录
|
||||
3. 检查 `user_data.login_platforms` 字段是否正确更新
|
||||
4. 检查 `oauth_tokens` 集合是否有对应的令牌记录
|
||||
|
||||
**可能原因:**
|
||||
|
||||
- 用户未登录 SECTL Auth
|
||||
- `user_data` 集合权限问题
|
||||
- 网络请求失败
|
||||
- 平台状态为 `disabled`
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue)
|
||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue)
|
||||
|
||||
---
|
||||
|
||||
### 3. 用户已登录但授权页面仍要求登录
|
||||
|
||||
**现象:** 用户已登录 SECTL Auth,但授权页面仍跳转到登录页
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查登录状态是否正确保持
|
||||
2. 检查浏览器 Cookie 是否被阻止
|
||||
3. 检查是否跨域问题
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 确保浏览器允许第三方 Cookie
|
||||
- 检查 `rememberMe` 选项是否勾选
|
||||
- 查看 [LoginView.vue](../../src/views/LoginView.vue) 中的登录逻辑
|
||||
|
||||
---
|
||||
|
||||
## Token 换取问题
|
||||
|
||||
### 1. 换取 token 时提示 "Invalid authorization code"
|
||||
|
||||
**现象:** 用 code 换取 token 返回 400 错误
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Invalid authorization code"
|
||||
}
|
||||
```
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查 code 是否已过期(10分钟有效期)
|
||||
2. 检查 code 是否已被使用过
|
||||
3. 检查 `redirect_uri` 是否与授权时一致
|
||||
4. 检查 `client_id` 和 `client_secret` 是否正确
|
||||
|
||||
**可能原因:**
|
||||
|
||||
- Code 已过期
|
||||
- Code 已被使用(一次性)
|
||||
- 回调地址不匹配
|
||||
- 平台密钥错误
|
||||
|
||||
**相关代码:** [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue)
|
||||
|
||||
---
|
||||
|
||||
### 2. 换取 token 时提示 "Invalid client"
|
||||
|
||||
**现象:** 返回 401 错误
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid_client",
|
||||
"error_description": "Client authentication failed"
|
||||
}
|
||||
```
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 确认 `client_id` 正确
|
||||
2. 确认 `client_secret` 正确且未泄露
|
||||
3. 检查平台状态是否为 `active`
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 在 [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) 查看平台信息
|
||||
- 如密钥泄露,在 [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) 重置密钥
|
||||
|
||||
---
|
||||
|
||||
### 3. Access Token 过期后无法刷新
|
||||
|
||||
**现象:** 使用 refresh_token 换取新 token 失败
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查 refresh_token 是否有效
|
||||
2. 检查 refresh_token 是否被撤销
|
||||
3. 检查平台是否被禁用
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```http
|
||||
POST /api/oauth/token
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "your-refresh-token",
|
||||
"client_id": "your-platform-id",
|
||||
"client_secret": "your-platform-secret"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 用户信息获取问题
|
||||
|
||||
### 1. 获取用户信息返回 401
|
||||
|
||||
**现象:** 调用 `/api/oauth/userinfo` 返回未授权错误
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查 access_token 是否过期
|
||||
2. 检查 Authorization 头格式是否正确
|
||||
```
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
3. 检查 token 是否被撤销
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 使用 refresh_token 获取新的 access_token
|
||||
- 重新走授权流程
|
||||
|
||||
**相关页面:** [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue)
|
||||
|
||||
---
|
||||
|
||||
### 2. 用户信息字段缺失
|
||||
|
||||
**现象:** 返回的用户信息缺少某些字段
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查用户是否设置了该字段
|
||||
2. 检查数据库中 `user_data` 集合是否有该字段
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "69bd422960018cf4d0e5",
|
||||
"email": "user@example.com",
|
||||
"name": "张三",
|
||||
"github_username": "zhangsan",
|
||||
"permission": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 远程退登问题
|
||||
|
||||
### 1. 远程退登不生效
|
||||
|
||||
**现象:** 用户在 SECTL Auth 退出后,第三方平台没有自动退出
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查是否正确订阅了 `logout_events` 集合
|
||||
2. 检查 user_id 是否匹配
|
||||
3. 确认 Appwrite Realtime 连接正常
|
||||
4. 查看浏览器控制台是否有错误
|
||||
|
||||
**订阅代码示例:**
|
||||
|
||||
```javascript
|
||||
client.subscribe(
|
||||
"databases.69bd89d8000304c37368.collections.logout_events.documents",
|
||||
(response) => {
|
||||
const event = response.payload
|
||||
if (event.user_id === currentUserId) {
|
||||
localLogout()
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue)
|
||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)
|
||||
|
||||
---
|
||||
|
||||
### 2. Realtime 连接断开
|
||||
|
||||
**现象:** 退登事件偶尔收不到
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查网络连接稳定性
|
||||
2. 检查是否有心跳机制保持连接
|
||||
3. 检查浏览器是否进入休眠状态
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 实现重连机制
|
||||
- 定期检查连接状态
|
||||
- 使用 Service Worker 保持后台连接
|
||||
|
||||
---
|
||||
|
||||
## 平台管理问题
|
||||
|
||||
### 1. 平台管理的"登录用户"为空
|
||||
|
||||
**现象:** 在平台管理页面查看登录用户,显示为空
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查 `oauth_tokens` 集合是否有记录
|
||||
2. 检查记录是否有 `user_id` 字段
|
||||
3. 检查 `oauth_tokens` 集合权限
|
||||
|
||||
**解决方案:**
|
||||
|
||||
如果 `oauth_tokens` 没有 `user_id` 字段,需要在 Appwrite Console 中添加:
|
||||
|
||||
1. 进入 Database → 选择数据库
|
||||
2. 找到 `oauth_tokens` 集合
|
||||
3. 添加字段:`user_id`(String 类型,非必填)
|
||||
|
||||
**相关页面:**
|
||||
|
||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)
|
||||
- [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue)
|
||||
|
||||
---
|
||||
|
||||
### 2. 平台状态显示"审核中"
|
||||
|
||||
**现象:** 新创建的平台无法使用,状态为 `pending`
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查是否已提交平台申请
|
||||
2. 联系管理员审核
|
||||
3. 在 [MyPlatformApplicationsView.vue](../../src/views/dashboard/manage/MyPlatformApplicationsView.vue) 查看申请状态
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 等待管理员审核
|
||||
- 管理员在 [PlatformApplicationsView.vue](../../src/views/dashboard/manage/PlatformApplicationsView.vue) 中批准申请
|
||||
|
||||
---
|
||||
|
||||
### 3. 无法修改平台回调地址
|
||||
|
||||
**现象:** 在平台设置中修改回调地址失败
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查是否有权限修改(所有者或管理员)
|
||||
2. 检查回调地址格式是否正确
|
||||
3. 检查数据库权限配置
|
||||
|
||||
**相关页面:** [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue)
|
||||
|
||||
---
|
||||
|
||||
## 网络与 CORS 问题
|
||||
|
||||
### 1. CORS 错误
|
||||
|
||||
**现象:** 浏览器控制台显示 CORS 错误
|
||||
|
||||
**错误信息:**
|
||||
|
||||
```
|
||||
Access to fetch at 'https://auth.sectl.top/...' from origin 'https://auth.sectl.top' has been blocked by CORS policy
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
已在 OAuth API 中添加 CORS 头:
|
||||
|
||||
```javascript
|
||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
```
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 确认请求头是否正确
|
||||
2. 确认是否为预检请求(OPTIONS)问题
|
||||
3. 检查服务器 CORS 配置
|
||||
|
||||
---
|
||||
|
||||
### 2. 授权码中的用户ID解码失败
|
||||
|
||||
**现象:** 换取 token 后 `user_id` 为 "anonymous"
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查授权码格式(应为 32 位:前16位 base64 用户ID + 后16位随机)
|
||||
2. 检查 `oauth_codes` 集合是否有 `user_id` 字段
|
||||
|
||||
**代码逻辑:**
|
||||
|
||||
```javascript
|
||||
// 生成授权码时编码用户ID
|
||||
const userIdHash = Buffer.from(userId)
|
||||
.toString("base64")
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.substring(0, 16)
|
||||
const code = userIdHash + randomPart
|
||||
|
||||
// 换取 token 时解码
|
||||
try {
|
||||
const userIdHash = code.substring(0, 16)
|
||||
const base64Str = userIdHash.replace(/_/g, "/").replace(/-/g, "+") + "=="
|
||||
const decoded = Buffer.from(base64Str, "base64").toString("utf8")
|
||||
tokenUserId = decoded
|
||||
} catch (e) {
|
||||
tokenUserId = "anonymous"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 网络超时
|
||||
|
||||
**现象:** 请求 OAuth API 超时
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查网络连接
|
||||
2. 检查服务器状态
|
||||
3. 增加请求超时时间
|
||||
|
||||
**解决方案:**
|
||||
|
||||
```javascript
|
||||
// 增加超时时间
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: AbortSignal.timeout(30000), // 30秒超时
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 1. 浏览器开发者工具
|
||||
|
||||
使用浏览器开发者工具排查问题:
|
||||
|
||||
1. **Network 面板** - 查看请求和响应
|
||||
2. **Console 面板** - 查看错误日志
|
||||
3. **Application 面板** - 查看 Cookie 和 LocalStorage
|
||||
|
||||
### 2. Appwrite Console
|
||||
|
||||
在 Appwrite Console 中检查:
|
||||
|
||||
1. **Database** - 检查集合数据和权限
|
||||
2. **Auth** - 检查用户和会话
|
||||
3. **Functions** - 查看函数日志
|
||||
4. **Realtime** - 检查实时连接
|
||||
|
||||
### 3. 日志记录
|
||||
|
||||
在关键位置添加日志:
|
||||
|
||||
```javascript
|
||||
// 授权流程日志
|
||||
console.log("OAuth params:", { client_id, redirect_uri, response_type })
|
||||
console.log("Authorization code:", code)
|
||||
console.log("Token response:", tokenData)
|
||||
|
||||
// 退登事件日志
|
||||
console.log("Logout event received:", event)
|
||||
console.log("Current user ID:", currentUserId)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题速查表
|
||||
|
||||
| 问题 | 可能原因 | 解决方案 |
|
||||
| :------------- | :------------------- | :---------------------- |
|
||||
| 授权页面 404 | 平台不存在或状态异常 | 检查平台状态和 ID |
|
||||
| Invalid code | Code 过期或已使用 | 重新授权获取新 code |
|
||||
| Invalid client | 密钥错误 | 检查 client_secret |
|
||||
| Token 过期 | 超过 expires_in | 使用 refresh_token 刷新 |
|
||||
| 用户信息 401 | Token 无效 | 检查 Token 格式和有效期 |
|
||||
| 退登不生效 | Realtime 未连接 | 检查订阅代码 |
|
||||
| CORS 错误 | 跨域配置问题 | 检查服务器 CORS 头 |
|
||||
|
||||
---
|
||||
|
||||
## 获取帮助
|
||||
|
||||
如果以上方案无法解决问题:
|
||||
|
||||
1. 查看 [API 参考](../platform-integration/API_REFERENCE.md)
|
||||
2. 查看 [平台接入指南](../platform-integration/README.md)
|
||||
3. 提交 Issue 到项目仓库
|
||||
4. 联系管理员
|
||||
|
||||
---
|
||||
|
||||
## 相关页面
|
||||
|
||||
- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - OAuth 授权页面
|
||||
- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 认证回调
|
||||
- [LoginView.vue](../../src/views/LoginView.vue) - 登录页面
|
||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) - 平台管理
|
||||
- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置
|
||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) - 安全设置
|
||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表
|
||||
@@ -20,7 +20,9 @@
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@react-awesome-query-builder/antd": "6.7.0-alpha.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"antd": "^6.3.1",
|
||||
"appwrite": "^24.0.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"i18next": "^25.8.14",
|
||||
"json-rules-engine": "^7.3.1",
|
||||
|
||||
Generated
+32
-18
@@ -17,9 +17,15 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.5.0
|
||||
version: 2.10.1
|
||||
'@tauri-apps/plugin-shell':
|
||||
specifier: ^2.3.5
|
||||
version: 2.3.5
|
||||
antd:
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.2(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
appwrite:
|
||||
specifier: ^24.0.0
|
||||
version: 24.0.0
|
||||
dayjs:
|
||||
specifier: ^1.11.20
|
||||
version: 1.11.20
|
||||
@@ -842,79 +848,66 @@ packages:
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
@@ -978,35 +971,30 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
|
||||
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.10.1':
|
||||
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
|
||||
@@ -1031,6 +1019,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-shell@2.3.5':
|
||||
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
||||
|
||||
'@ts-jison/common@0.4.1-alpha.1':
|
||||
resolution: {integrity: sha512-SDbHzq+UMD+V3ciKVBHwCEgVqSeyQPTCjOsd/ZNTGySUVg4x3EauR9ZcEfdVFAsYRR38XWgDI+spq5LDY46KvQ==}
|
||||
|
||||
@@ -1172,6 +1163,9 @@ packages:
|
||||
react: '>=18.0.0'
|
||||
react-dom: '>=18.0.0'
|
||||
|
||||
appwrite@24.0.0:
|
||||
resolution: {integrity: sha512-zo0ZMf+B5S+OgGLXMvzAFDNWU/RB3a38oO40o/Atjdk0itBj3Z6uk1cp4ckNWiwZVFoThFrjZWBw2Au+Flk7lw==}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -1223,6 +1217,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
@@ -1767,6 +1764,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-bigint@1.0.0:
|
||||
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
||||
|
||||
json-buffer@3.0.1:
|
||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||
|
||||
@@ -3310,6 +3310,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-shell@2.3.5':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
'@ts-jison/common@0.4.1-alpha.1': {}
|
||||
|
||||
'@ts-jison/lexer@0.4.1-alpha.1':
|
||||
@@ -3546,6 +3550,10 @@ snapshots:
|
||||
- luxon
|
||||
- moment
|
||||
|
||||
appwrite@24.0.0:
|
||||
dependencies:
|
||||
json-bigint: 1.0.0
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
array-buffer-byte-length@1.0.2:
|
||||
@@ -3617,6 +3625,8 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.8: {}
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
@@ -4282,6 +4292,10 @@ snapshots:
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-bigint@1.0.0:
|
||||
dependencies:
|
||||
bignumber.js: 9.3.1
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-logic-js@2.0.5: {}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
你是 SecScore 的“看板 SQL 生成器”。你的任务是根据“用户需求”生成一条可直接在看板执行的 SQL。
|
||||
|
||||
## 1) 强约束(必须全部满足)
|
||||
|
||||
1. 只允许输出**单条只读查询**,且必须以 `SELECT` 或 `WITH` 开头。
|
||||
2. 禁止输出分号 `;`。
|
||||
3. 禁止输出注释:`--`、`/*`、`*/`。
|
||||
@@ -14,9 +15,11 @@
|
||||
6. 只返回 SQL 纯文本,不要 markdown,不要解释。
|
||||
|
||||
## 2) 模板变量(仅允许以下 6 个)
|
||||
|
||||
说明:本提示词里占位符写成了 `{ {xxx} }`(中间有空格),这是为了兼容部分模型平台。你在理解时请把空格忽略,按标准占位符理解;你在最终输出 SQL 时,请使用无空格的标准写法。
|
||||
|
||||
只允许使用:
|
||||
|
||||
- `{ {now} }`
|
||||
- `{ {today_start} }`
|
||||
- `{ {this_week_start} }`
|
||||
@@ -25,12 +28,15 @@
|
||||
- `{ {since_30d} }`
|
||||
|
||||
并且:
|
||||
|
||||
1. 模板变量必须放在**单引号**中使用,例如 `event_time >= '{ {since_7d} }'`。
|
||||
2. 严禁发明任何未支持变量,例如:`{ {month_start} }`、`{ {last_month_start} }` 等。
|
||||
3. 严禁输出任何带花括号但不在允许列表中的占位符。
|
||||
|
||||
## 3) 系统表/元数据表禁用(防跨库报错)
|
||||
|
||||
禁止查询任何数据库系统表、元数据表或 PRAGMA 信息,包括但不限于:
|
||||
|
||||
- `sqlite_master`, `sqlite_schema`, `pragma_*`
|
||||
- `pg_catalog.*`, `pg_class`, `pg_tables`
|
||||
- `information_schema.*`
|
||||
@@ -38,34 +44,45 @@
|
||||
只允许使用下面给定的业务表。
|
||||
|
||||
## 4) 可用业务表与字段
|
||||
|
||||
1. `students`
|
||||
|
||||
- `id`, `name`, `tags`, `score`, `reward_points`, `extra_json`, `created_at`, `updated_at`
|
||||
|
||||
2. `reasons`
|
||||
|
||||
- `id`, `content`, `category`, `delta`, `is_system`, `updated_at`
|
||||
|
||||
3. `score_events`
|
||||
|
||||
- `id`, `uuid`, `student_name`, `reason_content`, `delta`, `val_prev`, `val_curr`, `event_time`, `settlement_id`
|
||||
|
||||
4. `settlements`
|
||||
|
||||
- `id`, `start_time`, `end_time`, `created_at`
|
||||
|
||||
5. `settings`
|
||||
|
||||
- `key`, `value`
|
||||
|
||||
6. `tags`
|
||||
|
||||
- `id`, `name`, `created_at`, `updated_at`
|
||||
|
||||
7. `student_tags`
|
||||
|
||||
- `id`, `student_id`, `tag_id`, `created_at`
|
||||
|
||||
8. `reward_settings`
|
||||
|
||||
- `id`, `name`, `cost_points`, `created_at`, `updated_at`
|
||||
|
||||
9. `reward_redemptions`
|
||||
|
||||
- `id`, `uuid`, `student_name`, `reward_id`, `reward_name`, `cost_points`, `redeemed_at`
|
||||
|
||||
## 5) 字段名使用铁律(必须遵守,防止列不存在)
|
||||
|
||||
1. `SELECT`、`WHERE`、`GROUP BY`、`ORDER BY`、`JOIN ON` 中引用的字段名,必须来自上面的“可用业务表与字段”原样字段,严禁臆造字段。
|
||||
2. “展示名/别名”不等于真实字段名。若需展示为 `student_name`,必须使用别名,不可把别名当字段直接查询。
|
||||
3. 关键映射(高频易错):
|
||||
@@ -76,64 +93,80 @@
|
||||
5. 正确示例(优先生成):`SELECT name AS student_name, score FROM students`
|
||||
|
||||
## 6) 看板展示字段命名约定(尽量遵守)
|
||||
|
||||
- 学生名统一命名为:`student_name`
|
||||
- 常见指标命名建议:`score`, `reward_points`, `week_change`, `week_deducted`, `answered_count`
|
||||
|
||||
## 7) 生成策略
|
||||
|
||||
1. 排行类需求必须有 `ORDER BY`。
|
||||
2. 聚合类需求必须有清晰别名。
|
||||
3. 对可能为空的数据优先使用 `COALESCE`。
|
||||
4. 默认不写 `LIMIT`(系统外层会限制);除非用户明确要求更小结果集。
|
||||
|
||||
## 7.1) 积分符号语义(高优先级强制规则)
|
||||
|
||||
`score_events.delta` 的业务语义固定为:
|
||||
|
||||
- `delta > 0`:加分
|
||||
- `delta < 0`:扣分
|
||||
- `delta = 0`:不变(通常可忽略)
|
||||
|
||||
因此遇到“扣分”相关自然语言时,必须按以下规则改写:
|
||||
|
||||
1. “扣分记录” => `delta < 0`
|
||||
2. “扣分大于 N 分 / 扣了超过 N 分” => `delta < -N`(或等价写法 `delta < 0 AND ABS(delta) > N`)
|
||||
3. “扣分至少 N 分” => `delta <= -N`(或等价写法 `delta < 0 AND ABS(delta) >= N`)
|
||||
4. “加分大于 N 分” => `delta > N`
|
||||
|
||||
错误示例(禁止生成):
|
||||
|
||||
- “扣分 > 5” 写成 `delta > 5`
|
||||
|
||||
正确示例(优先生成):
|
||||
|
||||
- “扣分 > 5” 写成 `delta < -5`
|
||||
- “只看扣分” 写成 `delta < 0`
|
||||
|
||||
## 7.2) 积分相关查询结果字段强制规则
|
||||
|
||||
若用户意图与“积分”相关(例如:积分排行、积分变化、扣分/加分统计、学生积分看板),最终结果中必须包含“学生当前总积分”字段:
|
||||
|
||||
- 字段名统一输出为:`score`
|
||||
- 来源必须是 `students.score`(不是 `SUM(score_events.delta)`)
|
||||
|
||||
生成要求:
|
||||
|
||||
1. 若主表是 `students`:直接选择 `students.score AS score`(或 `s.score AS score`)
|
||||
2. 若主表不是 `students`(如 `score_events`):必须关联 `students` 并带出总积分
|
||||
标准关联:`LEFT JOIN students s ON s.name = <学生名字段>`
|
||||
3. 若查询按学生聚合,仍需在结果中包含 `score`,不要省略
|
||||
|
||||
错误示例(禁止生成):
|
||||
|
||||
- 积分相关 SQL 只返回 `student_name`、`week_change`,但没有 `score`
|
||||
- 用 `SUM(delta)` 命名为 `score` 冒充当前总积分
|
||||
|
||||
正确示例(优先生成):
|
||||
|
||||
- `SELECT e.student_name, COALESCE(s.score, 0) AS score, SUM(e.delta) AS week_change ... LEFT JOIN students s ON s.name = e.student_name ...`
|
||||
- `SELECT s.name AS student_name, s.score AS score FROM students s ...`
|
||||
|
||||
## 8) 自然周规则(允许生成上个自然周 SQL)
|
||||
|
||||
当用户要求“上个自然周/本自然周”时,必须使用模板变量区间表达,不要自行计算数据库日期函数:
|
||||
|
||||
- 上个自然周:`event_time >= '{ {last_week_start} }' AND event_time < '{ {this_week_start} }'`
|
||||
- 本自然周:`event_time >= '{ {this_week_start} }'`
|
||||
|
||||
注意:
|
||||
|
||||
1. 模板变量必须带单引号。
|
||||
2. 不要发明其它周边界变量。
|
||||
3. 避免使用 SQLite/PostgreSQL 方言日期函数(如 `strftime`、`date_trunc`)以保证跨库兼容。
|
||||
|
||||
## 9) 输出前自检清单(必须全部为“是”)
|
||||
|
||||
- 是否只用了 `SELECT/WITH`?
|
||||
- 是否没有 `;` 和注释?
|
||||
- 是否没有禁用关键词?
|
||||
|
||||
Generated
+98
@@ -606,6 +606,26 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -728,6 +748,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -967,6 +993,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -2818,6 +2853,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
@@ -3850,6 +3895,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.40.0"
|
||||
@@ -4178,12 +4233,14 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-shell",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -5215,6 +5272,27 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.5"
|
||||
@@ -5450,6 +5528,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
@@ -6440,6 +6527,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
|
||||
@@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "protocol-asset"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -39,6 +40,7 @@ once_cell = "1"
|
||||
parking_lot = { version = "0.12", features = ["send_guard"] }
|
||||
dirs = "6.0.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
urlencoding = "2.1"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for SecScore application","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-start-dragging","core:webview:default","core:app:default","core:tray:default","core:tray:allow-new","core:tray:allow-set-icon","core:tray:allow-set-menu","core:menu:default","shell:allow-open"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,30 @@ pub struct SetPasswordsResponse {
|
||||
pub recovery_string: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
pub platform_id: String,
|
||||
pub platform_secret: String,
|
||||
pub callback_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthTokenResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthUserInfo {
|
||||
pub user_id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub github_username: Option<String>,
|
||||
pub permission: u32,
|
||||
}
|
||||
|
||||
fn get_iv_hex() -> String {
|
||||
SecurityService::generate_iv_hex()
|
||||
}
|
||||
@@ -271,3 +295,116 @@ pub async fn auth_clear_all(
|
||||
Err(e) => Ok(IpcResponse::error(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn oauth_get_authorization_url(
|
||||
platform_id: String,
|
||||
callback_url: String,
|
||||
) -> Result<IpcResponse<String>, String> {
|
||||
let url = format!(
|
||||
"https://sectl.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code",
|
||||
platform_id,
|
||||
urlencoding::encode(&callback_url)
|
||||
);
|
||||
Ok(IpcResponse::success(url))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn oauth_exchange_code(
|
||||
code: String,
|
||||
platform_id: String,
|
||||
platform_secret: String,
|
||||
callback_url: String,
|
||||
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://sectl.top/api/oauth/token")
|
||||
.json(&serde_json::json!({
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": platform_id,
|
||||
"client_secret": platform_secret,
|
||||
"redirect_uri": callback_url
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Ok(IpcResponse::error(&error_text));
|
||||
}
|
||||
|
||||
let token_response: OAuthTokenResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
Ok(IpcResponse::success(token_response))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn oauth_get_user_info(
|
||||
access_token: String,
|
||||
) -> Result<IpcResponse<OAuthUserInfo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get("https://sectl.top/api/oauth/userinfo")
|
||||
.header("Authorization", format!("Bearer {}", access_token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Ok(IpcResponse::error(&error_text));
|
||||
}
|
||||
|
||||
let user_info: OAuthUserInfo = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
Ok(IpcResponse::success(user_info))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn oauth_refresh_token(
|
||||
refresh_token: String,
|
||||
platform_id: String,
|
||||
platform_secret: String,
|
||||
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://sectl.top/api/oauth/token")
|
||||
.json(&serde_json::json!({
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": platform_id,
|
||||
"client_secret": platform_secret
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Ok(IpcResponse::error(&error_text));
|
||||
}
|
||||
|
||||
let token_response: OAuthTokenResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
Ok(IpcResponse::success(token_response))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::services::settings::{SettingsKey, SettingsValue};
|
||||
use crate::{commands::*, state::AppState};
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
#[cfg(desktop)]
|
||||
use tauri::{
|
||||
image::Image,
|
||||
@@ -26,6 +27,7 @@ use tokio::time::{timeout, Duration};
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.setup(|app| {
|
||||
let state = AppState::new(app.handle().clone());
|
||||
app.manage(Arc::new(RwLock::new(state)));
|
||||
@@ -73,6 +75,10 @@ pub fn run() {
|
||||
auth_generate_recovery,
|
||||
auth_reset_by_recovery,
|
||||
auth_clear_all,
|
||||
oauth_get_authorization_url,
|
||||
oauth_exchange_code,
|
||||
oauth_get_user_info,
|
||||
oauth_refresh_token,
|
||||
theme_list,
|
||||
theme_current,
|
||||
theme_set,
|
||||
@@ -136,6 +142,26 @@ pub fn setup_app(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
setup_window_events(app)?;
|
||||
|
||||
setup_deep_link(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_deep_link(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let handle = app.handle().clone();
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
app.deep_link().on_open_url(move |event| {
|
||||
let url = event.urls().first().map(|u| u.to_string()).unwrap_or_default();
|
||||
if !url.is_empty() {
|
||||
let _ = handle.emit("deep-link://new-url", url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"deepLink": {
|
||||
"domains": [
|
||||
{
|
||||
"host": "oauth",
|
||||
"schemes": ["secscore"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
-11
@@ -1,4 +1,13 @@
|
||||
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme, Drawer } from "antd"
|
||||
import {
|
||||
Layout,
|
||||
Modal,
|
||||
Input,
|
||||
message,
|
||||
ConfigProvider,
|
||||
theme as antTheme,
|
||||
Drawer,
|
||||
Button,
|
||||
} from "antd"
|
||||
import {
|
||||
HomeOutlined,
|
||||
SettingOutlined,
|
||||
@@ -20,15 +29,16 @@ import { useTranslation } from "react-i18next"
|
||||
import { Sidebar } from "./components/Sidebar"
|
||||
import { ContentArea } from "./components/ContentArea"
|
||||
import { OOBE } from "./components/OOBE/OOBE"
|
||||
import { OAuthLogin } from "./components/OAuthLogin"
|
||||
import { OAuthCallback } from "./components/OAuthCallback"
|
||||
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
||||
import {
|
||||
MOBILE_NAV_ITEMS,
|
||||
MobileNavKey,
|
||||
sanitizeMobileNavKeys,
|
||||
} from "./shared/mobileNavigation"
|
||||
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
|
||||
|
||||
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
|
||||
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(0, 4)
|
||||
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
|
||||
0,
|
||||
4
|
||||
)
|
||||
|
||||
function MainContent(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
@@ -88,6 +98,7 @@ function MainContent(): React.JSX.Element {
|
||||
const [authVisible, setAuthVisible] = useState(false)
|
||||
const [authPassword, setAuthPassword] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
const [oauthVisible, setOAuthVisible] = useState(false)
|
||||
const [mobileBottomNavItems, setMobileBottomNavItems] = useState<MobileNavKey[]>(
|
||||
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
|
||||
)
|
||||
@@ -97,9 +108,10 @@ function MainContent(): React.JSX.Element {
|
||||
const [editingMoreNavKeys, setEditingMoreNavKeys] = useState<MobileNavKey[]>([])
|
||||
const [draggingNavKey, setDraggingNavKey] = useState<MobileNavKey | null>(null)
|
||||
const [draggingFromList, setDraggingFromList] = useState<"bottom" | "more" | null>(null)
|
||||
const [dragOverSlot, setDragOverSlot] = useState<{ list: "bottom" | "more"; index: number } | null>(
|
||||
null
|
||||
)
|
||||
const [dragOverSlot, setDragOverSlot] = useState<{
|
||||
list: "bottom" | "more"
|
||||
index: number
|
||||
} | null>(null)
|
||||
const [isPortraitMode] = useState(defaultPortraitMode)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode)
|
||||
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
|
||||
@@ -379,6 +391,24 @@ function MainContent(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOAuthSuccess = (userInfo: {
|
||||
user_id: string
|
||||
email: string
|
||||
name: string
|
||||
github_username?: string
|
||||
permission: number
|
||||
}) => {
|
||||
let newPermission: "admin" | "points" | "view" = "view"
|
||||
if (userInfo.permission >= 18) {
|
||||
newPermission = "admin"
|
||||
} else if (userInfo.permission >= 1) {
|
||||
newPermission = "points"
|
||||
}
|
||||
|
||||
setPermission(newPermission)
|
||||
messageApi.success(t("auth.oauthSuccess", "登录成功"))
|
||||
}
|
||||
|
||||
const onMenuChange = (v: string) => {
|
||||
const key = String(v)
|
||||
setMoreNavVisible(false)
|
||||
@@ -544,7 +574,9 @@ function MainContent(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
{contextHolder}
|
||||
<Layout style={{ height: "100%", flexDirection: "row", overflow: "hidden", position: "relative" }}>
|
||||
<Layout
|
||||
style={{ height: "100%", flexDirection: "row", overflow: "hidden", position: "relative" }}
|
||||
>
|
||||
{!isPortraitMode && (
|
||||
<div
|
||||
className={`ss-immersive-sidebar ${immersiveMode ? "is-hidden" : "is-visible"}`}
|
||||
@@ -878,9 +910,26 @@ function MainContent(): React.JSX.Element {
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
maxLength={6}
|
||||
/>
|
||||
<div style={{ textAlign: "center", marginTop: "8px" }}>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
setAuthVisible(false)
|
||||
setOAuthVisible(true)
|
||||
}}
|
||||
>
|
||||
{t("auth.useOAuth", "使用 SECTL Auth 登录")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<OAuthLogin
|
||||
visible={oauthVisible}
|
||||
onClose={() => setOAuthVisible(false)}
|
||||
onSuccess={handleOAuthSuccess}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="检测到本地与远程数据冲突"
|
||||
open={syncConflictVisible}
|
||||
@@ -1044,6 +1093,7 @@ function App(): React.JSX.Element {
|
||||
<ThemeProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route path="/*" element={<MainContent />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
|
||||
@@ -872,17 +872,20 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
? { 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
|
||||
? { 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
|
||||
? { label: t("board.metrics.totalScore"), value: item.score }
|
||||
: item.rewardPoints !== undefined
|
||||
@@ -1039,10 +1042,13 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
{(list.scoreDisplayMode === "total" ||
|
||||
(list.scoreDisplayMode === "split" && !hasSplitScore)) &&
|
||||
item.score !== undefined && (
|
||||
<Tag color={item.score >= 0 ? "success" : "error"} style={{ margin: 0 }}>
|
||||
{t("board.metrics.totalScore")}:{" "}
|
||||
{item.score > 0 ? `+${item.score}` : item.score}
|
||||
</Tag>
|
||||
<Tag
|
||||
color={item.score >= 0 ? "success" : "error"}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{t("board.metrics.totalScore")}:{" "}
|
||||
{item.score > 0 ? `+${item.score}` : item.score}
|
||||
</Tag>
|
||||
)}
|
||||
{list.scoreDisplayMode === "split" && item.addScore !== undefined && (
|
||||
<Tag color="success" style={{ margin: 0 }}>
|
||||
@@ -1428,7 +1434,11 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
}}
|
||||
/>
|
||||
<Typography.Link href="https://doubao.com/bot/uEh3mtxq" target="_blank" rel="noreferrer">
|
||||
<Typography.Link
|
||||
href="https://doubao.com/bot/uEh3mtxq"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
豆包智能体一句话生成查询
|
||||
</Typography.Link>
|
||||
<Typography.Link
|
||||
|
||||
@@ -371,10 +371,7 @@ export function ContentArea({
|
||||
path="/reward-settings"
|
||||
element={<RewardSettings canEdit={permission === "admin"} />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<Settings permission={permission} />}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings permission={permission} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
+141
-135
@@ -2356,7 +2356,9 @@ export const Home: React.FC<HomeProps> = ({
|
||||
onClick={() => handleSearchKeyPress(keyItem.digit)}
|
||||
style={{ height: "28px", fontSize: "11px", padding: 0 }}
|
||||
>
|
||||
{keyItem.digit === "⌫" ? "⌫" : `${keyItem.digit} ${keyItem.letters}`}
|
||||
{keyItem.digit === "⌫"
|
||||
? "⌫"
|
||||
: `${keyItem.digit} ${keyItem.letters}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -2543,147 +2545,151 @@ export const Home: React.FC<HomeProps> = ({
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(node) => {
|
||||
searchAreaRef.current = node
|
||||
immersiveToolbarContentRef.current = node
|
||||
<div
|
||||
ref={(node) => {
|
||||
searchAreaRef.current = node
|
||||
immersiveToolbarContentRef.current = node
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
overflowX: "visible",
|
||||
justifyContent: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
|
||||
}}
|
||||
onClick={() => {
|
||||
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setShowPinyinKeyboard(false)
|
||||
}}
|
||||
placeholder={t("home.searchPlaceholder")}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
overflowX: "visible",
|
||||
justifyContent: "flex-start",
|
||||
width: isPortraitMode ? "170px" : "220px",
|
||||
borderRadius: "999px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={sortType}
|
||||
onChange={(v) => setSortType(v as SortType)}
|
||||
getPopupContainer={getImmersivePopupContainer}
|
||||
style={{ width: 126, flexShrink: 0 }}
|
||||
options={[
|
||||
{ value: "alphabet", label: t("home.sortBy.alphabet") },
|
||||
{ value: "surname", label: t("home.sortBy.surname") },
|
||||
{ value: "group", label: t("home.sortBy.group") },
|
||||
{ value: "score", label: t("home.sortBy.score") },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={layoutType}
|
||||
onChange={(v) => setLayoutType(v as LayoutType)}
|
||||
getPopupContainer={getImmersivePopupContainer}
|
||||
style={{ width: 126, flexShrink: 0 }}
|
||||
options={[
|
||||
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
||||
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
onClick={handleUndoLastEvent}
|
||||
loading={undoLoading}
|
||||
disabled={!canEdit || !latestEvent || rewardMode}
|
||||
title={
|
||||
latestEvent
|
||||
? t("home.undoLastHint", {
|
||||
name: latestEvent.student_name,
|
||||
delta: latestEvent.delta > 0 ? `+${latestEvent.delta}` : latestEvent.delta,
|
||||
})
|
||||
: t("home.undoUnavailable")
|
||||
}
|
||||
style={{ borderRadius: "999px", flexShrink: 0 }}
|
||||
>
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
|
||||
{t("home.undoLastAction")}
|
||||
</Button>
|
||||
<Button
|
||||
type={rewardMode ? "default" : "primary"}
|
||||
onClick={handleToggleRewardMode}
|
||||
disabled={!canEdit}
|
||||
style={{ borderRadius: "999px", flexShrink: 0 }}
|
||||
>
|
||||
{rewardMode ? t("rewardExchange.exitMode") : t("rewardExchange.enterMode")}
|
||||
</Button>
|
||||
<div style={{ flexShrink: 0 }}>{batchToolbar}</div>
|
||||
{canShowSearchKeyboard && showPinyinKeyboard && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 8px)",
|
||||
left: isPortraitMode ? "8px" : "12px",
|
||||
width: searchKeyboardLayout === "qwerty26" ? "288px" : "220px",
|
||||
padding: "8px",
|
||||
borderRadius: "10px",
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)",
|
||||
zIndex: 20,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setShowPinyinKeyboard(false)
|
||||
}}
|
||||
placeholder={t("home.searchPlaceholder")}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: isPortraitMode ? "170px" : "220px", borderRadius: "999px", flexShrink: 0 }}
|
||||
/>
|
||||
<Select
|
||||
value={sortType}
|
||||
onChange={(v) => setSortType(v as SortType)}
|
||||
getPopupContainer={getImmersivePopupContainer}
|
||||
style={{ width: 126, flexShrink: 0 }}
|
||||
options={[
|
||||
{ value: "alphabet", label: t("home.sortBy.alphabet") },
|
||||
{ value: "surname", label: t("home.sortBy.surname") },
|
||||
{ value: "group", label: t("home.sortBy.group") },
|
||||
{ value: "score", label: t("home.sortBy.score") },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={layoutType}
|
||||
onChange={(v) => setLayoutType(v as LayoutType)}
|
||||
getPopupContainer={getImmersivePopupContainer}
|
||||
style={{ width: 126, flexShrink: 0 }}
|
||||
options={[
|
||||
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
||||
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
onClick={handleUndoLastEvent}
|
||||
loading={undoLoading}
|
||||
disabled={!canEdit || !latestEvent || rewardMode}
|
||||
title={
|
||||
latestEvent
|
||||
? t("home.undoLastHint", {
|
||||
name: latestEvent.student_name,
|
||||
delta: latestEvent.delta > 0 ? `+${latestEvent.delta}` : latestEvent.delta,
|
||||
})
|
||||
: t("home.undoUnavailable")
|
||||
}
|
||||
style={{ borderRadius: "999px", flexShrink: 0 }}
|
||||
>
|
||||
{t("home.undoLastAction")}
|
||||
</Button>
|
||||
<Button
|
||||
type={rewardMode ? "default" : "primary"}
|
||||
onClick={handleToggleRewardMode}
|
||||
disabled={!canEdit}
|
||||
style={{ borderRadius: "999px", flexShrink: 0 }}
|
||||
>
|
||||
{rewardMode ? t("rewardExchange.exitMode") : t("rewardExchange.enterMode")}
|
||||
</Button>
|
||||
<div style={{ flexShrink: 0 }}>{batchToolbar}</div>
|
||||
{canShowSearchKeyboard && showPinyinKeyboard && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 8px)",
|
||||
left: isPortraitMode ? "8px" : "12px",
|
||||
width: searchKeyboardLayout === "qwerty26" ? "288px" : "220px",
|
||||
padding: "8px",
|
||||
borderRadius: "10px",
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)",
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{searchKeyboardLayout === "qwerty26"
|
||||
? qwertyKeyRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`qwerty-immersive-row-${rowIndex}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${row.length}, 1fr)`,
|
||||
gap: "7px",
|
||||
}}
|
||||
>
|
||||
{row.map((keyItem) => (
|
||||
<Button
|
||||
key={keyItem}
|
||||
size="small"
|
||||
onClick={() => handleSearchKeyPress(keyItem)}
|
||||
style={{ height: "32px", fontSize: "12px", padding: 0 }}
|
||||
>
|
||||
{keyItem === "⌫" ? "⌫" : keyItem.toUpperCase()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
: t9KeyRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`pinyin-immersive-row-${rowIndex}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{row.map((keyItem) => (
|
||||
<Button
|
||||
key={keyItem.digit}
|
||||
size="small"
|
||||
onClick={() => handleSearchKeyPress(keyItem.digit)}
|
||||
style={{ height: "28px", fontSize: "11px", padding: 0 }}
|
||||
>
|
||||
{keyItem.digit === "⌫" ? "⌫" : `${keyItem.digit} ${keyItem.letters}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{searchKeyboardLayout === "qwerty26"
|
||||
? qwertyKeyRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`qwerty-immersive-row-${rowIndex}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${row.length}, 1fr)`,
|
||||
gap: "7px",
|
||||
}}
|
||||
>
|
||||
{row.map((keyItem) => (
|
||||
<Button
|
||||
key={keyItem}
|
||||
size="small"
|
||||
onClick={() => handleSearchKeyPress(keyItem)}
|
||||
style={{ height: "32px", fontSize: "12px", padding: 0 }}
|
||||
>
|
||||
{keyItem === "⌫" ? "⌫" : keyItem.toUpperCase()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
: t9KeyRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`pinyin-immersive-row-${rowIndex}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{row.map((keyItem) => (
|
||||
<Button
|
||||
key={keyItem.digit}
|
||||
size="small"
|
||||
onClick={() => handleSearchKeyPress(keyItem.digit)}
|
||||
style={{ height: "28px", fontSize: "11px", padding: 0 }}
|
||||
>
|
||||
{keyItem.digit === "⌫" ? "⌫" : `${keyItem.digit} ${keyItem.letters}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPortraitMode ? (
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
|
||||
export function OAuthCallback() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
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 (code) {
|
||||
window.postMessage(
|
||||
{
|
||||
code,
|
||||
},
|
||||
window.location.origin
|
||||
)
|
||||
navigate("/")
|
||||
}
|
||||
}, [searchParams, navigate])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
background: "var(--ss-bg-color)",
|
||||
color: "var(--ss-text-main)",
|
||||
}}
|
||||
>
|
||||
<div>正在处理登录...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Button, message, Modal, Space, Spin } from "antd"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { open } from "@tauri-apps/plugin-shell"
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event"
|
||||
|
||||
interface OAuthLoginProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onSuccess: (userInfo: {
|
||||
user_id: string
|
||||
email: string
|
||||
name: string
|
||||
github_username?: string
|
||||
permission: number
|
||||
}) => void
|
||||
}
|
||||
|
||||
interface OAuthConfig {
|
||||
platform_id: string
|
||||
platform_secret: string
|
||||
callback_url: string
|
||||
}
|
||||
|
||||
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const deepLinkUnlistenRef = useRef<UnlistenFn | null>(null)
|
||||
|
||||
const getOAuthConfig = (): OAuthConfig | null => {
|
||||
const api = (window as any).api
|
||||
if (!api) return null
|
||||
|
||||
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
|
||||
const platformSecret = import.meta.env.VITE_OAUTH_PLATFORM_SECRET
|
||||
const callbackUrl = import.meta.env.VITE_OAUTH_CALLBACK_URL || "secscore://oauth/callback"
|
||||
|
||||
if (!platformId || !platformSecret) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
platform_id: platformId,
|
||||
platform_secret: platformSecret,
|
||||
callback_url: callbackUrl,
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeepLink = async (url: string) => {
|
||||
const config = getOAuthConfig()
|
||||
if (!config) return
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const code = urlObj.searchParams.get("code")
|
||||
const error = urlObj.searchParams.get("error")
|
||||
|
||||
if (error) {
|
||||
message.error(decodeURIComponent(error))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (code) {
|
||||
const api = (window as any).api
|
||||
const tokenRes = await api.oauthExchangeCode(
|
||||
code,
|
||||
config.platform_id,
|
||||
config.platform_secret,
|
||||
config.callback_url
|
||||
)
|
||||
|
||||
if (!tokenRes.success) {
|
||||
message.error(tokenRes.message || "获取访问令牌失败")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const userRes = await api.oauthGetUserInfo(tokenRes.data.access_token)
|
||||
|
||||
if (!userRes.success) {
|
||||
message.error(userRes.message || "获取用户信息失败")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
onSuccess(userRes.data)
|
||||
onClose()
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || "登录失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const setupDeepLink = async () => {
|
||||
try {
|
||||
const unlisten = await listen<string>("deep-link://new-url", (event) => {
|
||||
if (event.payload) {
|
||||
handleDeepLink(event.payload)
|
||||
}
|
||||
})
|
||||
deepLinkUnlistenRef.current = unlisten
|
||||
} catch (error) {
|
||||
console.error("Failed to setup deep link listener:", error)
|
||||
}
|
||||
}
|
||||
|
||||
setupDeepLink()
|
||||
|
||||
return () => {
|
||||
if (deepLinkUnlistenRef.current) {
|
||||
deepLinkUnlistenRef.current()
|
||||
deepLinkUnlistenRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
const config = getOAuthConfig()
|
||||
if (!config) {
|
||||
message.error("OAuth 配置未设置")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const api = (window as any).api
|
||||
const urlRes = await api.oauthGetAuthorizationUrl(config.platform_id, config.callback_url)
|
||||
|
||||
if (!urlRes.success) {
|
||||
message.error(urlRes.message || "获取授权链接失败")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
await open(urlRes.data)
|
||||
} catch (error: any) {
|
||||
message.error(error.message || "登录失败")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("auth.oauthLogin", "SECTL Auth 登录")}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={400}
|
||||
centered
|
||||
>
|
||||
<div style={{ textAlign: "center", padding: "20px 0" }}>
|
||||
{loading ? (
|
||||
<Space direction="vertical" size="middle">
|
||||
<Spin size="large" />
|
||||
<div>{t("auth.oauthLoggingIn", "正在登录...")}</div>
|
||||
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||
请在浏览器中完成授权
|
||||
</div>
|
||||
</Space>
|
||||
) : (
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<div style={{ color: "var(--ss-text-secondary)" }}>
|
||||
{t("auth.oauthHint", "使用 SECTL Auth 账号登录,享受统一认证和远程退登功能")}
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleOAuthLogin}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("auth.oauthButton", "使用 SECTL Auth 登录")}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+122
-5
@@ -12,12 +12,17 @@ import {
|
||||
Modal,
|
||||
Switch,
|
||||
message,
|
||||
Avatar,
|
||||
Typography,
|
||||
} from "antd"
|
||||
import { ThemeQuickSettings } from "./ThemeQuickSettings"
|
||||
import { OAuthLogin } from "./OAuthLogin"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
|
||||
import { useResponsive } from "../hooks/useResponsive"
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
type permissionLevel = "admin" | "points" | "view"
|
||||
type appSettings = {
|
||||
is_wizard_completed: boolean
|
||||
@@ -76,7 +81,13 @@ export const Settings: React.FC<{
|
||||
const [recoveryDialogString, setRecoveryDialogString] = useState("")
|
||||
const [recoveryDialogFilename, setRecoveryDialogFilename] = useState("")
|
||||
|
||||
const [aboutContent, setAboutContent] = useState<{ title: string; description: string; content: string; rawMarkdown: string } | null>(null)
|
||||
const [aboutContent, setAboutContent] = useState<{
|
||||
title: string
|
||||
description: string
|
||||
content: string
|
||||
rawMarkdown: string
|
||||
version?: string
|
||||
} | null>(null)
|
||||
|
||||
const [logsDialogVisible, setLogsDialogVisible] = useState(false)
|
||||
const [logsText, setLogsText] = useState("")
|
||||
@@ -120,6 +131,15 @@ export const Settings: React.FC<{
|
||||
const [pgSwitchLoading, setPgSwitchLoading] = useState(false)
|
||||
const [pgUploadLoading, setPgUploadLoading] = useState(false)
|
||||
|
||||
const [oauthLoginVisible, setOAuthLoginVisible] = useState(false)
|
||||
const [oauthUserInfo, setOAuthUserInfo] = useState<{
|
||||
user_id: string
|
||||
email: string
|
||||
name: string
|
||||
github_username?: string
|
||||
permission: number
|
||||
} | null>(null)
|
||||
|
||||
const permissionTag = useMemo(() => {
|
||||
return (
|
||||
<Tag
|
||||
@@ -652,7 +672,10 @@ export const Settings: React.FC<{
|
||||
onChange={async (v) => {
|
||||
if (!(window as any).api) return
|
||||
const next = String(v) as "t9" | "qwerty26"
|
||||
const res = await (window as any).api.setSetting("search_keyboard_layout", next)
|
||||
const res = await (window as any).api.setSetting(
|
||||
"search_keyboard_layout",
|
||||
next
|
||||
)
|
||||
if (res.success) {
|
||||
setSettings((prev) => ({ ...prev, search_keyboard_layout: next }))
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
@@ -668,7 +691,11 @@ export const Settings: React.FC<{
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
style={{
|
||||
marginTop: "4px",
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("settings.searchKeyboard.hint")}
|
||||
</div>
|
||||
@@ -693,7 +720,11 @@ export const Settings: React.FC<{
|
||||
disabled={!canAdmin}
|
||||
/>
|
||||
<div
|
||||
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
style={{
|
||||
marginTop: "4px",
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("settings.searchKeyboard.disableHint")}
|
||||
</div>
|
||||
@@ -734,7 +765,6 @@ export const Settings: React.FC<{
|
||||
{t("settings.zoomHint")}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
</Form>
|
||||
</Card>
|
||||
),
|
||||
@@ -843,6 +873,84 @@ export const Settings: React.FC<{
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "account",
|
||||
label: t("settings.tabs.account"),
|
||||
children: (
|
||||
<>
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
color: "var(--ss-text-main)",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: "16px" }}>
|
||||
{t("settings.account.title")}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<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>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text type="secondary">{t("settings.account.userId")}:</Text>
|
||||
<Text code style={{ marginLeft: "8px" }}>
|
||||
{oauthUserInfo.user_id}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text type="secondary">{t("settings.account.permission")}:</Text>
|
||||
<Tag color="blue" style={{ marginLeft: "8px" }}>
|
||||
{oauthUserInfo.permission === 1
|
||||
? t("permissions.admin")
|
||||
: oauthUserInfo.permission === 2
|
||||
? t("permissions.points")
|
||||
: t("permissions.view")}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Button danger onClick={() => setOAuthUserInfo(null)}>
|
||||
{t("settings.account.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: "20px 0" }}>
|
||||
<Paragraph style={{ color: "var(--ss-text-secondary)" }}>
|
||||
{t("settings.account.notLoggedIn")}
|
||||
</Paragraph>
|
||||
<Paragraph style={{ color: "var(--ss-text-secondary)", fontSize: "13px" }}>
|
||||
{t("settings.account.oauthHint")}
|
||||
</Paragraph>
|
||||
<Button type="primary" size="large" onClick={() => setOAuthLoginVisible(true)}>
|
||||
{t("settings.account.loginButton")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "database",
|
||||
label: t("settings.database.title"),
|
||||
@@ -1420,6 +1528,15 @@ export const Settings: React.FC<{
|
||||
>
|
||||
清空后将关闭保护(无密码时默认视为管理权限)。
|
||||
</Modal>
|
||||
|
||||
<OAuthLogin
|
||||
visible={oauthLoginVisible}
|
||||
onClose={() => setOAuthLoginVisible(false)}
|
||||
onSuccess={(userInfo) => {
|
||||
setOAuthUserInfo(userInfo)
|
||||
messageApi.success(t("settings.account.loginSuccess"))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -260,7 +260,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
return
|
||||
}
|
||||
|
||||
const res = await (window as any).api.createStudent({ ...values, name, group_name: groupName })
|
||||
const res = await (window as any).api.createStudent({
|
||||
...values,
|
||||
name,
|
||||
group_name: groupName,
|
||||
})
|
||||
if (res.success) {
|
||||
messageApi.success(t("students.addSuccess"))
|
||||
setVisible(false)
|
||||
@@ -1080,7 +1084,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const selectedMedals = (banYouDetail.medals || []).filter((m, idx) =>
|
||||
banYouCheckedMedals.includes(medalKey(m, idx))
|
||||
)
|
||||
const selectedTeams = (banYouDetail.teams || []).filter((g) => banYouCheckedTeams.includes(g.teamId))
|
||||
const selectedTeams = (banYouDetail.teams || []).filter((g) =>
|
||||
banYouCheckedTeams.includes(g.teamId)
|
||||
)
|
||||
|
||||
if (!selectedStudents.length && !selectedMedals.length && !selectedTeams.length) {
|
||||
messageApi.warning(t("students.banyouNothingSelected"))
|
||||
@@ -1325,7 +1331,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button size={isMobile ? "small" : "middle"} disabled={!canEdit} icon={<MoreOutlined />}>
|
||||
<Button
|
||||
size={isMobile ? "small" : "middle"}
|
||||
disabled={!canEdit}
|
||||
icon={<MoreOutlined />}
|
||||
>
|
||||
{t("common.operation")}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
@@ -1608,7 +1618,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
borderBottom: "1px dashed var(--ss-border-color)",
|
||||
paddingBottom: 8,
|
||||
cursor:
|
||||
groupKey === UNGROUPED_KEY || pointerDraggingStudentId != null ? "default" : "grab",
|
||||
groupKey === UNGROUPED_KEY || pointerDraggingStudentId != null
|
||||
? "default"
|
||||
: "grab",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
touchAction: "none",
|
||||
@@ -1632,7 +1644,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
studentsInGroup.map((student) => (
|
||||
<div
|
||||
key={`${groupKey}-${student.id}`}
|
||||
onPointerDown={(e) => beginPointerDrag(e, student.id, student.name, groupKey)}
|
||||
onPointerDown={(e) =>
|
||||
beginPointerDrag(e, student.id, student.name, groupKey)
|
||||
}
|
||||
onPointerMove={(e) => trackPointerTarget(e.clientX, e.clientY)}
|
||||
onPointerUp={finishPointerDrag}
|
||||
onPointerCancel={finishPointerDrag}
|
||||
@@ -1935,7 +1949,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
cancelText={t("common.cancel")}
|
||||
>
|
||||
{banYouDetailLoading ? (
|
||||
<div style={{ padding: "24px 0", textAlign: "center", color: "var(--ss-text-secondary)" }}>
|
||||
<div
|
||||
style={{ padding: "24px 0", textAlign: "center", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : !banYouDetail ? (
|
||||
@@ -1973,7 +1989,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8 }}>{t("students.banyouReasonList")}</div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8 }}>
|
||||
{t("students.banyouReasonList")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
@@ -2006,7 +2024,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8 }}>{t("students.banyouStudentList")}</div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8 }}>
|
||||
{t("students.banyouStudentList")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"tabs": {
|
||||
"appearance": "Appearance",
|
||||
"security": "Security",
|
||||
"account": "Account",
|
||||
"database": "Database Connection",
|
||||
"dataManagement": "Data Management",
|
||||
"urlProtocol": "URL Protocol",
|
||||
@@ -209,6 +210,16 @@
|
||||
"passwordClearedNewRecovery": "Password cleared, new recovery string",
|
||||
"newRecoveryString": "New recovery string (please save it)"
|
||||
},
|
||||
"account": {
|
||||
"title": "SECTL Auth Account",
|
||||
"notLoggedIn": "Not logged in to SECTL Auth",
|
||||
"oauthHint": "Use SECTL Auth account to login, enjoy unified authentication and remote logout",
|
||||
"loginButton": "Login with SECTL Auth",
|
||||
"loginSuccess": "Login successful",
|
||||
"logout": "Logout",
|
||||
"userId": "User ID",
|
||||
"permission": "Permission Level"
|
||||
},
|
||||
"database": {
|
||||
"title": "Database Connection",
|
||||
"currentStatus": "Current Database Status",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"tabs": {
|
||||
"appearance": "外观",
|
||||
"security": "安全",
|
||||
"account": "账户",
|
||||
"database": "数据库连接",
|
||||
"dataManagement": "数据管理",
|
||||
"urlProtocol": "URL 链接",
|
||||
@@ -209,6 +210,16 @@
|
||||
"passwordClearedNewRecovery": "密码已清空,新的找回字符串",
|
||||
"newRecoveryString": "新的找回字符串(请保存)"
|
||||
},
|
||||
"account": {
|
||||
"title": "SECTL Auth 账户",
|
||||
"notLoggedIn": "尚未登录 SECTL Auth 账户",
|
||||
"oauthHint": "使用 SECTL Auth 账号登录,享受统一认证和远程退登功能",
|
||||
"loginButton": "使用 SECTL Auth 登录",
|
||||
"loginSuccess": "登录成功",
|
||||
"logout": "退出登录",
|
||||
"userId": "用户ID",
|
||||
"permission": "权限等级"
|
||||
},
|
||||
"database": {
|
||||
"title": "数据库连接",
|
||||
"currentStatus": "当前数据库状态",
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ const enableTouchWindowDrag = () => {
|
||||
if (!target) return
|
||||
|
||||
// 检查是否在可拖动区域内
|
||||
const dragRegion = target.closest('[data-tauri-drag-region]')
|
||||
const dragRegion = target.closest("[data-tauri-drag-region]")
|
||||
if (!dragRegion) return
|
||||
|
||||
// 检查是否点击了不可拖动的元素(如按钮)
|
||||
|
||||
+53
-2
@@ -87,7 +87,9 @@ const api = {
|
||||
names: string[]
|
||||
}): Promise<{ success: boolean; data: { inserted: number; skipped: number; total: number } }> =>
|
||||
invoke("student_import_from_xlsx", { params }),
|
||||
fetchBanYouClassrooms: (params: { cookie: string }): Promise<{
|
||||
fetchBanYouClassrooms: (params: {
|
||||
cookie: string
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
classrooms: Array<{
|
||||
@@ -280,6 +282,55 @@ const api = {
|
||||
invoke("auth_reset_by_recovery", { recoveryString }),
|
||||
authClearAll: (): Promise<{ success: boolean }> => invoke("auth_clear_all"),
|
||||
|
||||
// OAuth
|
||||
oauthGetAuthorizationUrl: (
|
||||
platformId: string,
|
||||
callbackUrl: string
|
||||
): Promise<{ success: boolean; data: string; message?: string }> =>
|
||||
invoke("oauth_get_authorization_url", { platformId, callbackUrl }),
|
||||
oauthExchangeCode: (
|
||||
code: string,
|
||||
platformId: string,
|
||||
platformSecret: string,
|
||||
callbackUrl: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("oauth_exchange_code", { code, platformId, platformSecret, callbackUrl }),
|
||||
oauthGetUserInfo: (
|
||||
accessToken: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
user_id: string
|
||||
email: string
|
||||
name: string
|
||||
github_username?: string
|
||||
permission: number
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("oauth_get_user_info", { accessToken }),
|
||||
oauthRefreshToken: (
|
||||
refreshToken: string,
|
||||
platformId: string,
|
||||
platformSecret: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("oauth_refresh_token", { refreshToken, platformId, platformSecret }),
|
||||
|
||||
// Data import/export
|
||||
exportDataJson: (): Promise<{ success: boolean; data: string }> => invoke("data_export_json"),
|
||||
importDataJson: (jsonText: string): Promise<{ success: boolean }> =>
|
||||
@@ -442,7 +493,7 @@ const api = {
|
||||
appRestart: (): Promise<void> => invoke("app_restart"),
|
||||
|
||||
// Generic invoke wrapper for backward compatibility with callers using `api.invoke`
|
||||
invoke: async (channel: string, ...args: any[]): Promise<any> => {
|
||||
invoke: async (channel: string): Promise<any> => {
|
||||
switch (channel) {
|
||||
default:
|
||||
throw new Error(`Unsupported legacy invoke channel: ${channel}`)
|
||||
|
||||
@@ -2,6 +2,7 @@ export const MOBILE_NAV_ALL_KEYS = [
|
||||
"home",
|
||||
"students",
|
||||
"score",
|
||||
"auto-score",
|
||||
"reward-settings",
|
||||
"boards",
|
||||
"leaderboard",
|
||||
|
||||
Reference in New Issue
Block a user