mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 03:54:22 +08:00
feat(URL协议): 增强URL协议注册健壮性
- 新增check_url_protocol_status命令检测注册状态 - 新增unregister_url_protocol命令取消注册 - 改进register_url_protocol命令增加验证逻辑 - 前端增加状态检测、自动重试和操作日志功能 - 更新多语言翻译支持新功能
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,238 @@
|
||||
# URL 协议注册健壮性增强计划
|
||||
|
||||
## 目标
|
||||
|
||||
为 SecScore 的 URL 协议注册功能实现完整的健壮性增强,包括:状态检测、注册验证、自动重试(最多7次)、按钮状态自动切换、取消注册、以及全流程日志记录。
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
| -------------------------------- | ---- | ---------------------------------------------------------------------------------------------- |
|
||||
| `src-tauri/src/commands/app.rs` | 修改 | 新增 `check_url_protocol_status`、`unregister_url_protocol` 命令;增强 `register_url_protocol` |
|
||||
| `src-tauri/src/lib.rs` | 修改 | 注册新命令到 invoke_handler |
|
||||
| `src/preload/types.ts` | 修改 | 新增 API 类型定义 |
|
||||
| `src/components/Settings.tsx` | 修改 | 重写 URL 协议 UI,加入状态显示、重试逻辑、按钮切换 |
|
||||
| `src/i18n/locales/*.json` (10个) | 修改 | 新增 i18n 翻译键 |
|
||||
|
||||
---
|
||||
|
||||
## 步骤 1:Rust 后端 - 新增 `UrlProtocolStatus` 结构体和 `check_url_protocol_status` 命令
|
||||
|
||||
**文件**: `src-tauri/src/commands/app.rs`
|
||||
|
||||
### 1.1 新增结构体
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UrlProtocolStatus {
|
||||
pub registered: bool,
|
||||
pub protocol: String,
|
||||
pub platform: String,
|
||||
pub details: String,
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 实现 `check_url_protocol_status` 命令
|
||||
|
||||
按平台实现 URL 协议状态检测:
|
||||
|
||||
- **Windows**: 查询注册表 `HKCU\Software\Classes\secscore` 是否存在,检查 `URL Protocol` 值和 `shell\open\command` 中的路径是否指向当前 exe
|
||||
|
||||
- **macOS**:
|
||||
1. 先尝试通过 `lsregister -dump` 查询 `secscore://` scheme 的注册状态
|
||||
2. 检查 `~/Library/LaunchAgents/com.secscore.url-handler.plist` 是否存在(非 .app bundle 场景)
|
||||
|
||||
- **Linux**:
|
||||
1. 检查 `~/.local/share/applications/secscore.desktop` 是否存在且包含 `x-scheme-handler/secscore`
|
||||
2. 通过 `xdg-settings get default-url-scheme-handler secscore` 验证是否为默认处理器
|
||||
|
||||
返回 `UrlProtocolStatus` 结构体,包含注册状态、当前平台、检测详情。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 2:Rust 后端 - 新增 `unregister_url_protocol` 命令
|
||||
|
||||
**文件**: `src-tauri/src/commands/app.rs`
|
||||
|
||||
按平台实现取消注册:
|
||||
|
||||
- **Windows**: 删除注册表 `HKCU\Software\Classes\secscore` 键 (`reg delete "HKCU\Software\Classes\secscore" /f`)
|
||||
|
||||
- **macOS**:
|
||||
1. 如果存在 .app bundle,通过 `lsregister -u` 取消注册
|
||||
2. 删除 `~/Library/LaunchAgents/com.secscore.url-handler.plist`
|
||||
|
||||
- **Linux**:
|
||||
1. 删除 `~/.local/share/applications/secscore.desktop`
|
||||
2. 运行 `update-desktop-database`
|
||||
|
||||
---
|
||||
|
||||
## 步骤 3:Rust 后端 - 增强 `register_url_protocol` 命令
|
||||
|
||||
**文件**: `src-tauri/src/commands/app.rs`
|
||||
|
||||
改造现有 `register_url_protocol`:
|
||||
|
||||
1. 执行注册操作后,不再盲目返回 `registered: Some(true)`
|
||||
2. 注册完成后,自动调用状态检测逻辑验证注册是否成功
|
||||
3. 返回实际检测到的注册状态(`registered` 字段反映真实结果)
|
||||
4. 在每个关键步骤添加 `eprintln!` 日志输出(Rust 端日志)
|
||||
|
||||
---
|
||||
|
||||
## 步骤 4:Rust 后端 - 注册新命令
|
||||
|
||||
**文件**: `src-tauri/src/lib.rs`
|
||||
|
||||
在 `invoke_handler` 中添加:
|
||||
|
||||
- `check_url_protocol_status`
|
||||
|
||||
- `unregister_url_protocol`
|
||||
|
||||
---
|
||||
|
||||
## 步骤 5:前端 API 类型定义
|
||||
|
||||
**文件**: `src/preload/types.ts`
|
||||
|
||||
新增 API 方法:
|
||||
|
||||
```typescript
|
||||
checkUrlProtocolStatus: (): Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
registered: boolean
|
||||
protocol: string
|
||||
platform: string
|
||||
details: string
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("check_url_protocol_status"),
|
||||
|
||||
unregisterUrlProtocol: (): Promise<{
|
||||
success: boolean
|
||||
data?: { registered: boolean }
|
||||
message?: string
|
||||
}> => invoke("unregister_url_protocol"),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 6:前端 Settings.tsx - 重写 URL 协议卡片 UI
|
||||
|
||||
**文件**: `src/components/Settings.tsx`
|
||||
|
||||
### 6.1 新增状态变量
|
||||
|
||||
```typescript
|
||||
const [urlStatus, setUrlStatus] = useState<{ registered: boolean; details: string } | null>(null)
|
||||
const [urlStatusLoading, setUrlStatusLoading] = useState(false)
|
||||
const [urlOperationLogs, setUrlOperationLogs] = useState<string[]>([])
|
||||
```
|
||||
|
||||
### 6.2 状态检测逻辑
|
||||
|
||||
- 组件加载 URL tab 时自动执行一次 `checkUrlProtocolStatus`
|
||||
|
||||
- 在 UI 中显示当前注册状态(Tag: 已注册/未注册 + 详情文本)
|
||||
|
||||
### 6.3 注册流程(含自动重试)
|
||||
|
||||
```
|
||||
handleRegister():
|
||||
1. 记录日志:"开始注册 URL 协议..."
|
||||
2. 调用 registerUrlProtocol()
|
||||
3. 记录日志:"注册命令已执行,开始验证..."
|
||||
4. 调用 checkUrlProtocolStatus() 验证
|
||||
5. IF 注册成功:
|
||||
- 记录日志:"注册验证成功"
|
||||
- 更新状态显示
|
||||
- 完成
|
||||
6. ELSE (注册失败):
|
||||
- FOR retry = 1 TO 7:
|
||||
- 记录日志:"注册失败,第 {retry}/7 次重试..."
|
||||
- 延迟 retry * 1000 ms (递增延迟: 1s, 2s, 3s, ..., 7s)
|
||||
- 调用 registerUrlProtocol()
|
||||
- 调用 checkUrlProtocolStatus() 验证
|
||||
- IF 成功:
|
||||
- 记录日志:"第 {retry} 次重试成功"
|
||||
- 更新状态,退出循环
|
||||
- ELSE:
|
||||
- 记录日志:"第 {retry} 次重试失败"
|
||||
- IF 7次全部失败:
|
||||
- 记录日志:"已达最大重试次数(7次),注册失败"
|
||||
- 显示错误消息
|
||||
```
|
||||
|
||||
### 6.4 按钮状态自动切换
|
||||
|
||||
根据 `urlStatus.registered` 动态渲染:
|
||||
|
||||
| 状态 | 按钮文本 | 按钮类型 | 点击行为 |
|
||||
| ------ | --------------- | -------- | --------------------------------- |
|
||||
| 未注册 | "注册 URL 协议" | primary | 执行注册(含重试) |
|
||||
| 已注册 | "重新注册" | default | 执行注册(覆盖注册+验证) |
|
||||
| 已注册 | "取消注册" | danger | 执行 unregisterUrlProtocol + 验证 |
|
||||
|
||||
### 6.5 操作日志展示
|
||||
|
||||
在 URL 协议卡片底部增加一个可折叠的 `<Collapse>` 或简洁的 `<Typography.Paragraph>` 区域,展示最近的操作日志(最多保留最新 20 条),使用 `fontSize: 12px, fontFamily: monospace` 样式。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 7:i18n 翻译
|
||||
|
||||
**文件**: `src/i18n/locales/` 下所有 10 个语言文件
|
||||
|
||||
在 `settings.url` 下新增以下键:
|
||||
|
||||
| 键 | zh-CN | en-US |
|
||||
| ---------------------- | ----------------------------- | --------------------------------------------- |
|
||||
| `checkStatus` | "检测状态" | "Check Status" |
|
||||
| `reRegister` | "重新注册" | "Re-register" |
|
||||
| `unregister` | "取消注册" | "Unregister" |
|
||||
| `unregistered` | "URL 协议已取消注册" | "URL protocol unregistered" |
|
||||
| `unregisterFailed` | "取消注册失败" | "Failed to unregister" |
|
||||
| `statusRegistered` | "已注册" | "Registered" |
|
||||
| `statusNotRegistered` | "未注册" | "Not Registered" |
|
||||
| `statusCheckFailed` | "状态检测失败" | "Status check failed" |
|
||||
| `retrying` | "重试中 ({current}/{max})" | "Retrying ({current}/{max})" |
|
||||
| `retryFailed` | "已达最大重试次数,注册失败" | "Max retries reached, registration failed" |
|
||||
| `retrySuccess` | "第 {n} 次重试成功" | "Retry #{n} succeeded" |
|
||||
| `operationLog` | "操作日志" | "Operation Log" |
|
||||
| `logRegisterStart` | "开始注册 URL 协议..." | "Starting URL protocol registration..." |
|
||||
| `logRegisterVerify` | "注册命令已执行,正在验证..." | "Registration command executed, verifying..." |
|
||||
| `logRegisterSuccess` | "注册验证成功" | "Registration verified successfully" |
|
||||
| `logRegisterFailed` | "注册验证失败" | "Registration verification failed" |
|
||||
| `logUnregisterStart` | "正在取消注册..." | "Unregistering..." |
|
||||
| `logUnregisterSuccess` | "取消注册成功" | "Unregistered successfully" |
|
||||
| `logUnregisterFailed` | "取消注册失败" | "Unregister failed" |
|
||||
| `logMaxRetries` | "已达最大重试次数 (7次)" | "Max retries (7) reached" |
|
||||
|
||||
其余 9 个语言文件同步翻译。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 8:运行测试与格式化
|
||||
|
||||
1. 执行 `pnpm run typecheck`(如有)检查前端类型
|
||||
2. 执行 `cargo check` 检查 Rust 编译
|
||||
3. 执行格式化命令
|
||||
4. 运行测试套件
|
||||
|
||||
---
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. ✅ 步骤 1:Rust - `check_url_protocol_status`
|
||||
2. ✅ 步骤 2:Rust - `unregister_url_protocol`
|
||||
3. ✅ 步骤 3:Rust - 增强 `register_url_protocol`
|
||||
4. ✅ 步骤 4:Rust - 注册新命令到 lib.rs
|
||||
5. ✅ 步骤 5:前端 API 类型
|
||||
6. ✅ 步骤 6:前端 Settings.tsx UI
|
||||
7. ✅ 步骤 7:i18n 翻译
|
||||
8. ✅ 步骤 8:测试与格式化
|
||||
File diff suppressed because one or more lines are too long
Generated
+2
@@ -4221,6 +4221,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"dirs",
|
||||
"hex",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
@@ -4242,6 +4243,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"windows-sys 0.59.0",
|
||||
"winreg 0.52.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ urlencoding = "2.1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
+683
-22
@@ -13,13 +13,474 @@ pub struct RegisterUrlProtocolResult {
|
||||
pub registered: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UrlProtocolStatus {
|
||||
pub registered: bool,
|
||||
pub protocol: String,
|
||||
pub platform: String,
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ElevationStatus {
|
||||
pub is_elevated: bool,
|
||||
pub platform: String,
|
||||
pub can_request_elevation: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_url_protocol_status(
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<UrlProtocolStatus>, String> {
|
||||
let protocol = "secscore";
|
||||
eprintln!("[URL Protocol] Checking status for protocol: {}", protocol);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::process::Command;
|
||||
|
||||
let ps_check = format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$key = Get-Item 'HKCU:\Software\Classes\{protocol}'
|
||||
if (-not $key) {{
|
||||
Write-Output 'NOT_FOUND'
|
||||
exit 0
|
||||
}}
|
||||
$urlProtocol = Get-ItemProperty -Path 'HKCU:\Software\Classes\{protocol}' -Name 'URL Protocol'
|
||||
$command = Get-ItemProperty -Path 'HKCU:\Software\Classes\{protocol}\shell\open\command' -Name '(Default)'
|
||||
if ($urlProtocol -and $command) {{
|
||||
Write-Output 'REGISTERED'
|
||||
Write-Output ("CMD:" + $command.'(Default)')
|
||||
}} else {{
|
||||
Write-Output 'PARTIAL'
|
||||
Write-Output ("URL_PROTOCOL:" + [string]($null -ne $urlProtocol))
|
||||
Write-Output ("COMMAND:" + [string]($null -ne $command))
|
||||
}}
|
||||
"#,
|
||||
protocol = protocol
|
||||
);
|
||||
|
||||
let output = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_check])
|
||||
.output();
|
||||
|
||||
let (registered, details) = match output {
|
||||
Ok(o) => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout).to_string();
|
||||
eprintln!("[URL Protocol] Windows PowerShell check output: {}", stdout.trim());
|
||||
|
||||
if stdout.contains("REGISTERED") {
|
||||
(true, "URL protocol fully registered in Windows Registry".to_string())
|
||||
} else if stdout.contains("PARTIAL") {
|
||||
(false, format!("Partial registration: {}", stdout.trim()))
|
||||
} else {
|
||||
(false, "Registry key HKCU\\Software\\Classes\\secscore not found".to_string())
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[URL Protocol] Windows PowerShell check error: {}", e);
|
||||
(false, format!("Failed to check registry: {}", e))
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("[URL Protocol] Windows check result: registered={}, {}", registered, details);
|
||||
|
||||
Ok(IpcResponse::success(UrlProtocolStatus {
|
||||
registered,
|
||||
protocol: protocol.to_string(),
|
||||
platform: "windows".to_string(),
|
||||
details,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut registered = false;
|
||||
let mut details = String::new();
|
||||
|
||||
let exe_path = std::env::current_exe().unwrap_or_default();
|
||||
let app_bundle = find_app_bundle(&exe_path);
|
||||
|
||||
if app_bundle.is_some() {
|
||||
let lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
||||
let output = std::process::Command::new(lsregister)
|
||||
.args(["-dump"])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(o) => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
if stdout.contains("secscore") {
|
||||
registered = true;
|
||||
details = "secscore:// registered via Launch Services (app bundle)".to_string();
|
||||
} else {
|
||||
details = "secscore:// not found in Launch Services dump".to_string();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
details = format!("Failed to query Launch Services: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !registered {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let plist_path = home.join("Library/LaunchAgents/com.secscore.url-handler.plist");
|
||||
if plist_path.exists() {
|
||||
registered = true;
|
||||
details = "URL handler plist found at ~/Library/LaunchAgents".to_string();
|
||||
} else if details.is_empty() {
|
||||
details = "No app bundle or plist found for URL protocol".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[URL Protocol] macOS check result: registered={}, {}", registered, details);
|
||||
|
||||
Ok(IpcResponse::success(UrlProtocolStatus {
|
||||
registered,
|
||||
protocol: protocol.to_string(),
|
||||
platform: "macos".to_string(),
|
||||
details,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let desktop_path = home.join(".local/share/applications/secscore.desktop");
|
||||
let mut registered = false;
|
||||
let mut details = String::new();
|
||||
|
||||
if desktop_path.exists() {
|
||||
match std::fs::read_to_string(&desktop_path) {
|
||||
Ok(content) => {
|
||||
if content.contains("x-scheme-handler/secscore") {
|
||||
let xdg_output = std::process::Command::new("xdg-settings")
|
||||
.args(["get", "default-url-scheme-handler", "secscore"])
|
||||
.output();
|
||||
|
||||
match xdg_output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let handler = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if handler == "secscore.desktop" {
|
||||
registered = true;
|
||||
details = "secscore.desktop is default URL scheme handler".to_string();
|
||||
} else {
|
||||
registered = true;
|
||||
details = format!(
|
||||
"Desktop file exists with MimeType, but default handler is: {}",
|
||||
handler
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
registered = true;
|
||||
details = "Desktop file exists with MimeType (xdg-settings query failed)".to_string();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
details = "Desktop file exists but missing x-scheme-handler/secscore MimeType".to_string();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
details = format!("Failed to read desktop file: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
details = "secscore.desktop not found in ~/.local/share/applications".to_string();
|
||||
}
|
||||
|
||||
eprintln!("[URL Protocol] Linux check result: registered={}, {}", registered, details);
|
||||
|
||||
Ok(IpcResponse::success(UrlProtocolStatus {
|
||||
registered,
|
||||
protocol: protocol.to_string(),
|
||||
platform: "linux".to_string(),
|
||||
details,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
Ok(IpcResponse::success(UrlProtocolStatus {
|
||||
registered: false,
|
||||
protocol: "secscore".to_string(),
|
||||
platform: "unknown".to_string(),
|
||||
details: "URL protocol check not supported on this platform".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unregister_url_protocol(
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<RegisterUrlProtocolResult>, String> {
|
||||
let protocol = "secscore";
|
||||
eprintln!("[URL Protocol] Unregistering protocol: {}", protocol);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::process::Command;
|
||||
|
||||
let ps_script = format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Remove-Item -Path 'HKCU:\Software\Classes\{protocol}' -Recurse -Force
|
||||
if (-not (Test-Path 'HKCU:\Software\Classes\{protocol}')) {{
|
||||
Write-Output 'SUCCESS'
|
||||
}} else {{
|
||||
Write-Output 'FAILED'
|
||||
}}
|
||||
"#,
|
||||
protocol = protocol
|
||||
);
|
||||
|
||||
let output = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_script])
|
||||
.output();
|
||||
|
||||
let success = match output {
|
||||
Ok(o) => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
let ok = o.status.success() && stdout.contains("SUCCESS");
|
||||
eprintln!("[URL Protocol] Windows PowerShell unregister result: {}", ok);
|
||||
ok
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[URL Protocol] Windows PowerShell unregister error: {}", e);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(IpcResponse::success(RegisterUrlProtocolResult {
|
||||
registered: Some(!success),
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let exe_path = std::env::current_exe().unwrap_or_default();
|
||||
let mut unregistered = true;
|
||||
|
||||
if let Some(bundle_path) = find_app_bundle(&exe_path) {
|
||||
let lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
||||
let output = std::process::Command::new(lsregister)
|
||||
.args(["-u", &bundle_path.to_string_lossy()])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(o) => {
|
||||
eprintln!("[URL Protocol] macOS lsregister -u result: {}", o.status.success());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[URL Protocol] macOS lsregister -u error: {}", e);
|
||||
unregistered = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let plist_path = home.join("Library/LaunchAgents/com.secscore.url-handler.plist");
|
||||
if plist_path.exists() {
|
||||
match std::fs::remove_file(&plist_path) {
|
||||
Ok(_) => {
|
||||
eprintln!("[URL Protocol] macOS plist removed: {:?}", plist_path);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[URL Protocol] macOS plist remove error: {}", e);
|
||||
unregistered = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(IpcResponse::success(RegisterUrlProtocolResult {
|
||||
registered: Some(!unregistered),
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::fs;
|
||||
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let desktop_path = home.join(".local/share/applications/secscore.desktop");
|
||||
let mut unregistered = true;
|
||||
|
||||
if desktop_path.exists() {
|
||||
match fs::remove_file(&desktop_path) {
|
||||
Ok(_) => {
|
||||
eprintln!("[URL Protocol] Linux desktop file removed: {:?}", desktop_path);
|
||||
let apps_dir = home.join(".local/share/applications");
|
||||
let _ = std::process::Command::new("update-desktop-database")
|
||||
.arg(apps_dir.to_string_lossy().to_string())
|
||||
.status();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[URL Protocol] Linux desktop file remove error: {}", e);
|
||||
unregistered = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(IpcResponse::success(RegisterUrlProtocolResult {
|
||||
registered: Some(!unregistered),
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
Ok(IpcResponse::error(
|
||||
"URL protocol unregistration is not supported on this platform",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_elevation(
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<ElevationStatus>, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let is_elevated = check_windows_elevation();
|
||||
return Ok(IpcResponse::success(ElevationStatus {
|
||||
is_elevated,
|
||||
platform: "windows".to_string(),
|
||||
can_request_elevation: true,
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let is_elevated = unsafe { libc::geteuid() == 0 };
|
||||
return Ok(IpcResponse::success(ElevationStatus {
|
||||
is_elevated,
|
||||
platform: "macos".to_string(),
|
||||
can_request_elevation: true,
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let is_elevated = unsafe { libc::geteuid() == 0 };
|
||||
return Ok(IpcResponse::success(ElevationStatus {
|
||||
is_elevated,
|
||||
platform: "linux".to_string(),
|
||||
can_request_elevation: true,
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
Ok(IpcResponse::success(ElevationStatus {
|
||||
is_elevated: false,
|
||||
platform: "unknown".to_string(),
|
||||
can_request_elevation: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn request_elevation(
|
||||
app: AppHandle,
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let exe_path =
|
||||
std::env::current_exe().map_err(|e| format!("Failed to get exe path: {}", e))?;
|
||||
let exe_str = exe_path.to_string_lossy().to_string();
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::UI::Shell::ShellExecuteW;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW;
|
||||
|
||||
let verb: Vec<u16> = OsStr::new("runas\0").encode_wide().collect();
|
||||
let file: Vec<u16> = OsStr::new(&format!("{}\0", exe_str)).encode_wide().collect();
|
||||
let params: Vec<u16> = OsStr::new("\0").encode_wide().collect();
|
||||
|
||||
let result = unsafe {
|
||||
ShellExecuteW(
|
||||
0 as _,
|
||||
verb.as_ptr(),
|
||||
file.as_ptr(),
|
||||
params.as_ptr(),
|
||||
std::ptr::null(),
|
||||
SW_SHOW,
|
||||
)
|
||||
};
|
||||
|
||||
if result as usize > 32 {
|
||||
app.exit(0);
|
||||
Ok(IpcResponse::success(()))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Failed to request elevation (ShellExecuteW returned {})",
|
||||
result as usize
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let exe_path =
|
||||
std::env::current_exe().map_err(|e| format!("Failed to get exe path: {}", e))?;
|
||||
let exe_str = exe_path.to_string_lossy().to_string();
|
||||
|
||||
let script = format!(
|
||||
"do shell script \"\\\"{}\\\"\" with administrator privileges",
|
||||
exe_str
|
||||
);
|
||||
|
||||
let status = std::process::Command::new("osascript")
|
||||
.args(["-e", &script])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
app.exit(0);
|
||||
Ok(IpcResponse::success(()))
|
||||
}
|
||||
Ok(s) => Err(format!("Elevation cancelled or failed (exit code: {:?})", s.code())),
|
||||
Err(e) => Err(format!("Failed to request elevation: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let exe_path =
|
||||
std::env::current_exe().map_err(|e| format!("Failed to get exe path: {}", e))?;
|
||||
let exe_str = exe_path.to_string_lossy().to_string();
|
||||
|
||||
let status = std::process::Command::new("pkexec")
|
||||
.arg(&exe_str)
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
app.exit(0);
|
||||
Ok(IpcResponse::success(()))
|
||||
}
|
||||
Ok(s) => Err(format!("Elevation cancelled or failed (exit code: {:?})", s.code())),
|
||||
Err(e) => Err(format!("Failed to request elevation: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let _ = app;
|
||||
Err("Elevation is not supported on this platform".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_url_protocol(
|
||||
app: AppHandle,
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<RegisterUrlProtocolResult>, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let protocol = "secscore";
|
||||
eprintln!("[URL Protocol] Starting registration for protocol: {}", protocol);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -27,50 +488,222 @@ pub async fn register_url_protocol(
|
||||
|
||||
let exe_path =
|
||||
std::env::current_exe().map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
let exe_path_str = exe_path.to_string_lossy();
|
||||
|
||||
let reg_command = format!(
|
||||
r#"reg add "HKCU\Software\Classes\{}" /ve /d "URL:SecScore Protocol" /f"#,
|
||||
protocol
|
||||
eprintln!("[URL Protocol] Windows: Exe path = {}", exe_path_str);
|
||||
|
||||
let ps_script = format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try {{
|
||||
New-Item -Path 'HKCU:\Software\Classes\{protocol}' -Force | Out-Null
|
||||
Set-ItemProperty -Path 'HKCU:\Software\Classes\{protocol}' -Name '(Default)' -Value 'URL:SecScore Protocol' -Force
|
||||
Set-ItemProperty -Path 'HKCU:\Software\Classes\{protocol}' -Name 'URL Protocol' -Value '' -Force
|
||||
New-Item -Path 'HKCU:\Software\Classes\{protocol}\DefaultIcon' -Force | Out-Null
|
||||
Set-ItemProperty -Path 'HKCU:\Software\Classes\{protocol}\DefaultIcon' -Name '(Default)' -Value '{exe},1' -Force
|
||||
New-Item -Path 'HKCU:\Software\Classes\{protocol}\shell' -Force | Out-Null
|
||||
New-Item -Path 'HKCU:\Software\Classes\{protocol}\shell\open' -Force | Out-Null
|
||||
New-Item -Path 'HKCU:\Software\Classes\{protocol}\shell\open\command' -Force | Out-Null
|
||||
Set-ItemProperty -Path 'HKCU:\Software\Classes\{protocol}\shell\open\command' -Name '(Default)' -Value '"{exe}" "%1"' -Force
|
||||
Write-Output 'SUCCESS'
|
||||
}} catch {{
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}}
|
||||
"#,
|
||||
protocol = protocol,
|
||||
exe = exe_path_str
|
||||
);
|
||||
|
||||
let reg_command2 = format!(
|
||||
r#"reg add "HKCU\Software\Classes\{}\DefaultIcon" /ve /d "{},1" /f"#,
|
||||
protocol, exe_path_str
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_script])
|
||||
.output();
|
||||
|
||||
let reg_command3 = format!(
|
||||
r#"reg add "HKCU\Software\Classes\{}\shell\open\command" /ve /d "\"{}\" \"%%1\"" /f"#,
|
||||
protocol, exe_path_str
|
||||
);
|
||||
let mut registered = false;
|
||||
match output {
|
||||
Ok(o) => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
let success = o.status.success() && stdout.contains("SUCCESS");
|
||||
eprintln!("[URL Protocol] Windows PowerShell result: success={}, stdout={}, stderr={}", success, stdout.trim(), stderr.trim());
|
||||
if success {
|
||||
let query_cmd = format!(
|
||||
r#"reg query "HKCU\Software\Classes\{}\shell\open\command" /ve"#,
|
||||
protocol
|
||||
);
|
||||
let verify = Command::new("cmd").args(["/C", &query_cmd]).output();
|
||||
registered = verify.map(|v| v.status.success()).unwrap_or(false);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[URL Protocol] Windows PowerShell error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = Command::new("cmd").args(["/C", ®_command]).output();
|
||||
|
||||
let _ = Command::new("cmd").args(["/C", ®_command2]).output();
|
||||
|
||||
let _ = Command::new("cmd").args(["/C", ®_command3]).output();
|
||||
eprintln!("[URL Protocol] Windows registration verification: {}", registered);
|
||||
|
||||
let _ = app;
|
||||
|
||||
return Ok(IpcResponse::success(RegisterUrlProtocolResult {
|
||||
registered: Some(true),
|
||||
registered: Some(registered),
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
let exe_path =
|
||||
std::env::current_exe().map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
let app_bundle = find_app_bundle(&exe_path);
|
||||
let mut registered = false;
|
||||
|
||||
if let Some(bundle_path) = app_bundle {
|
||||
eprintln!("[URL Protocol] macOS: Found app bundle at {:?}", bundle_path);
|
||||
let output = Command::new(
|
||||
"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
|
||||
)
|
||||
.args(["-f", &bundle_path.to_string_lossy()])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
eprintln!("[URL Protocol] macOS: lsregister -f succeeded");
|
||||
registered = true;
|
||||
}
|
||||
Ok(o) => {
|
||||
eprintln!("[URL Protocol] macOS: lsregister -f failed (exit: {:?}), trying -R -f", o.status.code());
|
||||
let _ = Command::new("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister")
|
||||
.args(["-R", "-f", &bundle_path.to_string_lossy()])
|
||||
.output();
|
||||
registered = true;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[URL Protocol] macOS: lsregister error: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("[URL Protocol] macOS: No app bundle found, using plist fallback");
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let plist_dir = home.join("Library/LaunchAgents");
|
||||
let _ = fs::create_dir_all(&plist_dir);
|
||||
|
||||
let plist_path = plist_dir.join("com.secscore.url-handler.plist");
|
||||
let plist_content = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.secscore.url-handler</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{}</string>
|
||||
</array>
|
||||
<key>Sockets</key>
|
||||
<dict>
|
||||
<key>secscore</key>
|
||||
<dict>
|
||||
<key>SockServiceName</key>
|
||||
<string>secscore</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>"#,
|
||||
exe_path.to_string_lossy()
|
||||
);
|
||||
|
||||
match fs::write(&plist_path, plist_content) {
|
||||
Ok(_) => {
|
||||
eprintln!("[URL Protocol] macOS: Plist written to {:?}", plist_path);
|
||||
registered = plist_path.exists();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[URL Protocol] macOS: Plist write error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app;
|
||||
return Ok(IpcResponse::success(RegisterUrlProtocolResult {
|
||||
registered: Some(false),
|
||||
registered: Some(registered),
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::fs;
|
||||
|
||||
let exe_path =
|
||||
std::env::current_exe().map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
let exe_str = exe_path.to_string_lossy();
|
||||
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let apps_dir = home.join(".local/share/applications");
|
||||
let _ = fs::create_dir_all(&apps_dir);
|
||||
|
||||
let desktop_path = apps_dir.join("secscore.desktop");
|
||||
let desktop_content = format!(
|
||||
r#"[Desktop Entry]
|
||||
Name=SecScore
|
||||
Comment=Security Score Application
|
||||
Exec={} %u
|
||||
Icon=secscore
|
||||
Type=Application
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
MimeType=x-scheme-handler/secscore;
|
||||
"#,
|
||||
exe_str
|
||||
);
|
||||
|
||||
match fs::write(&desktop_path, &desktop_content) {
|
||||
Ok(_) => {
|
||||
eprintln!("[URL Protocol] Linux: Desktop file written to {:?}", desktop_path);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[URL Protocol] Linux: Desktop file write error: {}", e);
|
||||
let _ = app;
|
||||
return Ok(IpcResponse::success(RegisterUrlProtocolResult {
|
||||
registered: Some(false),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let update_result = std::process::Command::new("update-desktop-database")
|
||||
.arg(apps_dir.to_string_lossy().to_string())
|
||||
.status();
|
||||
eprintln!("[URL Protocol] Linux: update-desktop-database result: {:?}", update_result);
|
||||
|
||||
let xdg_set_result = std::process::Command::new("xdg-settings")
|
||||
.args([
|
||||
"set",
|
||||
"default-url-scheme-handler",
|
||||
"secscore",
|
||||
"secscore.desktop",
|
||||
])
|
||||
.status();
|
||||
eprintln!("[URL Protocol] Linux: xdg-settings set result: {:?}", xdg_set_result);
|
||||
|
||||
let xdg_mime_result = std::process::Command::new("xdg-mime")
|
||||
.args([
|
||||
"default",
|
||||
"secscore.desktop",
|
||||
"x-scheme-handler/secscore",
|
||||
])
|
||||
.status();
|
||||
eprintln!("[URL Protocol] Linux: xdg-mime default result: {:?}", xdg_mime_result);
|
||||
|
||||
let verify_exists = desktop_path.exists();
|
||||
let verify_content = fs::read_to_string(&desktop_path).unwrap_or_default();
|
||||
let actually_registered = verify_exists && verify_content.contains("x-scheme-handler/secscore");
|
||||
|
||||
eprintln!("[URL Protocol] Linux registration verification: {}", actually_registered);
|
||||
|
||||
let _ = app;
|
||||
return Ok(IpcResponse::success(RegisterUrlProtocolResult {
|
||||
registered: Some(false),
|
||||
registered: Some(actually_registered),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -99,3 +732,31 @@ pub async fn app_restart(
|
||||
) -> Result<(), String> {
|
||||
app.restart();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_windows_elevation() -> bool {
|
||||
use std::process::Command;
|
||||
|
||||
Command::new("net")
|
||||
.args(["session"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn find_app_bundle(exe_path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
let mut current = exe_path.parent()?;
|
||||
loop {
|
||||
if current.extension().map_or(false, |e| e == "app") {
|
||||
return Some(current.to_path_buf());
|
||||
}
|
||||
if !current.parent().map_or(false, |p| p != current) {
|
||||
break;
|
||||
}
|
||||
current = current.parent()?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ pub struct SetPasswordsResponse {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
pub platform_id: String,
|
||||
pub platform_secret: String,
|
||||
pub callback_url: String,
|
||||
}
|
||||
|
||||
@@ -463,7 +462,6 @@ pub async fn oauth_get_authorization_url(
|
||||
pub async fn oauth_exchange_code(
|
||||
code: String,
|
||||
platform_id: String,
|
||||
platform_secret: String,
|
||||
callback_url: String,
|
||||
code_verifier: String,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
@@ -473,19 +471,16 @@ pub async fn oauth_exchange_code(
|
||||
code, platform_id, callback_url
|
||||
);
|
||||
|
||||
// 获取设备 UUID 和 IP 地址
|
||||
let device_uuid = get_or_create_device_uuid();
|
||||
let ip_address = get_local_ip().await.unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
|
||||
println!("[OAuth] 设备 UUID: {}, IP: {}", device_uuid, ip_address);
|
||||
|
||||
let state_guard = state.read();
|
||||
let client = &state_guard.http_client;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"grant_type": "authorization_code",
|
||||
"code": &code,
|
||||
"client_id": &platform_id,
|
||||
"client_secret": &platform_secret,
|
||||
"redirect_uri": &callback_url,
|
||||
"code_verifier": &code_verifier,
|
||||
"device_uuid": &device_uuid,
|
||||
@@ -537,7 +532,6 @@ pub async fn oauth_revoke_token(
|
||||
token: String,
|
||||
token_type_hint: Option<String>,
|
||||
platform_id: String,
|
||||
platform_secret: String,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
let state_guard = state.read();
|
||||
@@ -546,7 +540,6 @@ pub async fn oauth_revoke_token(
|
||||
let mut payload = serde_json::json!({
|
||||
"token": token,
|
||||
"client_id": platform_id,
|
||||
"client_secret": platform_secret
|
||||
});
|
||||
|
||||
if let Some(hint) = token_type_hint {
|
||||
@@ -575,7 +568,6 @@ pub async fn oauth_revoke_token(
|
||||
pub async fn oauth_introspect_token(
|
||||
token: String,
|
||||
platform_id: String,
|
||||
platform_secret: String,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<OAuthIntrospectResponse>, String> {
|
||||
let state_guard = state.read();
|
||||
@@ -586,7 +578,6 @@ pub async fn oauth_introspect_token(
|
||||
.json(&serde_json::json!({
|
||||
"token": token,
|
||||
"client_id": platform_id,
|
||||
"client_secret": platform_secret
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -667,7 +658,6 @@ pub async fn oauth_get_user_info(
|
||||
pub async fn oauth_refresh_token(
|
||||
refresh_token: String,
|
||||
platform_id: String,
|
||||
platform_secret: String,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
|
||||
let state_guard = state.read();
|
||||
@@ -679,7 +669,6 @@ pub async fn oauth_refresh_token(
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": platform_id,
|
||||
"client_secret": platform_secret
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -901,7 +890,6 @@ pub async fn oauth_clear_login_state() -> Result<IpcResponse<()>, String> {
|
||||
pub async fn oauth_refresh_access_token(
|
||||
refresh_token: String,
|
||||
platform_id: String,
|
||||
platform_secret: String,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
|
||||
println!("[OAuth] 刷新 access_token");
|
||||
@@ -916,7 +904,6 @@ pub async fn oauth_refresh_access_token(
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": platform_id,
|
||||
"client_secret": platform_secret,
|
||||
"device_uuid": device_uuid,
|
||||
"ip_address": ip_address,
|
||||
});
|
||||
|
||||
@@ -153,6 +153,10 @@ pub fn run() {
|
||||
mcp_server_stop,
|
||||
mcp_server_status,
|
||||
register_url_protocol,
|
||||
check_url_protocol_status,
|
||||
unregister_url_protocol,
|
||||
check_elevation,
|
||||
request_elevation,
|
||||
app_quit,
|
||||
app_restart,
|
||||
])
|
||||
|
||||
+23
@@ -36,6 +36,8 @@ import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
||||
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
|
||||
import { resolveStoredFontFamily } from "./shared/fontFamily"
|
||||
import { getPluginRuntime } from "./plugins/runtime"
|
||||
import { sectlCloudSync } from "./services/sectlCloudSync"
|
||||
import { sectlAuth } from "./services/sectlAuth"
|
||||
|
||||
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(
|
||||
@@ -389,6 +391,27 @@ function MainContent(): React.JSX.Element {
|
||||
if (permission !== "admin") return
|
||||
try {
|
||||
syncCheckingRef.current = true
|
||||
|
||||
const settingsRes = await api.getAllSettings()
|
||||
const syncMethod = settingsRes?.success ? settingsRes.data?.sync_method : null
|
||||
|
||||
if (syncMethod === "sectl_cloud") {
|
||||
if (!sectlAuth.isAuthenticated()) return
|
||||
const status = sectlCloudSync.getStatus()
|
||||
if (!status.is_configured || status.is_syncing) return
|
||||
|
||||
const now = Date.now()
|
||||
const recentLocal = now - lastLocalMutationAtRef.current < 15000
|
||||
const direction = recentLocal ? "push" : "bidirectional"
|
||||
const result = await sectlCloudSync.fullSync(direction)
|
||||
if (result.success) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } })
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const statusRes = await api.dbGetStatus()
|
||||
if (
|
||||
!statusRes?.success ||
|
||||
|
||||
@@ -630,9 +630,9 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
||||
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName) || ""
|
||||
: ""
|
||||
})()
|
||||
: draft.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||
: draft.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||
} satisfies ActionDraft
|
||||
})
|
||||
.filter((item): item is ActionDraft => Boolean(item))
|
||||
@@ -664,9 +664,9 @@ export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
|
||||
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName) || ""
|
||||
: ""
|
||||
})()
|
||||
: action.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(action.value),
|
||||
: action.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(action.value),
|
||||
} satisfies ActionDraft
|
||||
})
|
||||
.filter((item): item is ActionDraft => Boolean(item))
|
||||
|
||||
@@ -686,4 +686,3 @@ export function ContentArea({
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ interface OAuthLoginProps {
|
||||
|
||||
interface OAuthConfig {
|
||||
platform_id: string
|
||||
platform_secret: string
|
||||
}
|
||||
|
||||
interface OAuthCallbackResult {
|
||||
@@ -56,15 +55,13 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
if (!api) return null
|
||||
|
||||
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
|
||||
const platformSecret = import.meta.env.VITE_OAUTH_PLATFORM_SECRET
|
||||
|
||||
if (!platformId || !platformSecret) {
|
||||
if (!platformId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
platform_id: platformId,
|
||||
platform_secret: platformSecret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +135,6 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
const tokenRes = await api.oauthExchangeCode(
|
||||
result.code,
|
||||
config.platform_id,
|
||||
config.platform_secret,
|
||||
callbackUrl,
|
||||
codeVerifier
|
||||
)
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Table,
|
||||
Progress,
|
||||
message,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Modal,
|
||||
Radio,
|
||||
Divider,
|
||||
Alert,
|
||||
} from "antd"
|
||||
import {
|
||||
CloudSyncOutlined,
|
||||
CloudUploadOutlined,
|
||||
CloudDownloadOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSectl } from "../contexts/SectlContext"
|
||||
import { sectlCloudSync, CloudSyncResult, CloudSyncStatus } from "../services/sectlCloudSync"
|
||||
import { sectlAuth } from "../services/sectlAuth"
|
||||
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
students: "学生",
|
||||
reasons: "理由",
|
||||
score_events: "积分事件",
|
||||
tags: "标签",
|
||||
student_tags: "学生标签",
|
||||
reward_settings: "奖励设置",
|
||||
reward_redemptions: "奖励兑换",
|
||||
}
|
||||
|
||||
interface StorageStats {
|
||||
used: number
|
||||
total: number
|
||||
percentage: number
|
||||
table_stats: Array<{ table: string; count: number; size: number }>
|
||||
}
|
||||
|
||||
export const SectlCloudSyncPanel: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isAuthenticated, isLoading: authLoading } = useSectl()
|
||||
const [syncStatus, setSyncStatus] = useState<CloudSyncStatus>(sectlCloudSync.getStatus())
|
||||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null)
|
||||
const [syncDirection, setSyncDirection] = useState<"push" | "pull" | "bidirectional">(
|
||||
"bidirectional"
|
||||
)
|
||||
const [lastResult, setLastResult] = useState<CloudSyncResult | null>(null)
|
||||
const [loadingStats, setLoadingStats] = useState(false)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const refreshStatus = useCallback(() => {
|
||||
setSyncStatus(sectlCloudSync.getStatus())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus()
|
||||
const interval = setInterval(refreshStatus, 10_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [refreshStatus])
|
||||
|
||||
const loadStorageStats = useCallback(async () => {
|
||||
if (!isAuthenticated) return
|
||||
setLoadingStats(true)
|
||||
try {
|
||||
const stats = await sectlCloudSync.getCloudStorageUsage()
|
||||
setStorageStats(stats)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingStats(false)
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) loadStorageStats()
|
||||
}, [isAuthenticated, loadStorageStats])
|
||||
|
||||
const handleSync = async (direction: "push" | "pull" | "bidirectional") => {
|
||||
if (!sectlAuth.isAuthenticated()) {
|
||||
messageApi.warning(t("settings.cloudSync.loginRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
refreshStatus()
|
||||
if (syncStatus.is_syncing) {
|
||||
messageApi.warning(t("settings.cloudSync.syncInProgress"))
|
||||
return
|
||||
}
|
||||
|
||||
const directionLabel =
|
||||
direction === "push"
|
||||
? t("settings.cloudSync.uploadToCloud")
|
||||
: direction === "pull"
|
||||
? t("settings.cloudSync.downloadFromCloud")
|
||||
: t("settings.cloudSync.fullSync")
|
||||
|
||||
Modal.confirm({
|
||||
title: directionLabel,
|
||||
content: t("settings.cloudSync.syncConfirm", { direction: directionLabel }),
|
||||
okText: t("common.confirm"),
|
||||
cancelText: t("common.cancel"),
|
||||
onOk: async () => {
|
||||
try {
|
||||
let result: CloudSyncResult
|
||||
if (direction === "push") {
|
||||
result = await sectlCloudSync.syncToCloud()
|
||||
} else if (direction === "pull") {
|
||||
result = await sectlCloudSync.syncFromCloud()
|
||||
} else {
|
||||
result = await sectlCloudSync.fullSync("bidirectional")
|
||||
}
|
||||
|
||||
setLastResult(result)
|
||||
refreshStatus()
|
||||
await loadStorageStats()
|
||||
|
||||
if (result.success) {
|
||||
messageApi.success(result.message)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ss:data-updated", { detail: { category: "all" } })
|
||||
)
|
||||
} else {
|
||||
messageApi.error(result.message)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
messageApi.error(`同步失败:${msg}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B"
|
||||
const k = 1024
|
||||
const sizes = ["B", "KB", "MB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const formatTime = (iso: string | null): string => {
|
||||
if (!iso) return t("settings.cloudSync.never")
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ textAlign: "center", padding: "40px 0" }}>
|
||||
<Spin />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Card>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<InfoCircleOutlined />}
|
||||
message={t("settings.cloudSync.loginRequired")}
|
||||
description={t("settings.cloudSync.loginHint")}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{contextHolder}
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<CloudSyncOutlined />
|
||||
<span>{t("settings.cloudSync.statusTitle")}</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Tag color={syncStatus.is_configured ? "success" : "default"}>
|
||||
{syncStatus.is_configured
|
||||
? t("settings.cloudSync.configured")
|
||||
: t("settings.cloudSync.notConfigured")}
|
||||
</Tag>
|
||||
{syncStatus.is_syncing && (
|
||||
<Tag color="processing">
|
||||
<Spin size="small" /> {t("settings.cloudSync.syncing")}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
<span style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||
{t("settings.cloudSync.lastSync")}: {formatTime(syncStatus.last_sync_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{lastResult && (
|
||||
<Alert
|
||||
type={lastResult.success ? "success" : "error"}
|
||||
showIcon
|
||||
icon={lastResult.success ? <CheckCircleOutlined /> : <ExclamationCircleOutlined />}
|
||||
message={lastResult.message}
|
||||
description={
|
||||
lastResult.tables.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{lastResult.tables
|
||||
.filter((tr) => tr.uploaded > 0 || tr.downloaded > 0 || tr.errors.length > 0)
|
||||
.map((tr) => (
|
||||
<Tag
|
||||
key={tr.table}
|
||||
color={tr.errors.length > 0 ? "error" : "success"}
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
{TABLE_LABELS[tr.table] || tr.table}
|
||||
{tr.uploaded > 0 && ` ↑${tr.uploaded}`}
|
||||
{tr.downloaded > 0 && ` ↓${tr.downloaded}`}
|
||||
{tr.errors.length > 0 && ` ⚠${tr.errors.length}`}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
closable
|
||||
onClose={() => setLastResult(null)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t("settings.cloudSync.syncActions")}
|
||||
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>
|
||||
{t("settings.cloudSync.syncDirection")}
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={syncDirection}
|
||||
onChange={(e) => setSyncDirection(e.target.value)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Radio value="bidirectional">{t("settings.cloudSync.bidirectional")}</Radio>
|
||||
<Radio value="push">{t("settings.cloudSync.pushOnly")}</Radio>
|
||||
<Radio value="pull">{t("settings.cloudSync.pullOnly")}</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: "12px 0" }} />
|
||||
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudSyncOutlined />}
|
||||
onClick={() => handleSync(syncDirection)}
|
||||
loading={syncStatus.is_syncing}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
{t("settings.cloudSync.startSync")}
|
||||
</Button>
|
||||
|
||||
<Space style={{ width: "100%" }}>
|
||||
<Button
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={() => handleSync("push")}
|
||||
loading={syncStatus.is_syncing}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{t("settings.cloudSync.uploadToCloud")}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => handleSync("pull")}
|
||||
loading={syncStatus.is_syncing}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{t("settings.cloudSync.downloadFromCloud")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<span>{t("settings.cloudSync.storageUsage")}</span>
|
||||
<Tooltip title={t("common.refresh")}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadStorageStats}
|
||||
loading={loadingStats}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
|
||||
>
|
||||
{storageStats ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 8,
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
<span>{t("settings.cloudSync.usedStorage")}</span>
|
||||
<span>
|
||||
{formatBytes(storageStats.used)} / {formatBytes(storageStats.total)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={storageStats.percentage}
|
||||
status={storageStats.percentage > 90 ? "exception" : "active"}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
dataSource={storageStats.table_stats}
|
||||
rowKey="table"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: t("settings.cloudSync.table"),
|
||||
dataIndex: "table",
|
||||
key: "table",
|
||||
render: (val: string) => TABLE_LABELS[val] || val,
|
||||
},
|
||||
{
|
||||
title: t("settings.cloudSync.records"),
|
||||
dataIndex: "count",
|
||||
key: "count",
|
||||
width: 80,
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: t("settings.cloudSync.size"),
|
||||
dataIndex: "size",
|
||||
key: "size",
|
||||
width: 100,
|
||||
align: "right",
|
||||
render: (val: number) => formatBytes(val),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{ textAlign: "center", padding: "20px 0", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{loadingStats ? <Spin /> : t("settings.cloudSync.clickToLoadStats")}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>
|
||||
{t("settings.cloudSync.description")}
|
||||
</div>
|
||||
<div style={{ fontSize: "13px", color: "var(--ss-text-secondary)", lineHeight: 1.8 }}>
|
||||
<p>{t("settings.cloudSync.descriptionPoint1")}</p>
|
||||
<p>{t("settings.cloudSync.descriptionPoint2")}</p>
|
||||
<p>{t("settings.cloudSync.descriptionPoint3")}</p>
|
||||
<p>{t("settings.cloudSync.descriptionPoint4")}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+523
-139
@@ -14,9 +14,14 @@ import {
|
||||
message,
|
||||
Avatar,
|
||||
Typography,
|
||||
Radio,
|
||||
Collapse,
|
||||
} from "antd"
|
||||
import { CloudOutlined, DatabaseOutlined } from "@ant-design/icons"
|
||||
import { ThemeQuickSettings } from "./ThemeQuickSettings"
|
||||
import { OAuthLogin } from "./OAuth/OAuthLogin"
|
||||
import { SectlCloudSyncPanel } from "./SectlCloudSyncPanel"
|
||||
import { SectlProvider } from "../contexts/SectlContext"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { pinyin } from "pinyin-pro"
|
||||
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
|
||||
@@ -168,6 +173,21 @@ export const Settings: React.FC<{
|
||||
const [settleDialogVisible, setSettleDialogVisible] = useState(false)
|
||||
|
||||
const [urlRegisterLoading, setUrlRegisterLoading] = useState(false)
|
||||
const [elevationStatus, setElevationStatus] = useState<{
|
||||
is_elevated: boolean
|
||||
platform: string
|
||||
can_request_elevation: boolean
|
||||
} | null>(null)
|
||||
const [elevationLoading, setElevationLoading] = useState(false)
|
||||
const [urlStatus, setUrlStatus] = useState<{
|
||||
registered: boolean
|
||||
protocol: string
|
||||
platform: string
|
||||
details: string
|
||||
} | null>(null)
|
||||
const [urlStatusLoading, setUrlStatusLoading] = useState(false)
|
||||
const [urlUnregisterLoading, setUrlUnregisterLoading] = useState(false)
|
||||
const [urlOperationLogs, setUrlOperationLogs] = useState<string[]>([])
|
||||
const [appQuitLoading, setAppQuitLoading] = useState(false)
|
||||
const [appRestartLoading, setAppRestartLoading] = useState(false)
|
||||
const [mcpLoading, setMcpLoading] = useState(false)
|
||||
@@ -187,6 +207,8 @@ export const Settings: React.FC<{
|
||||
const canAdmin = permission === "admin"
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const [syncMethod, setSyncMethod] = useState<"postgresql" | "sectl_cloud">("postgresql")
|
||||
|
||||
const [pgConnectionString, setPgConnectionString] = useState("")
|
||||
const [pgConnectionStatus, setPgConnectionStatus] = useState<{
|
||||
connected: boolean
|
||||
@@ -307,6 +329,11 @@ export const Settings: React.FC<{
|
||||
setSettings(res.data)
|
||||
setPgConnectionString(res.data.pg_connection_string || "")
|
||||
setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" })
|
||||
if (res.data.sync_method === "sectl_cloud") {
|
||||
setSyncMethod("sectl_cloud")
|
||||
} else {
|
||||
setSyncMethod("postgresql")
|
||||
}
|
||||
savedFontFamily = res.data.font_family || "system"
|
||||
}
|
||||
const authRes = await api.authGetStatus()
|
||||
@@ -326,6 +353,28 @@ export const Settings: React.FC<{
|
||||
|
||||
await loadMcpStatus()
|
||||
await loadSystemFonts(savedFontFamily)
|
||||
|
||||
if (api.checkElevation) {
|
||||
try {
|
||||
const elevRes = await api.checkElevation()
|
||||
if (elevRes?.success && elevRes.data) {
|
||||
setElevationStatus(elevRes.data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (api.checkUrlProtocolStatus) {
|
||||
try {
|
||||
const urlRes = await api.checkUrlProtocolStatus()
|
||||
if (urlRes?.success && urlRes.data) {
|
||||
setUrlStatus(urlRes.data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleOAuthLogout = async () => {
|
||||
@@ -345,6 +394,118 @@ export const Settings: React.FC<{
|
||||
}
|
||||
}
|
||||
|
||||
const handleRequestElevation = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api?.requestElevation) return
|
||||
setElevationLoading(true)
|
||||
try {
|
||||
const res = await api.requestElevation()
|
||||
if (res?.success) {
|
||||
messageApi.success(t("settings.elevation.requestSuccess"))
|
||||
} else {
|
||||
messageApi.error(res?.message || t("settings.elevation.requestFailed"))
|
||||
}
|
||||
} catch (error: any) {
|
||||
messageApi.error(error?.message || t("settings.elevation.requestFailed"))
|
||||
} finally {
|
||||
setElevationLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const appendUrlLog = (msg: string) => {
|
||||
const ts = new Date().toLocaleTimeString()
|
||||
setUrlOperationLogs((prev) => [...prev.slice(-19), `[${ts}] ${msg}`])
|
||||
}
|
||||
|
||||
const loadUrlStatus = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api?.checkUrlProtocolStatus) return
|
||||
setUrlStatusLoading(true)
|
||||
try {
|
||||
const res = await api.checkUrlProtocolStatus()
|
||||
if (res?.success && res.data) {
|
||||
setUrlStatus(res.data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setUrlStatusLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUrlRegister = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api?.registerUrlProtocol || !api?.checkUrlProtocolStatus) return
|
||||
setUrlRegisterLoading(true)
|
||||
appendUrlLog(t("settings.url.logRegisterStart"))
|
||||
|
||||
const maxRetries = 7
|
||||
let success = false
|
||||
|
||||
const doRegister = async () => {
|
||||
await api.registerUrlProtocol()
|
||||
appendUrlLog(t("settings.url.logRegisterVerify"))
|
||||
const statusRes = await api.checkUrlProtocolStatus()
|
||||
if (statusRes?.success && statusRes.data?.registered) {
|
||||
setUrlStatus(statusRes.data)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
success = await doRegister()
|
||||
|
||||
if (!success) {
|
||||
for (let retry = 1; retry <= maxRetries; retry++) {
|
||||
appendUrlLog(t("settings.url.retrying", { current: retry, max: maxRetries }))
|
||||
await new Promise((r) => setTimeout(r, retry * 1000))
|
||||
success = await doRegister()
|
||||
if (success) {
|
||||
appendUrlLog(t("settings.url.retrySuccess", { n: retry }))
|
||||
break
|
||||
}
|
||||
appendUrlLog(t("settings.url.logRegisterFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
setUrlRegisterLoading(false)
|
||||
|
||||
if (success) {
|
||||
appendUrlLog(t("settings.url.logRegisterSuccess"))
|
||||
messageApi.success(t("settings.url.registered"))
|
||||
} else {
|
||||
appendUrlLog(t("settings.url.logMaxRetries"))
|
||||
messageApi.error(t("settings.url.retryFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUrlUnregister = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api?.unregisterUrlProtocol || !api?.checkUrlProtocolStatus) return
|
||||
setUrlUnregisterLoading(true)
|
||||
appendUrlLog(t("settings.url.logUnregisterStart"))
|
||||
|
||||
try {
|
||||
const res = await api.unregisterUrlProtocol()
|
||||
if (res?.success) {
|
||||
const statusRes = await api.checkUrlProtocolStatus()
|
||||
if (statusRes?.success && statusRes.data) {
|
||||
setUrlStatus(statusRes.data)
|
||||
}
|
||||
appendUrlLog(t("settings.url.logUnregisterSuccess"))
|
||||
messageApi.success(t("settings.url.unregistered"))
|
||||
} else {
|
||||
appendUrlLog(t("settings.url.logUnregisterFailed"))
|
||||
messageApi.error(res?.message || t("settings.url.unregisterFailed"))
|
||||
}
|
||||
} catch (error: any) {
|
||||
appendUrlLog(t("settings.url.logUnregisterFailed"))
|
||||
messageApi.error(error?.message || t("settings.url.unregisterFailed"))
|
||||
} finally {
|
||||
setUrlUnregisterLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAboutContent = async () => {
|
||||
try {
|
||||
const res = await fetch("/about-content.json")
|
||||
@@ -1178,7 +1339,7 @@ export const Settings: React.FC<{
|
||||
},
|
||||
{
|
||||
key: "database",
|
||||
label: t("settings.database.title"),
|
||||
label: t("settings.cloudSync.title"),
|
||||
children: (
|
||||
<>
|
||||
<Card
|
||||
@@ -1189,110 +1350,220 @@ export const Settings: React.FC<{
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
|
||||
{t("settings.database.currentStatus")}
|
||||
</div>
|
||||
<Space>
|
||||
<Tag color={pgConnectionStatus.type === "postgresql" ? "blue" : "green"}>
|
||||
{pgConnectionStatus.type === "postgresql"
|
||||
? t("settings.database.postgresqlRemote")
|
||||
: t("settings.database.sqliteLocal")}
|
||||
</Tag>
|
||||
<Tag color={pgConnectionStatus.connected ? "success" : "error"}>
|
||||
{pgConnectionStatus.connected
|
||||
? t("settings.database.connected")
|
||||
: t("settings.database.disconnected")}
|
||||
</Tag>
|
||||
{pgConnectionStatus.error && (
|
||||
<span style={{ color: "var(--ant-color-error, #ff4d4f)", fontSize: "12px" }}>
|
||||
{pgConnectionStatus.error}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
color: "var(--ss-text-main)",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
|
||||
{t("settings.database.postgresqlConnection")}
|
||||
{t("settings.cloudSync.selectMethod")}
|
||||
</div>
|
||||
<div
|
||||
style={{ marginBottom: "12px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
style={{ marginBottom: "16px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("settings.database.connectionHint")}
|
||||
{t("settings.cloudSync.selectMethodHint")}
|
||||
</div>
|
||||
<Space.Compact style={{ width: "100%", marginBottom: "12px" }}>
|
||||
<Input
|
||||
value={pgConnectionString}
|
||||
onChange={(e) => setPgConnectionString(e.target.value)}
|
||||
placeholder="postgresql://user:password@host:port/database?sslmode=require"
|
||||
style={{ flex: 1 }}
|
||||
disabled={!canAdmin}
|
||||
/>
|
||||
<Button
|
||||
onClick={testPgConnection}
|
||||
loading={pgTestLoading}
|
||||
disabled={!canAdmin || !pgConnectionString}
|
||||
>
|
||||
{t("settings.database.testConnection")}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<div
|
||||
style={{ marginBottom: "12px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("settings.database.connectionExample")}
|
||||
</div>
|
||||
<Space
|
||||
direction={isMobile ? "vertical" : "horizontal"}
|
||||
size={isMobile ? 8 : "small"}
|
||||
<Radio.Group
|
||||
value={syncMethod}
|
||||
onChange={async (e) => {
|
||||
const next = e.target.value as "postgresql" | "sectl_cloud"
|
||||
setSyncMethod(next)
|
||||
if ((window as any).api) {
|
||||
await (window as any).api.setSetting("sync_method", next)
|
||||
}
|
||||
}}
|
||||
disabled={!canAdmin}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={switchToPg}
|
||||
loading={pgSwitchLoading}
|
||||
disabled={!canAdmin || !pgConnectionString}
|
||||
style={{ width: isMobile ? "100%" : "auto" }}
|
||||
>
|
||||
{t("settings.database.switchToPostgreSQL")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={switchToSQLite}
|
||||
loading={pgSwitchLoading}
|
||||
disabled={!canAdmin || pgConnectionStatus.type === "sqlite"}
|
||||
style={{ width: isMobile ? "100%" : "auto" }}
|
||||
>
|
||||
{t("settings.database.switchToSQLite")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={uploadLocalToRemote}
|
||||
loading={pgUploadLoading}
|
||||
disabled={!canAdmin || pgConnectionStatus.type !== "postgresql"}
|
||||
style={{ width: isMobile ? "100%" : "auto" }}
|
||||
>
|
||||
{t("settings.database.uploadButton")}
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ marginTop: "8px", fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||
{t("settings.database.uploadHint")}
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={12}>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
style={{
|
||||
borderColor:
|
||||
syncMethod === "postgresql" ? "var(--ant-color-primary)" : undefined,
|
||||
backgroundColor:
|
||||
syncMethod === "postgresql" ? "var(--ant-color-primary-bg)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Radio value="postgresql">
|
||||
<Space>
|
||||
<DatabaseOutlined />
|
||||
<span style={{ fontWeight: 500 }}>{t("settings.cloudSync.postgresql")}</span>
|
||||
</Space>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
marginTop: 4,
|
||||
marginLeft: 22,
|
||||
}}
|
||||
>
|
||||
{t("settings.cloudSync.postgresqlDesc")}
|
||||
</div>
|
||||
</Radio>
|
||||
</Card>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
style={{
|
||||
borderColor:
|
||||
syncMethod === "sectl_cloud" ? "var(--ant-color-primary)" : undefined,
|
||||
backgroundColor:
|
||||
syncMethod === "sectl_cloud" ? "var(--ant-color-primary-bg)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Radio value="sectl_cloud">
|
||||
<Space>
|
||||
<CloudOutlined />
|
||||
<span style={{ fontWeight: 500 }}>{t("settings.cloudSync.sectlCloud")}</span>
|
||||
</Space>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
marginTop: 4,
|
||||
marginLeft: 22,
|
||||
}}
|
||||
>
|
||||
{t("settings.cloudSync.sectlCloudDesc")}
|
||||
</div>
|
||||
</Radio>
|
||||
</Card>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Card>
|
||||
|
||||
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
|
||||
{t("settings.database.syncDescription")}
|
||||
</div>
|
||||
<div style={{ fontSize: "13px", color: "var(--ss-text-secondary)", lineHeight: "1.8" }}>
|
||||
<p>{t("settings.database.syncPoint1")}</p>
|
||||
<p>{t("settings.database.syncPoint2")}</p>
|
||||
<p>{t("settings.database.syncPoint3")}</p>
|
||||
<p>{t("settings.database.syncPoint4")}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{syncMethod === "postgresql" ? (
|
||||
<>
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
color: "var(--ss-text-main)",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
|
||||
{t("settings.database.currentStatus")}
|
||||
</div>
|
||||
<Space>
|
||||
<Tag color={pgConnectionStatus.type === "postgresql" ? "blue" : "green"}>
|
||||
{pgConnectionStatus.type === "postgresql"
|
||||
? t("settings.database.postgresqlRemote")
|
||||
: t("settings.database.sqliteLocal")}
|
||||
</Tag>
|
||||
<Tag color={pgConnectionStatus.connected ? "success" : "error"}>
|
||||
{pgConnectionStatus.connected
|
||||
? t("settings.database.connected")
|
||||
: t("settings.database.disconnected")}
|
||||
</Tag>
|
||||
{pgConnectionStatus.error && (
|
||||
<span style={{ color: "var(--ant-color-error, #ff4d4f)", fontSize: "12px" }}>
|
||||
{pgConnectionStatus.error}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
color: "var(--ss-text-main)",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
|
||||
{t("settings.database.postgresqlConnection")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "12px",
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("settings.database.connectionHint")}
|
||||
</div>
|
||||
<Space.Compact style={{ width: "100%", marginBottom: "12px" }}>
|
||||
<Input
|
||||
value={pgConnectionString}
|
||||
onChange={(e) => setPgConnectionString(e.target.value)}
|
||||
placeholder="postgresql://user:password@host:port/database?sslmode=require"
|
||||
style={{ flex: 1 }}
|
||||
disabled={!canAdmin}
|
||||
/>
|
||||
<Button
|
||||
onClick={testPgConnection}
|
||||
loading={pgTestLoading}
|
||||
disabled={!canAdmin || !pgConnectionString}
|
||||
>
|
||||
{t("settings.database.testConnection")}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "12px",
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("settings.database.connectionExample")}
|
||||
</div>
|
||||
<Space
|
||||
direction={isMobile ? "vertical" : "horizontal"}
|
||||
size={isMobile ? 8 : "small"}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={switchToPg}
|
||||
loading={pgSwitchLoading}
|
||||
disabled={!canAdmin || !pgConnectionString}
|
||||
style={{ width: isMobile ? "100%" : "auto" }}
|
||||
>
|
||||
{t("settings.database.switchToPostgreSQL")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={switchToSQLite}
|
||||
loading={pgSwitchLoading}
|
||||
disabled={!canAdmin || pgConnectionStatus.type === "sqlite"}
|
||||
style={{ width: isMobile ? "100%" : "auto" }}
|
||||
>
|
||||
{t("settings.database.switchToSQLite")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={uploadLocalToRemote}
|
||||
loading={pgUploadLoading}
|
||||
disabled={!canAdmin || pgConnectionStatus.type !== "postgresql"}
|
||||
style={{ width: isMobile ? "100%" : "auto" }}
|
||||
>
|
||||
{t("settings.database.uploadButton")}
|
||||
</Button>
|
||||
</Space>
|
||||
<div
|
||||
style={{ marginTop: "8px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("settings.database.uploadHint")}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
|
||||
{t("settings.database.syncDescription")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
lineHeight: "1.8",
|
||||
}}
|
||||
>
|
||||
<p>{t("settings.database.syncPoint1")}</p>
|
||||
<p>{t("settings.database.syncPoint2")}</p>
|
||||
<p>{t("settings.database.syncPoint3")}</p>
|
||||
<p>{t("settings.database.syncPoint4")}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<SectlProvider>
|
||||
<SectlCloudSyncPanel />
|
||||
</SectlProvider>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -1412,56 +1683,169 @@ export const Settings: React.FC<{
|
||||
},
|
||||
{
|
||||
key: "url",
|
||||
label: t("settings.url.title"),
|
||||
label: t("settings.tabs.urlProtocol"),
|
||||
children: (
|
||||
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
|
||||
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
|
||||
{t("settings.url.protocol")}
|
||||
</div>
|
||||
<Divider />
|
||||
<div
|
||||
style={{ marginBottom: "12px", fontSize: "13px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("settings.url.description")}
|
||||
</div>
|
||||
<div
|
||||
<>
|
||||
<Card
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
fontSize: "12px",
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", monospace',
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
color: "var(--ss-text-main)",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<div>secscore://settings</div>
|
||||
<div>secscore://score</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<Space>
|
||||
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
|
||||
{t("settings.elevation.title")}
|
||||
</div>
|
||||
<Divider />
|
||||
<div
|
||||
style={{ marginBottom: "12px", fontSize: "13px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("settings.elevation.description")}
|
||||
</div>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Tag color={elevationStatus?.is_elevated ? "success" : "warning"}>
|
||||
{elevationStatus?.is_elevated
|
||||
? t("settings.elevation.elevated")
|
||||
: t("settings.elevation.notElevated")}
|
||||
</Tag>
|
||||
{elevationStatus?.platform && <Tag color="blue">{elevationStatus.platform}</Tag>}
|
||||
</Space>
|
||||
<Divider />
|
||||
<Button
|
||||
type="primary"
|
||||
loading={urlRegisterLoading}
|
||||
disabled={!canAdmin}
|
||||
onClick={async () => {
|
||||
if (!(window as any).api) return
|
||||
setUrlRegisterLoading(true)
|
||||
const res = await (window as any).api.registerUrlProtocol()
|
||||
setUrlRegisterLoading(false)
|
||||
if (res && res.success) {
|
||||
messageApi.success(t("settings.url.registered"))
|
||||
} else {
|
||||
messageApi.error(res?.message || t("settings.url.registerFailed"))
|
||||
}
|
||||
loading={elevationLoading}
|
||||
disabled={
|
||||
!canAdmin || !elevationStatus?.can_request_elevation || elevationStatus?.is_elevated
|
||||
}
|
||||
onClick={handleRequestElevation}
|
||||
>
|
||||
{t("settings.elevation.request")}
|
||||
</Button>
|
||||
<div style={{ marginTop: "8px", fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||
{elevationStatus?.platform === "windows"
|
||||
? t("settings.elevation.windowsHint")
|
||||
: elevationStatus?.platform === "macos"
|
||||
? t("settings.elevation.macosHint")
|
||||
: t("settings.elevation.linuxHint")}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
|
||||
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
|
||||
{t("settings.url.protocol")}
|
||||
</div>
|
||||
<Divider />
|
||||
<div
|
||||
style={{ marginBottom: "12px", fontSize: "13px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("settings.url.description")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
fontSize: "12px",
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", monospace',
|
||||
}}
|
||||
>
|
||||
{t("settings.url.register")}
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ marginTop: "8px", fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||
{t("settings.url.installerRequired")}
|
||||
</div>
|
||||
</Card>
|
||||
<div>secscore://settings</div>
|
||||
<div>secscore://score</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Tag color={urlStatus?.registered ? "success" : "error"}>
|
||||
{urlStatus?.registered
|
||||
? t("settings.url.statusRegistered")
|
||||
: t("settings.url.statusNotRegistered")}
|
||||
</Tag>
|
||||
<Button size="small" loading={urlStatusLoading} onClick={loadUrlStatus}>
|
||||
{t("settings.url.checkStatus")}
|
||||
</Button>
|
||||
</Space>
|
||||
{urlStatus?.details && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "12px",
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{urlStatus.details}
|
||||
</div>
|
||||
)}
|
||||
<Divider />
|
||||
<Space>
|
||||
{!urlStatus?.registered ? (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={urlRegisterLoading}
|
||||
disabled={!canAdmin}
|
||||
onClick={handleUrlRegister}
|
||||
>
|
||||
{t("settings.url.register")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
loading={urlRegisterLoading}
|
||||
disabled={!canAdmin}
|
||||
onClick={handleUrlRegister}
|
||||
>
|
||||
{t("settings.url.reRegister")}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
loading={urlUnregisterLoading}
|
||||
disabled={!canAdmin}
|
||||
onClick={handleUrlUnregister}
|
||||
>
|
||||
{t("settings.url.unregister")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
<div style={{ marginTop: "8px", fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||
{t("settings.url.installerRequired")}
|
||||
</div>
|
||||
{urlOperationLogs.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "logs",
|
||||
label: (
|
||||
<span style={{ fontSize: "13px" }}>
|
||||
{t("settings.url.operationLog")} ({urlOperationLogs.length})
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
lineHeight: "1.8",
|
||||
}}
|
||||
>
|
||||
{urlOperationLogs.map((log, i) => (
|
||||
<div key={i}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"appearance": "المظهر",
|
||||
"security": "الأمان",
|
||||
"account": "الحساب",
|
||||
"database": "اتصال قاعدة البيانات",
|
||||
"database": "المزامنة السحابية",
|
||||
"dataManagement": "إدارة البيانات",
|
||||
"urlProtocol": "بروتوكول URL",
|
||||
"about": "حول"
|
||||
@@ -255,6 +255,45 @@
|
||||
"uploadSuccess": "تم رفع البيانات المحلية إلى الخادم البعيد",
|
||||
"uploadFailed": "فشل الرفع"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "المزامنة السحابية",
|
||||
"selectMethod": "اختر طريقة المزامنة",
|
||||
"selectMethodHint": "اختر طريقة مزامنة البيانات. الطريقتان متنافيتان.",
|
||||
"postgresql": "PostgreSQL ذاتي الاستضافة",
|
||||
"postgresqlDesc": "اتصل بقاعدة بيانات PostgreSQL الخاصة بك للتحكم الكامل في البيانات.",
|
||||
"sectlCloud": "مزامنة SECTL السحابية",
|
||||
"sectlCloudDesc": "مزامنة البيانات عبر منصة SECTL السحابية. لا حاجة لقاعدة بيانات ذاتية.",
|
||||
"loginRequired": "يرجى تسجيل الدخول إلى SECTL Auth أولاً",
|
||||
"loginHint": "تتطلب المزامنة السحابية حساب SECTL Auth. يرجى تسجيل الدخول في تبويب 'الحساب'.",
|
||||
"statusTitle": "حالة المزامنة",
|
||||
"configured": "جاهز",
|
||||
"notConfigured": "غير جاهز",
|
||||
"syncing": "جارٍ المزامنة...",
|
||||
"lastSync": "آخر مزامنة",
|
||||
"never": "لم تتم المزامنة قط",
|
||||
"syncActions": "إجراءات المزامنة",
|
||||
"syncDirection": "اتجاه المزامنة",
|
||||
"bidirectional": "ثنائي الاتجاه (موصى به) — دمج تلقائي للبيانات المحلية والسحابية",
|
||||
"pushOnly": "رفع فقط — استبدال البيانات السحابية بالبيانات المحلية",
|
||||
"pullOnly": "تنزيل فقط — استبدال البيانات المحلية بالبيانات السحابية",
|
||||
"startSync": "بدء المزامنة",
|
||||
"uploadToCloud": "رفع إلى السحابة",
|
||||
"downloadFromCloud": "تنزيل من السحابة",
|
||||
"fullSync": "مزامنة ثنائية الاتجاه",
|
||||
"syncInProgress": "المزامنة قيد التنفيذ، يرجى الانتظار",
|
||||
"syncConfirm": "تأكيد {{direction}}؟",
|
||||
"storageUsage": "استخدام التخزين",
|
||||
"usedStorage": "المستخدم",
|
||||
"table": "الجدول",
|
||||
"records": "السجلات",
|
||||
"size": "الحجم",
|
||||
"clickToLoadStats": "انقر على التحديث لتحميل الإحصائيات",
|
||||
"description": "دليل المزامنة السحابية SECTL",
|
||||
"descriptionPoint1": "مزامنة البيانات عبر خدمة SECTL السحابية. لا حاجة لقاعدة بيانات ذاتية. البيانات معزولة بحساب SECTL Auth.",
|
||||
"descriptionPoint2": "بناءً على بنية تخزين KV. كل جدول بيانات مخزن كزوج قيمة-مفتاح.",
|
||||
"descriptionPoint3": "يدعم أنماط ثنائي الاتجاه والرفع فقط والتنزيل فقط.",
|
||||
"descriptionPoint4": "البيانات منقولة عبر HTTPS مع مصادقة OAuth 2.0."
|
||||
},
|
||||
"data": {
|
||||
"title": "إدارة البيانات",
|
||||
"settlement": "التسوية",
|
||||
@@ -301,7 +340,39 @@
|
||||
"register": "تسجيل بروتوكول URL",
|
||||
"registered": "تم تسجيل بروتوكول URL",
|
||||
"registerFailed": "فشل التسجيل",
|
||||
"installerRequired": "يتطلب الإصدار المثبت من SecScore، قد لا يعمل في وضع التطوير."
|
||||
"installerRequired": "يتطلب الإصدار المثبت من SecScore، قد لا يعمل في وضع التطوير.",
|
||||
"checkStatus": "التحقق من الحالة",
|
||||
"reRegister": "إعادة التسجيل",
|
||||
"unregister": "إلغاء التسجيل",
|
||||
"unregistered": "تم إلغاء تسجيل بروتوكول URL",
|
||||
"unregisterFailed": "فشل إلغاء التسجيل",
|
||||
"statusRegistered": "مسجل",
|
||||
"statusNotRegistered": "غير مسجل",
|
||||
"statusCheckFailed": "فشل التحقق من الحالة",
|
||||
"retrying": "إعادة المحاولة ({{current}}/{{max}})",
|
||||
"retryFailed": "تم الوصول إلى الحد الأقصى من المحاولات، فشل التسجيل",
|
||||
"retrySuccess": "إعادة المحاولة #{{n}} نجحت",
|
||||
"operationLog": "سجل العمليات",
|
||||
"logRegisterStart": "بدء تسجيل بروتوكول URL...",
|
||||
"logRegisterVerify": "تم تنفيذ أمر التسجيل، جارٍ التحقق...",
|
||||
"logRegisterSuccess": "تم التحقق من التسجيل بنجاح",
|
||||
"logRegisterFailed": "فشل التحقق من التسجيل",
|
||||
"logUnregisterStart": "جارٍ إلغاء التسجيل...",
|
||||
"logUnregisterSuccess": "تم إلغاء التسجيل بنجاح",
|
||||
"logUnregisterFailed": "فشل إلغاء التسجيل",
|
||||
"logMaxRetries": "تم الوصول إلى الحد الأقصى من المحاولات (7)"
|
||||
},
|
||||
"elevation": {
|
||||
"title": "صلاحيات المسؤول",
|
||||
"description": "التحقق مما إذا كان التطبيق يعمل بصلاحيات المسؤول/الجذر. تتطلب بعض الميزات (استماع المنفذ، العمليات على مستوى النظام) صلاحيات مرتفعة.",
|
||||
"elevated": "مرتفع",
|
||||
"notElevated": "غير مرتفع",
|
||||
"request": "طلب صلاحيات المسؤول",
|
||||
"requestSuccess": "تم طلب صلاحيات المسؤول، سيتم إعادة تشغيل التطبيق",
|
||||
"requestFailed": "فشل طلب صلاحيات المسؤول",
|
||||
"windowsHint": "ويندوز: ستظهر رسالة UAC. بعد التأكيد، سيتم إعادة تشغيل التطبيق كمسؤول.",
|
||||
"macosHint": "macOS: ستظهر نافذة مصادقة النظام. أدخل كلمة مرور المسؤول لإعادة التشغيل.",
|
||||
"linuxHint": "لينكس: يستخدم pkexec لطلب صلاحيات الجذر. أدخل كلمة المرور لإعادة التشغيل."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "خدمة MCP",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"appearance": "Erscheinungsbild",
|
||||
"security": "Sicherheit",
|
||||
"account": "Konto",
|
||||
"database": "Datenbankverbindung",
|
||||
"database": "Cloud-Synchronisierung",
|
||||
"dataManagement": "Datenverwaltung",
|
||||
"urlProtocol": "URL-Protokoll",
|
||||
"about": "Über"
|
||||
@@ -252,9 +252,48 @@
|
||||
"noNeedUpload": "Lokale und Remote-Daten sind bereits konsistent",
|
||||
"uploadConfirmTitle": "Lokale Daten auf Remote-Server hochladen bestätigen?",
|
||||
"uploadConfirmContent": "Die Synchronisierung priorisiert lokale Daten. Erkannt: nur lokal {{localOnly}}, nur remote {{remoteOnly}}, Konflikte {{conflicts}}.",
|
||||
"uploadSuccess": "Lokale Daten auf Remote-Server hochgeladen",
|
||||
"uploadSuccess": "Lokale Daten auf den Server hochgeladen",
|
||||
"uploadFailed": "Hochladen fehlgeschlagen"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "Cloud-Synchronisierung",
|
||||
"selectMethod": "Synchronisierungsmethode wählen",
|
||||
"selectMethodHint": "Wählen Sie eine Datensynchronisierungsmethode. Die beiden Methoden schließen sich gegenseitig aus.",
|
||||
"postgresql": "Selbst-gehostetes PostgreSQL",
|
||||
"postgresqlDesc": "Verbinden Sie Ihre eigene PostgreSQL-Datenbank für volle Datenkontrolle. Ideal für Benutzer mit eigenem Server.",
|
||||
"sectlCloud": "SECTL Cloud-Sync",
|
||||
"sectlCloudDesc": "Daten über die SECTL-Cloud-Plattform synchronisieren. Keine eigene Datenbank nötig, sofort einsatzbereit.",
|
||||
"loginRequired": "Bitte zuerst bei SECTL Auth anmelden",
|
||||
"loginHint": "SECTL Cloud-Sync erfordert ein SECTL Auth-Konto. Bitte im Tab 'Konto' anmelden.",
|
||||
"statusTitle": "Synchronisierungsstatus",
|
||||
"configured": "Bereit",
|
||||
"notConfigured": "Nicht bereit",
|
||||
"syncing": "Synchronisiere...",
|
||||
"lastSync": "Letzte Sync",
|
||||
"never": "Nie synchronisiert",
|
||||
"syncActions": "Synchronisierungsaktionen",
|
||||
"syncDirection": "Synchronisierungsrichtung",
|
||||
"bidirectional": "Bidirektional (Empfohlen) — Lokale und Cloud-Daten automatisch zusammenführen",
|
||||
"pushOnly": "Nur hochladen — Cloud-Daten mit lokalen Daten überschreiben",
|
||||
"pullOnly": "Nur herunterladen — Lokale Daten mit Cloud-Daten überschreiben",
|
||||
"startSync": "Synchronisierung starten",
|
||||
"uploadToCloud": "In die Cloud hochladen",
|
||||
"downloadFromCloud": "Aus der Cloud herunterladen",
|
||||
"fullSync": "Bidirektionale Synchronisierung",
|
||||
"syncInProgress": "Synchronisierung läuft, bitte warten",
|
||||
"syncConfirm": "{{direction}} bestätigen?",
|
||||
"storageUsage": "Speichernutzung",
|
||||
"usedStorage": "Verwendet",
|
||||
"table": "Tabelle",
|
||||
"records": "Datensätze",
|
||||
"size": "Größe",
|
||||
"clickToLoadStats": "Aktualisieren klicken um Statistiken zu laden",
|
||||
"description": "SECTL Cloud-Sync-Anleitung",
|
||||
"descriptionPoint1": "Daten über den SECTL-Cloud-Dienst synchronisieren. Keine eigene Datenbank nötig. Daten werden durch SECTL Auth-Konto isoliert.",
|
||||
"descriptionPoint2": "Basierend auf KV-Speicherarchitektur. Jede Tabelle wird als Schlüssel-Wert-Paar gespeichert.",
|
||||
"descriptionPoint3": "Unterstützt bidirektionale, Nur-Upload- und Nur-Download-Modi.",
|
||||
"descriptionPoint4": "Daten werden über HTTPS mit OAuth 2.0-Authentifizierung übertragen."
|
||||
},
|
||||
"data": {
|
||||
"title": "Datenverwaltung",
|
||||
"settlement": "Abrechnung",
|
||||
@@ -301,7 +340,39 @@
|
||||
"register": "URL-Protokoll registrieren",
|
||||
"registered": "URL-Protokoll registriert",
|
||||
"registerFailed": "Registrierung fehlgeschlagen",
|
||||
"installerRequired": "Erfordert die installierte Version von SecScore, kann im Entwicklungsmodus nicht funktionieren."
|
||||
"installerRequired": "Erfordert die installierte Version von SecScore, kann im Entwicklungsmodus nicht funktionieren.",
|
||||
"checkStatus": "Status prüfen",
|
||||
"reRegister": "Erneut registrieren",
|
||||
"unregister": "Registrierung aufheben",
|
||||
"unregistered": "URL-Protokoll-Registrierung aufgehoben",
|
||||
"unregisterFailed": "Registrierung konnte nicht aufgehoben werden",
|
||||
"statusRegistered": "Registriert",
|
||||
"statusNotRegistered": "Nicht registriert",
|
||||
"statusCheckFailed": "Statusprüfung fehlgeschlagen",
|
||||
"retrying": "Wiederholung ({{current}}/{{max}})",
|
||||
"retryFailed": "Maximale Wiederholungen erreicht, Registrierung fehlgeschlagen",
|
||||
"retrySuccess": "Wiederholung #{{n}} erfolgreich",
|
||||
"operationLog": "Protokoll",
|
||||
"logRegisterStart": "URL-Protokoll-Registrierung gestartet...",
|
||||
"logRegisterVerify": "Registrierungsbefehl ausgeführt, Überprüfung läuft...",
|
||||
"logRegisterSuccess": "Registrierung erfolgreich überprüft",
|
||||
"logRegisterFailed": "Registrierungsüberprüfung fehlgeschlagen",
|
||||
"logUnregisterStart": "Registrierung wird aufgehoben...",
|
||||
"logUnregisterSuccess": "Registrierung erfolgreich aufgehoben",
|
||||
"logUnregisterFailed": "Registrierung konnte nicht aufgehoben werden",
|
||||
"logMaxRetries": "Maximale Wiederholungen (7) erreicht"
|
||||
},
|
||||
"elevation": {
|
||||
"title": "Administratorenrechte",
|
||||
"description": "Überprüfen, ob die Anwendung mit Administrator/Root-Rechten ausgeführt wird. Einige Funktionen (Port-Listening, Systemoperationen) erfordern erhöhte Rechte.",
|
||||
"elevated": "Erhöht",
|
||||
"notElevated": "Nicht erhöht",
|
||||
"request": "Admin-Rechte anfordern",
|
||||
"requestSuccess": "Admin-Rechte angefordert, Anwendung wird neu gestartet",
|
||||
"requestFailed": "Admin-Rechte konnten nicht angefordert werden",
|
||||
"windowsHint": "Windows: Ein UAC-Dialog erscheint. Nach Bestätigung wird die App als Administrator neu gestartet.",
|
||||
"macosHint": "macOS: Ein System-Authentifizierungsdialog erscheint. Admin-Passwort eingeben, um die App neu zu starten.",
|
||||
"linuxHint": "Linux: Verwendet pkexec für Root-Rechte. Passwort eingeben, um die App neu zu starten."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP-Dienst",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"appearance": "Appearance",
|
||||
"security": "Security",
|
||||
"account": "Account",
|
||||
"database": "Database Connection",
|
||||
"database": "Cloud Sync",
|
||||
"dataManagement": "Data Management",
|
||||
"urlProtocol": "URL Protocol",
|
||||
"about": "About"
|
||||
@@ -255,6 +255,45 @@
|
||||
"uploadSuccess": "Local data uploaded to remote",
|
||||
"uploadFailed": "Upload failed"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "Cloud Sync",
|
||||
"selectMethod": "Select Sync Method",
|
||||
"selectMethodHint": "Choose a data sync method. The two methods are mutually exclusive. Switching will use the new sync channel.",
|
||||
"postgresql": "Self-hosted PostgreSQL",
|
||||
"postgresqlDesc": "Connect to your own PostgreSQL database for full data control. Ideal for users with their own server.",
|
||||
"sectlCloud": "SECTL Cloud Sync",
|
||||
"sectlCloudDesc": "Sync data via SECTL cloud platform. No self-hosted database needed, works out of the box.",
|
||||
"loginRequired": "Please log in to SECTL Auth first",
|
||||
"loginHint": "SECTL Cloud Sync requires a SECTL Auth account. Please go to the 'Account' tab to log in.",
|
||||
"statusTitle": "Sync Status",
|
||||
"configured": "Ready",
|
||||
"notConfigured": "Not Ready",
|
||||
"syncing": "Syncing...",
|
||||
"lastSync": "Last synced",
|
||||
"never": "Never synced",
|
||||
"syncActions": "Sync Actions",
|
||||
"syncDirection": "Sync Direction",
|
||||
"bidirectional": "Bidirectional (Recommended) — Automatically merge local and cloud data",
|
||||
"pushOnly": "Upload only — Overwrite cloud data with local data",
|
||||
"pullOnly": "Download only — Overwrite local data with cloud data",
|
||||
"startSync": "Start Sync",
|
||||
"uploadToCloud": "Upload to Cloud",
|
||||
"downloadFromCloud": "Download from Cloud",
|
||||
"fullSync": "Bidirectional Sync",
|
||||
"syncInProgress": "Sync is in progress, please wait",
|
||||
"syncConfirm": "Confirm {{direction}}?",
|
||||
"storageUsage": "Storage Usage",
|
||||
"usedStorage": "Used",
|
||||
"table": "Table",
|
||||
"records": "Records",
|
||||
"size": "Size",
|
||||
"clickToLoadStats": "Click refresh to load stats",
|
||||
"description": "SECTL Cloud Sync Guide",
|
||||
"descriptionPoint1": "Sync data via SECTL cloud service. No self-hosted database needed. Data is isolated by SECTL Auth account.",
|
||||
"descriptionPoint2": "Based on KV storage architecture. Each data table is stored as a key-value pair for efficient read/write.",
|
||||
"descriptionPoint3": "Supports bidirectional, upload-only, and download-only modes for different scenarios.",
|
||||
"descriptionPoint4": "Data is transmitted over HTTPS with OAuth 2.0 authentication for security."
|
||||
},
|
||||
"data": {
|
||||
"title": "Data Management",
|
||||
"settlement": "Settlement",
|
||||
@@ -301,7 +340,39 @@
|
||||
"register": "Register URL Protocol",
|
||||
"registered": "URL protocol registered",
|
||||
"registerFailed": "Registration failed",
|
||||
"installerRequired": "Requires installed SecScore, may not work in development mode."
|
||||
"installerRequired": "Requires installed SecScore, may not work in development mode.",
|
||||
"checkStatus": "Check Status",
|
||||
"reRegister": "Re-register",
|
||||
"unregister": "Unregister",
|
||||
"unregistered": "URL protocol unregistered",
|
||||
"unregisterFailed": "Failed to unregister",
|
||||
"statusRegistered": "Registered",
|
||||
"statusNotRegistered": "Not Registered",
|
||||
"statusCheckFailed": "Status check failed",
|
||||
"retrying": "Retrying ({{current}}/{{max}})",
|
||||
"retryFailed": "Max retries reached, registration failed",
|
||||
"retrySuccess": "Retry #{{n}} succeeded",
|
||||
"operationLog": "Operation Log",
|
||||
"logRegisterStart": "Starting URL protocol registration...",
|
||||
"logRegisterVerify": "Registration command executed, verifying...",
|
||||
"logRegisterSuccess": "Registration verified successfully",
|
||||
"logRegisterFailed": "Registration verification failed",
|
||||
"logUnregisterStart": "Unregistering...",
|
||||
"logUnregisterSuccess": "Unregistered successfully",
|
||||
"logUnregisterFailed": "Unregister failed",
|
||||
"logMaxRetries": "Max retries (7) reached"
|
||||
},
|
||||
"elevation": {
|
||||
"title": "Administrator Privileges",
|
||||
"description": "Check if the application is running with administrator/root privileges. Some features (like port listening, system-level operations) require elevated privileges.",
|
||||
"elevated": "Elevated",
|
||||
"notElevated": "Not Elevated",
|
||||
"request": "Request Admin Privileges",
|
||||
"requestSuccess": "Admin privileges requested, application will restart",
|
||||
"requestFailed": "Failed to request admin privileges",
|
||||
"windowsHint": "Windows: A UAC prompt will appear. After confirmation, the app will restart as administrator.",
|
||||
"macosHint": "macOS: A system authentication dialog will appear. Enter admin password to restart the app.",
|
||||
"linuxHint": "Linux: Uses pkexec to request root privileges. Enter password to restart the app."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Service",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"appearance": "Apariencia",
|
||||
"security": "Seguridad",
|
||||
"account": "Cuenta",
|
||||
"database": "Conexión a base de datos",
|
||||
"database": "Sincronización en la nube",
|
||||
"dataManagement": "Gestión de datos",
|
||||
"urlProtocol": "Protocolo URL",
|
||||
"about": "Acerca de"
|
||||
@@ -255,6 +255,45 @@
|
||||
"uploadSuccess": "Datos locales cargados al servidor remoto",
|
||||
"uploadFailed": "Fallo al cargar"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "Sincronización en la nube",
|
||||
"selectMethod": "Seleccionar método de sincronización",
|
||||
"selectMethodHint": "Elija un método de sincronización de datos. Los dos métodos son mutuamente excluyentes.",
|
||||
"postgresql": "PostgreSQL auto-alojado",
|
||||
"postgresqlDesc": "Conéctese a su propia base de datos PostgreSQL para control total. Ideal para usuarios con servidor propio.",
|
||||
"sectlCloud": "SECTL Cloud Sync",
|
||||
"sectlCloudDesc": "Sincronice datos a través de la plataforma cloud SECTL. Sin base de datos propia necesaria.",
|
||||
"loginRequired": "Por favor, inicie sesión en SECTL Auth primero",
|
||||
"loginHint": "La sincronización cloud SECTL requiere una cuenta SECTL Auth. Inicie sesión en la pestaña 'Cuenta'.",
|
||||
"statusTitle": "Estado de sincronización",
|
||||
"configured": "Listo",
|
||||
"notConfigured": "No listo",
|
||||
"syncing": "Sincronizando...",
|
||||
"lastSync": "Última sincronización",
|
||||
"never": "Nunca sincronizado",
|
||||
"syncActions": "Acciones de sincronización",
|
||||
"syncDirection": "Dirección de sincronización",
|
||||
"bidirectional": "Bidireccional (Recomendado) — Fusionar automáticamente datos locales y en la nube",
|
||||
"pushOnly": "Solo subir — Sobrescribir datos en la nube con datos locales",
|
||||
"pullOnly": "Solo descargar — Sobrescribir datos locales con datos en la nube",
|
||||
"startSync": "Iniciar sincronización",
|
||||
"uploadToCloud": "Subir a la nube",
|
||||
"downloadFromCloud": "Descargar de la nube",
|
||||
"fullSync": "Sincronización bidireccional",
|
||||
"syncInProgress": "Sincronización en progreso, por favor espere",
|
||||
"syncConfirm": "¿Confirmar {{direction}}?",
|
||||
"storageUsage": "Uso de almacenamiento",
|
||||
"usedStorage": "Usado",
|
||||
"table": "Tabla",
|
||||
"records": "Registros",
|
||||
"size": "Tamaño",
|
||||
"clickToLoadStats": "Haga clic en actualizar para cargar estadísticas",
|
||||
"description": "Guía de sincronización cloud SECTL",
|
||||
"descriptionPoint1": "Sincronice datos a través del servicio cloud SECTL. Sin base de datos propia. Los datos se aíslan por cuenta SECTL Auth.",
|
||||
"descriptionPoint2": "Basado en arquitectura de almacenamiento KV. Cada tabla se almacena como par clave-valor.",
|
||||
"descriptionPoint3": "Soporta modos bidireccional, solo subida y solo descarga.",
|
||||
"descriptionPoint4": "Los datos se transmiten por HTTPS con autenticación OAuth 2.0."
|
||||
},
|
||||
"data": {
|
||||
"title": "Gestión de datos",
|
||||
"settlement": "Liquidación",
|
||||
@@ -301,7 +340,39 @@
|
||||
"register": "Registrar protocolo URL",
|
||||
"registered": "Protocolo URL registrado",
|
||||
"registerFailed": "Fallo al registrar",
|
||||
"installerRequired": "Requiere la versión instalada de SecScore, puede no funcionar en modo desarrollo."
|
||||
"installerRequired": "Requiere la versión instalada de SecScore, puede no funcionar en modo desarrollo.",
|
||||
"checkStatus": "Verificar estado",
|
||||
"reRegister": "Re-registrar",
|
||||
"unregister": "Anular registro",
|
||||
"unregistered": "Registro del protocolo URL anulado",
|
||||
"unregisterFailed": "Error al anular el registro",
|
||||
"statusRegistered": "Registrado",
|
||||
"statusNotRegistered": "No registrado",
|
||||
"statusCheckFailed": "Error al verificar el estado",
|
||||
"retrying": "Reintentando ({{current}}/{{max}})",
|
||||
"retryFailed": "Se alcanzó el máximo de reintentos, fallo en el registro",
|
||||
"retrySuccess": "Reintento #{{n}} exitoso",
|
||||
"operationLog": "Registro de operaciones",
|
||||
"logRegisterStart": "Iniciando registro del protocolo URL...",
|
||||
"logRegisterVerify": "Comando de registro ejecutado, verificando...",
|
||||
"logRegisterSuccess": "Registro verificado exitosamente",
|
||||
"logRegisterFailed": "Error en la verificación del registro",
|
||||
"logUnregisterStart": "Anulando registro...",
|
||||
"logUnregisterSuccess": "Registro anulado exitosamente",
|
||||
"logUnregisterFailed": "Error al anular el registro",
|
||||
"logMaxRetries": "Se alcanzó el máximo de reintentos (7)"
|
||||
},
|
||||
"elevation": {
|
||||
"title": "Privilegios de administrador",
|
||||
"description": "Verificar si la aplicación se ejecuta con privilegios de administrador/root. Algunas funciones (escucha de puertos, operaciones de sistema) requieren privilegios elevados.",
|
||||
"elevated": "Elevado",
|
||||
"notElevated": "No elevado",
|
||||
"request": "Solicitar privilegios de admin",
|
||||
"requestSuccess": "Privilegios de admin solicitados, la aplicación se reiniciará",
|
||||
"requestFailed": "Error al solicitar privilegios de admin",
|
||||
"windowsHint": "Windows: Aparecerá un prompt UAC. Después de confirmar, la app se reiniciará como administrador.",
|
||||
"macosHint": "macOS: Aparecerá un diálogo de autenticación del sistema. Ingrese la contraseña de admin para reiniciar.",
|
||||
"linuxHint": "Linux: Usa pkexec para solicitar privilegios root. Ingrese la contraseña para reiniciar."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Servicio MCP",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"appearance": "Apparence",
|
||||
"security": "Sécurité",
|
||||
"account": "Compte",
|
||||
"database": "Connexion à la base de données",
|
||||
"database": "Synchronisation cloud",
|
||||
"dataManagement": "Gestion des données",
|
||||
"urlProtocol": "Protocole URL",
|
||||
"about": "À propos"
|
||||
@@ -255,6 +255,45 @@
|
||||
"uploadSuccess": "Données locales téléchargées vers le serveur distant",
|
||||
"uploadFailed": "Échec du téléchargement"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "Synchronisation cloud",
|
||||
"selectMethod": "Choisir la méthode de synchronisation",
|
||||
"selectMethodHint": "Choisissez une méthode de synchronisation des données. Les deux méthodes sont mutuellement exclusives.",
|
||||
"postgresql": "PostgreSQL auto-hébergé",
|
||||
"postgresqlDesc": "Connectez-vous à votre propre base de données PostgreSQL pour un contrôle total. Idéal pour les utilisateurs ayant leur propre serveur.",
|
||||
"sectlCloud": "SECTL Cloud Sync",
|
||||
"sectlCloudDesc": "Synchronisez les données via la plateforme cloud SECTL. Aucune base de données auto-hébergée nécessaire.",
|
||||
"loginRequired": "Veuillez vous connecter à SECTL Auth d'abord",
|
||||
"loginHint": "La synchronisation cloud SECTL nécessite un compte SECTL Auth. Veuillez vous connecter dans l'onglet « Compte ».",
|
||||
"statusTitle": "État de la synchronisation",
|
||||
"configured": "Prêt",
|
||||
"notConfigured": "Non prêt",
|
||||
"syncing": "Synchronisation...",
|
||||
"lastSync": "Dernière sync",
|
||||
"never": "Jamais synchronisé",
|
||||
"syncActions": "Actions de synchronisation",
|
||||
"syncDirection": "Direction de synchronisation",
|
||||
"bidirectional": "Bidirectionnelle (Recommandé) — Fusion automatique des données locales et cloud",
|
||||
"pushOnly": "Upload uniquement — Écraser les données cloud avec les données locales",
|
||||
"pullOnly": "Download uniquement — Écraser les données locales avec les données cloud",
|
||||
"startSync": "Démarrer la synchronisation",
|
||||
"uploadToCloud": "Uploader vers le cloud",
|
||||
"downloadFromCloud": "Télécharger depuis le cloud",
|
||||
"fullSync": "Synchronisation bidirectionnelle",
|
||||
"syncInProgress": "Synchronisation en cours, veuillez patienter",
|
||||
"syncConfirm": "Confirmer {{direction}} ?",
|
||||
"storageUsage": "Utilisation du stockage",
|
||||
"usedStorage": "Utilisé",
|
||||
"table": "Table",
|
||||
"records": "Enregistrements",
|
||||
"size": "Taille",
|
||||
"clickToLoadStats": "Cliquez sur actualiser pour charger les stats",
|
||||
"description": "Guide de synchronisation cloud SECTL",
|
||||
"descriptionPoint1": "Synchronisez les données via le service cloud SECTL. Aucune base de données auto-hébergée nécessaire. Les données sont isolées par compte SECTL Auth.",
|
||||
"descriptionPoint2": "Basé sur l'architecture de stockage KV. Chaque table est stockée en paire clé-valeur pour une lecture/écriture efficace.",
|
||||
"descriptionPoint3": "Prend en charge les modes bidirectionnel, upload uniquement et download uniquement.",
|
||||
"descriptionPoint4": "Les données sont transmises via HTTPS avec authentification OAuth 2.0."
|
||||
},
|
||||
"data": {
|
||||
"title": "Gestion des données",
|
||||
"settlement": "Règlement",
|
||||
@@ -301,7 +340,39 @@
|
||||
"register": "Enregistrer le protocole URL",
|
||||
"registered": "Protocole URL enregistré",
|
||||
"registerFailed": "Échec de l'enregistrement",
|
||||
"installerRequired": "Nécessite la version installée de SecScore, peut ne pas fonctionner en mode développement."
|
||||
"installerRequired": "Nécessite la version installée de SecScore, peut ne pas fonctionner en mode développement.",
|
||||
"checkStatus": "Vérifier le statut",
|
||||
"reRegister": "Ré-enregistrer",
|
||||
"unregister": "Désinscrire",
|
||||
"unregistered": "Protocole URL désinscrit",
|
||||
"unregisterFailed": "Échec de la désinscription",
|
||||
"statusRegistered": "Inscrit",
|
||||
"statusNotRegistered": "Non inscrit",
|
||||
"statusCheckFailed": "Échec de la vérification du statut",
|
||||
"retrying": "Nouvelle tentative ({{current}}/{{max}})",
|
||||
"retryFailed": "Nombre maximal de tentatives atteint, échec de l'inscription",
|
||||
"retrySuccess": "Tentative #{{n}} réussie",
|
||||
"operationLog": "Journal des opérations",
|
||||
"logRegisterStart": "Démarrage de l'inscription du protocole URL...",
|
||||
"logRegisterVerify": "Commande d'inscription exécution, vérification en cours...",
|
||||
"logRegisterSuccess": "Inscription vérifiée avec succès",
|
||||
"logRegisterFailed": "Échec de la vérification de l'inscription",
|
||||
"logUnregisterStart": "Désinscription en cours...",
|
||||
"logUnregisterSuccess": "Désinscription réussie",
|
||||
"logUnregisterFailed": "Échec de la désinscription",
|
||||
"logMaxRetries": "Nombre maximal de tentatives (7) atteint"
|
||||
},
|
||||
"elevation": {
|
||||
"title": "Privilèges administrateur",
|
||||
"description": "Vérifier si l'application s'exécute avec des privilèges administrateur/root. Certaines fonctionnalités (écoute de port, opérations système) nécessitent des privilèges élevés.",
|
||||
"elevated": "Élevé",
|
||||
"notElevated": "Non élevé",
|
||||
"request": "Demander les privilèges admin",
|
||||
"requestSuccess": "Privilèges admin demandés, l'application va redémarrer",
|
||||
"requestFailed": "Échec de la demande de privilèges admin",
|
||||
"windowsHint": "Windows : une invite UAC apparaîtra. Après confirmation, l'app redémarrera en tant qu'administrateur.",
|
||||
"macosHint": "macOS : une boîte de dialogue d'authentification système apparaîtra. Entrez le mot de passe admin pour redémarrer.",
|
||||
"linuxHint": "Linux : utilise pkexec pour demander les privilèges root. Entrez le mot de passe pour redémarrer."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Service MCP",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"appearance": "外観",
|
||||
"security": "セキュリティ",
|
||||
"account": "アカウント",
|
||||
"database": "データベース接続",
|
||||
"database": "クラウド同期",
|
||||
"dataManagement": "データ管理",
|
||||
"urlProtocol": "URLプロトコル",
|
||||
"about": "について"
|
||||
@@ -253,7 +253,46 @@
|
||||
"uploadConfirmTitle": "ローカルデータをリモートにアップロードしますか?",
|
||||
"uploadConfirmContent": "ローカルデータを優先して同期します。ローカルのみ {{localOnly}}、リモートのみ {{remoteOnly}}、競合 {{conflicts}} を検出。",
|
||||
"uploadSuccess": "ローカルデータをリモートにアップロードしました",
|
||||
"uploadFailed": "アップロードに失敗しました"
|
||||
"uploadFailed": "アップロード失敗"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "クラウド同期",
|
||||
"selectMethod": "同期方法を選択",
|
||||
"selectMethodHint": "データ同期方法を選択してください。2つの方法は排他で、切り替えると新しい同期チャネルが使用されます。",
|
||||
"postgresql": "自建 PostgreSQL データベース",
|
||||
"postgresqlDesc": "自建の PostgreSQL データベースに接続し、データを完全に管理できます。サーバーをお持ちのユーザーに最適です。",
|
||||
"sectlCloud": "SECTL クラウド同期",
|
||||
"sectlCloudDesc": "SECTL クラウドプラットフォームでデータ同期。自建データベース不要、すぐに使えます。",
|
||||
"loginRequired": "先に SECTL Auth アカウントにログインしてください",
|
||||
"loginHint": "SECTL クラウド同期には SECTL Auth アカウントが必要です。「アカウント」タブからログインしてください。",
|
||||
"statusTitle": "同期状態",
|
||||
"configured": "準備完了",
|
||||
"notConfigured": "未準備",
|
||||
"syncing": "同期中...",
|
||||
"lastSync": "前回の同期",
|
||||
"never": "未同期",
|
||||
"syncActions": "同期操作",
|
||||
"syncDirection": "同期方向",
|
||||
"bidirectional": "双方向同期(推奨)— ローカルとクラウドのデータを自動的にマージ",
|
||||
"pushOnly": "アップロードのみ — ローカルデータでクラウドデータを上書き",
|
||||
"pullOnly": "ダウンロードのみ — クラウドデータでローカルデータを上書き",
|
||||
"startSync": "同期開始",
|
||||
"uploadToCloud": "クラウドにアップロード",
|
||||
"downloadFromCloud": "クラウドからダウンロード",
|
||||
"fullSync": "双方向同期",
|
||||
"syncInProgress": "同期中です。お待ちください",
|
||||
"syncConfirm": "{{direction}}を実行しますか?",
|
||||
"storageUsage": "ストレージ使用量",
|
||||
"usedStorage": "使用済み",
|
||||
"table": "テーブル",
|
||||
"records": "レコード数",
|
||||
"size": "サイズ",
|
||||
"clickToLoadStats": "更新ボタンをクリックして統計を読み込む",
|
||||
"description": "SECTL クラウド同期ガイド",
|
||||
"descriptionPoint1": "SECTL クラウドサービスでデータ同期。自建データベース不要。データは SECTL Auth アカウントで分離されます。",
|
||||
"descriptionPoint2": "KV ストレージアーキテクチャに基づき、各データテーブルはキーバリューペアとして保存され、効率的な読み書きを実現。",
|
||||
"descriptionPoint3": "双方向、アップロードのみ、ダウンロードのみの3つのモードをサポート。",
|
||||
"descriptionPoint4": "データは HTTPS で暗号化され、OAuth 2.0 認証でセキュリティを確保。"
|
||||
},
|
||||
"data": {
|
||||
"title": "データ管理",
|
||||
@@ -301,7 +340,39 @@
|
||||
"register": "URLプロトコルを登録",
|
||||
"registered": "URLプロトコルが登録されました",
|
||||
"registerFailed": "登録に失敗しました",
|
||||
"installerRequired": "インストール版のSecScoreが必要です。開発モードでは動作しない場合があります。"
|
||||
"installerRequired": "インストール版のSecScoreが必要です。開発モードでは動作しない場合があります。",
|
||||
"checkStatus": "ステータス確認",
|
||||
"reRegister": "再登録",
|
||||
"unregister": "登録解除",
|
||||
"unregistered": "URLプロトコルの登録が解除されました",
|
||||
"unregisterFailed": "登録解除に失敗しました",
|
||||
"statusRegistered": "登録済み",
|
||||
"statusNotRegistered": "未登録",
|
||||
"statusCheckFailed": "ステータス確認に失敗しました",
|
||||
"retrying": "リトライ中 ({{current}}/{{max}})",
|
||||
"retryFailed": "最大リトライ回数に達しました。登録に失敗しました",
|
||||
"retrySuccess": "リトライ #{{n}} が成功しました",
|
||||
"operationLog": "操作ログ",
|
||||
"logRegisterStart": "URLプロトコルの登録を開始...",
|
||||
"logRegisterVerify": "登録コマンドを実行しました。検証中...",
|
||||
"logRegisterSuccess": "登録の検証に成功しました",
|
||||
"logRegisterFailed": "登録の検証に失敗しました",
|
||||
"logUnregisterStart": "登録解除中...",
|
||||
"logUnregisterSuccess": "登録解除に成功しました",
|
||||
"logUnregisterFailed": "登録解除に失敗しました",
|
||||
"logMaxRetries": "最大リトライ回数 (7回) に達しました"
|
||||
},
|
||||
"elevation": {
|
||||
"title": "管理者権限",
|
||||
"description": "アプリケーションが管理者/root権限で実行されているか確認します。一部の機能(ポートリッスニング、システムレベル操作など)には昇格権限が必要です。",
|
||||
"elevated": "昇格済み",
|
||||
"notElevated": "未昇格",
|
||||
"request": "管理者権限をリクエスト",
|
||||
"requestSuccess": "管理者権限をリクエストしました。アプリが再起動します",
|
||||
"requestFailed": "管理者権限のリクエストに失敗しました",
|
||||
"windowsHint": "Windows:UAC プロンプトが表示されます。確認後、アプリが管理者として再起動します。",
|
||||
"macosHint": "macOS:システム認証ダイアログが表示されます。管理者パスワードを入力してアプリを再起動します。",
|
||||
"linuxHint": "Linux:pkexec を使用して root 権限をリクエストします。パスワードを入力してアプリを再起動します。"
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCPサービス",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"appearance": "모양새",
|
||||
"security": "보안",
|
||||
"account": "계정",
|
||||
"database": "데이터베이스 연결",
|
||||
"database": "클라우드 동기화",
|
||||
"dataManagement": "데이터 관리",
|
||||
"urlProtocol": "URL 프로토콜",
|
||||
"about": "정보"
|
||||
@@ -255,6 +255,45 @@
|
||||
"uploadSuccess": "로컬 데이터가 원격 서버에 업로드되었습니다",
|
||||
"uploadFailed": "업로드 실패"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "클라우드 동기화",
|
||||
"selectMethod": "동기화 방법 선택",
|
||||
"selectMethodHint": "데이터 동기화 방법을 선택하세요. 두 방법은 상호 배제적이며, 전환 시 새 동기화 채널을 사용합니다.",
|
||||
"postgresql": "자체 호스팅 PostgreSQL",
|
||||
"postgresqlDesc": "자체 PostgreSQL 데이터베이스에 연결하여 데이터를 완전히 제어합니다. 서버가 있는 사용자에게 적합합니다.",
|
||||
"sectlCloud": "SECTL 클라우드 동기화",
|
||||
"sectlCloudDesc": "SECTL 클라우드 플랫폼을 통해 데이터를 동기화합니다. 자체 데이터베이스 불필요, 즉시 사용 가능.",
|
||||
"loginRequired": "먼저 SECTL Auth에 로그인하세요",
|
||||
"loginHint": "SECTL 클라우드 동기화에는 SECTL Auth 계정이 필요합니다. '계정' 탭에서 로그인하세요.",
|
||||
"statusTitle": "동기화 상태",
|
||||
"configured": "준비 완료",
|
||||
"notConfigured": "준비 안 됨",
|
||||
"syncing": "동기화 중...",
|
||||
"lastSync": "마지막 동기화",
|
||||
"never": "동기화 안 됨",
|
||||
"syncActions": "동기화 작업",
|
||||
"syncDirection": "동기화 방향",
|
||||
"bidirectional": "양방향 동기화 (권장) — 로컬과 클라우드 데이터를 자동으로 병합",
|
||||
"pushOnly": "업로드만 — 로컬 데이터로 클라우드 데이터 덮어쓰기",
|
||||
"pullOnly": "다운로드만 — 클라우드 데이터로 로컬 데이터 덮어쓰기",
|
||||
"startSync": "동기화 시작",
|
||||
"uploadToCloud": "클라우드에 업로드",
|
||||
"downloadFromCloud": "클라우드에서 다운로드",
|
||||
"fullSync": "양방향 동기화",
|
||||
"syncInProgress": "동기화 진행 중, 잠시만 기다려주세요",
|
||||
"syncConfirm": "{{direction}}를 실행하시겠습니까?",
|
||||
"storageUsage": "저장소 사용량",
|
||||
"usedStorage": "사용됨",
|
||||
"table": "테이블",
|
||||
"records": "레코드 수",
|
||||
"size": "크기",
|
||||
"clickToLoadStats": "새로고침 버튼을 클릭하여 통계를 로드하세요",
|
||||
"description": "SECTL 클라우드 동기화 가이드",
|
||||
"descriptionPoint1": "SECTL 클라우드 서비스로 데이터를 동기화합니다. 자체 데이터베이스 불필요. 데이터는 SECTL Auth 계정으로 격리됩니다.",
|
||||
"descriptionPoint2": "KV 저장소 아키텍처 기반. 각 데이터 테이블은 키-값 쌍으로 저장되어 효율적인 읽기/쓰기를 지원합니다.",
|
||||
"descriptionPoint3": "양방향, 업로드만, 다운로드만 세 가지 모드를 지원합니다.",
|
||||
"descriptionPoint4": "데이터는 HTTPS로 전송되며 OAuth 2.0 인증으로 보안을 보장합니다."
|
||||
},
|
||||
"data": {
|
||||
"title": "데이터 관리",
|
||||
"settlement": "결제",
|
||||
@@ -301,7 +340,39 @@
|
||||
"register": "URL 프로토콜 등록",
|
||||
"registered": "URL 프로토콜이 등록되었습니다",
|
||||
"registerFailed": "등록 실패",
|
||||
"installerRequired": "설치된 SecScore 버전이 필요합니다. 개발 모드에서는 작동하지 않을 수 있습니다."
|
||||
"installerRequired": "설치된 SecScore 버전이 필요합니다. 개발 모드에서는 작동하지 않을 수 있습니다.",
|
||||
"checkStatus": "상태 확인",
|
||||
"reRegister": "재등록",
|
||||
"unregister": "등록 해제",
|
||||
"unregistered": "URL 프로토콜 등록이 해제되었습니다",
|
||||
"unregisterFailed": "등록 해제 실패",
|
||||
"statusRegistered": "등록됨",
|
||||
"statusNotRegistered": "등록되지 않음",
|
||||
"statusCheckFailed": "상태 확인 실패",
|
||||
"retrying": "재시도 중 ({{current}}/{{max}})",
|
||||
"retryFailed": "최대 재시도 횟수에 도달했습니다. 등록 실패",
|
||||
"retrySuccess": "재시도 #{{n}} 성공",
|
||||
"operationLog": "작업 로그",
|
||||
"logRegisterStart": "URL 프로토콜 등록 시작...",
|
||||
"logRegisterVerify": "등록 명령 실행됨, 확인 중...",
|
||||
"logRegisterSuccess": "등록 확인 성공",
|
||||
"logRegisterFailed": "등록 확인 실패",
|
||||
"logUnregisterStart": "등록 해제 중...",
|
||||
"logUnregisterSuccess": "등록 해제 성공",
|
||||
"logUnregisterFailed": "등록 해제 실패",
|
||||
"logMaxRetries": "최대 재시도 횟수 (7회) 도달"
|
||||
},
|
||||
"elevation": {
|
||||
"title": "관리자 권한",
|
||||
"description": "응용 프로그램이 관리자/root 권한으로 실행 중인지 확인합니다. 일부 기능(포트 리스닝, 시스템 수준 작업)에는 권한 상승이 필요합니다.",
|
||||
"elevated": "권한 상승됨",
|
||||
"notElevated": "권한 상승 안 됨",
|
||||
"request": "관리자 권한 요청",
|
||||
"requestSuccess": "관리자 권한이 요청되었습니다. 앱이 재시작됩니다",
|
||||
"requestFailed": "관리자 권한 요청 실패",
|
||||
"windowsHint": "Windows: UAC 프롬프트가 나타납니다. 확인 후 앱이 관리자로 재시작됩니다.",
|
||||
"macosHint": "macOS: 시스템 인증 대화 상자가 나타납니다. 관리자 비밀번호를 입력하여 앱을 재시작합니다.",
|
||||
"linuxHint": "Linux: pkexec를 사용하여 root 권한을 요청합니다. 비밀번호를 입력하여 앱을 재시작합니다."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP 서비스",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"appearance": "Aparência",
|
||||
"security": "Segurança",
|
||||
"account": "Conta",
|
||||
"database": "Conexão com banco de dados",
|
||||
"database": "Sincronização na nuvem",
|
||||
"dataManagement": "Gerenciamento de dados",
|
||||
"urlProtocol": "Protocolo URL",
|
||||
"about": "Sobre"
|
||||
@@ -255,6 +255,45 @@
|
||||
"uploadSuccess": "Dados locais carregados para o servidor remoto",
|
||||
"uploadFailed": "Falha ao carregar"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "Sincronização na nuvem",
|
||||
"selectMethod": "Selecionar método de sincronização",
|
||||
"selectMethodHint": "Escolha um método de sincronização de dados. Os dois métodos são mutuamente exclusivos.",
|
||||
"postgresql": "PostgreSQL auto-hospedado",
|
||||
"postgresqlDesc": "Conecte-se ao seu próprio banco de dados PostgreSQL para controle total. Ideal para usuários com servidor próprio.",
|
||||
"sectlCloud": "SECTL Cloud Sync",
|
||||
"sectlCloudDesc": "Sincronize dados pela plataforma cloud SECTL. Sem necessidade de banco de dados próprio.",
|
||||
"loginRequired": "Por favor, faça login no SECTL Auth primeiro",
|
||||
"loginHint": "A sincronização cloud SECTL requer uma conta SECTL Auth. Faça login na aba 'Conta'.",
|
||||
"statusTitle": "Status da sincronização",
|
||||
"configured": "Pronto",
|
||||
"notConfigured": "Não pronto",
|
||||
"syncing": "Sincronizando...",
|
||||
"lastSync": "Última sincronização",
|
||||
"never": "Nunca sincronizado",
|
||||
"syncActions": "Ações de sincronização",
|
||||
"syncDirection": "Direção da sincronização",
|
||||
"bidirectional": "Bidirecional (Recomendado) — Mesclar automaticamente dados locais e na nuvem",
|
||||
"pushOnly": "Apenas upload — Sobrescrever dados na nuvem com dados locais",
|
||||
"pullOnly": "Apenas download — Sobrescrever dados locais com dados na nuvem",
|
||||
"startSync": "Iniciar sincronização",
|
||||
"uploadToCloud": "Enviar para a nuvem",
|
||||
"downloadFromCloud": "Baixar da nuvem",
|
||||
"fullSync": "Sincronização bidirecional",
|
||||
"syncInProgress": "Sincronização em andamento, aguarde",
|
||||
"syncConfirm": "Confirmar {{direction}}?",
|
||||
"storageUsage": "Uso de armazenamento",
|
||||
"usedStorage": "Usado",
|
||||
"table": "Tabela",
|
||||
"records": "Registros",
|
||||
"size": "Tamanho",
|
||||
"clickToLoadStats": "Clique em atualizar para carregar estatísticas",
|
||||
"description": "Guia de sincronização cloud SECTL",
|
||||
"descriptionPoint1": "Sincronize dados pelo serviço cloud SECTL. Sem banco de dados próprio. Dados isolados por conta SECTL Auth.",
|
||||
"descriptionPoint2": "Baseado em arquitetura de armazenamento KV. Cada tabela é armazenada como par chave-valor.",
|
||||
"descriptionPoint3": "Suporta modos bidirecional, apenas upload e apenas download.",
|
||||
"descriptionPoint4": "Dados transmitidos por HTTPS com autenticação OAuth 2.0."
|
||||
},
|
||||
"data": {
|
||||
"title": "Gerenciamento de dados",
|
||||
"settlement": "Liquidação",
|
||||
@@ -301,7 +340,39 @@
|
||||
"register": "Registrar protocolo URL",
|
||||
"registered": "Protocolo URL registrado",
|
||||
"registerFailed": "Falha ao registrar",
|
||||
"installerRequired": "Requer a versão instalada do SecScore, pode não funcionar no modo de desenvolvimento."
|
||||
"installerRequired": "Requer a versão instalada do SecScore, pode não funcionar no modo de desenvolvimento.",
|
||||
"checkStatus": "Verificar status",
|
||||
"reRegister": "Re-registrar",
|
||||
"unregister": "Cancelar registro",
|
||||
"unregistered": "Registro do protocolo URL cancelado",
|
||||
"unregisterFailed": "Falha ao cancelar o registro",
|
||||
"statusRegistered": "Registrado",
|
||||
"statusNotRegistered": "Não registrado",
|
||||
"statusCheckFailed": "Falha ao verificar o status",
|
||||
"retrying": "Tentando novamente ({{current}}/{{max}})",
|
||||
"retryFailed": "Número máximo de tentativas atingido, falha no registro",
|
||||
"retrySuccess": "Tentativa #{{n}} bem-sucedida",
|
||||
"operationLog": "Log de operações",
|
||||
"logRegisterStart": "Iniciando registro do protocolo URL...",
|
||||
"logRegisterVerify": "Comando de registro executado, verificando...",
|
||||
"logRegisterSuccess": "Registro verificado com sucesso",
|
||||
"logRegisterFailed": "Falha na verificação do registro",
|
||||
"logUnregisterStart": "Cancelando registro...",
|
||||
"logUnregisterSuccess": "Registro cancelado com sucesso",
|
||||
"logUnregisterFailed": "Falha ao cancelar o registro",
|
||||
"logMaxRetries": "Número máximo de tentativas (7) atingido"
|
||||
},
|
||||
"elevation": {
|
||||
"title": "Privilégios de administrador",
|
||||
"description": "Verificar se o aplicativo está sendo executado com privilégios de administrador/root. Algumas funcionalidades (escuta de porta, operações de sistema) requerem privilégios elevados.",
|
||||
"elevated": "Elevado",
|
||||
"notElevated": "Não elevado",
|
||||
"request": "Solicitar privilégios de admin",
|
||||
"requestSuccess": "Privilégios de admin solicitados, o aplicativo será reiniciado",
|
||||
"requestFailed": "Falha ao solicitar privilégios de admin",
|
||||
"windowsHint": "Windows: Um prompt UAC aparecerá. Após confirmação, o app será reiniciado como administrador.",
|
||||
"macosHint": "macOS: Um diálogo de autenticação do sistema aparecerá. Digite a senha de admin para reiniciar.",
|
||||
"linuxHint": "Linux: Usa pkexec para solicitar privilégios root. Digite a senha para reiniciar."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Serviço MCP",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"appearance": "Внешний вид",
|
||||
"security": "Безопасность",
|
||||
"account": "Учетная запись",
|
||||
"database": "Подключение к базе данных",
|
||||
"database": "Облачная синхронизация",
|
||||
"dataManagement": "Управление данными",
|
||||
"urlProtocol": "Протокол URL",
|
||||
"about": "О программе"
|
||||
@@ -255,6 +255,45 @@
|
||||
"uploadSuccess": "Локальные данные загружены на удаленный сервер",
|
||||
"uploadFailed": "Ошибка загрузки"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "Облачная синхронизация",
|
||||
"selectMethod": "Выберите метод синхронизации",
|
||||
"selectMethodHint": "Выберите метод синхронизации данных. Два метода взаимоисключающие.",
|
||||
"postgresql": "Самостоятельный PostgreSQL",
|
||||
"postgresqlDesc": "Подключитесь к собственной базе данных PostgreSQL для полного контроля данных.",
|
||||
"sectlCloud": "SECTL Облачная синхронизация",
|
||||
"sectlCloudDesc": "Синхронизация данных через облачную платформу SECTL. Без собственной базы данных.",
|
||||
"loginRequired": "Пожалуйста, войдите в SECTL Auth",
|
||||
"loginHint": "Для облачной синхронизации SECTL требуется аккаунт SECTL Auth.",
|
||||
"statusTitle": "Статус синхронизации",
|
||||
"configured": "Готово",
|
||||
"notConfigured": "Не готово",
|
||||
"syncing": "Синхронизация...",
|
||||
"lastSync": "Последняя синхронизация",
|
||||
"never": "Никогда",
|
||||
"syncActions": "Действия синхронизации",
|
||||
"syncDirection": "Направление синхронизации",
|
||||
"bidirectional": "Двунаправленная (Рекомендуется) — Автоматическое слияние локальных и облачных данных",
|
||||
"pushOnly": "Только загрузка — Перезаписать облачные данные локальными",
|
||||
"pullOnly": "Только скачивание — Перезаписать локальные данные облачными",
|
||||
"startSync": "Начать синхронизацию",
|
||||
"uploadToCloud": "Загрузить в облако",
|
||||
"downloadFromCloud": "Скачать из облака",
|
||||
"fullSync": "Двунаправленная синхронизация",
|
||||
"syncInProgress": "Синхронизация выполняется, подождите",
|
||||
"syncConfirm": "Подтвердить {{direction}}?",
|
||||
"storageUsage": "Использование хранилища",
|
||||
"usedStorage": "Использовано",
|
||||
"table": "Таблица",
|
||||
"records": "Записи",
|
||||
"size": "Размер",
|
||||
"clickToLoadStats": "Нажмите обновить для загрузки статистики",
|
||||
"description": "Руководство по облачной синхронизации SECTL",
|
||||
"descriptionPoint1": "Синхронизация данных через облачный сервис SECTL. Без собственной базы данных.",
|
||||
"descriptionPoint2": "На основе архитектуры KV-хранилища. Каждая таблица хранится как пара ключ-значение.",
|
||||
"descriptionPoint3": "Поддержка двунаправленного, только загрузки и только скачивания режимов.",
|
||||
"descriptionPoint4": "Данные передаются по HTTPS с аутентификацией OAuth 2.0."
|
||||
},
|
||||
"data": {
|
||||
"title": "Управление данными",
|
||||
"settlement": "Расчет",
|
||||
@@ -301,7 +340,39 @@
|
||||
"register": "Зарегистрировать протокол URL",
|
||||
"registered": "Протокол URL зарегистрирован",
|
||||
"registerFailed": "Ошибка регистрации",
|
||||
"installerRequired": "Требуется установленная версия SecScore, может не работать в режиме разработки."
|
||||
"installerRequired": "Требуется установленная версия SecScore, может не работать в режиме разработки.",
|
||||
"checkStatus": "Проверить статус",
|
||||
"reRegister": "Перерегистрировать",
|
||||
"unregister": "Отменить регистрацию",
|
||||
"unregistered": "Регистрация протокола URL отменена",
|
||||
"unregisterFailed": "Не удалось отменить регистрацию",
|
||||
"statusRegistered": "Зарегистрирован",
|
||||
"statusNotRegistered": "Не зарегистрирован",
|
||||
"statusCheckFailed": "Ошибка проверки статуса",
|
||||
"retrying": "Повторная попытка ({{current}}/{{max}})",
|
||||
"retryFailed": "Достигнуто максимальное количество попыток, ошибка регистрации",
|
||||
"retrySuccess": "Повторная попытка #{{n}} успешна",
|
||||
"operationLog": "Журнал операций",
|
||||
"logRegisterStart": "Начало регистрации протокола URL...",
|
||||
"logRegisterVerify": "Команда регистрации выполнена, проверка...",
|
||||
"logRegisterSuccess": "Регистрация успешно подтверждена",
|
||||
"logRegisterFailed": "Ошибка подтверждения регистрации",
|
||||
"logUnregisterStart": "Отмена регистрации...",
|
||||
"logUnregisterSuccess": "Регистрация успешно отменена",
|
||||
"logUnregisterFailed": "Не удалось отменить регистрацию",
|
||||
"logMaxRetries": "Достигнуто максимальное количество попыток (7)"
|
||||
},
|
||||
"elevation": {
|
||||
"title": "Права администратора",
|
||||
"description": "Проверить, запущено ли приложение с правами администратора/root. Некоторые функции (прослушивание портов, системные операции) требуют повышенных привилегий.",
|
||||
"elevated": "Повышено",
|
||||
"notElevated": "Не повышено",
|
||||
"request": "Запросить права администратора",
|
||||
"requestSuccess": "Права администратора запрошены, приложение перезапустится",
|
||||
"requestFailed": "Не удалось запросить права администратора",
|
||||
"windowsHint": "Windows: Появится запрос UAC. После подтверждения приложение перезапустится от имени администратора.",
|
||||
"macosHint": "macOS: Появится диалог аутентификации. Введите пароль администратора для перезапуска.",
|
||||
"linuxHint": "Linux: Используется pkexec для запроса root-прав. Введите пароль для перезапуска."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Сервис MCP",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"appearance": "外观",
|
||||
"security": "安全",
|
||||
"account": "账户",
|
||||
"database": "数据库连接",
|
||||
"database": "云同步",
|
||||
"dataManagement": "数据管理",
|
||||
"urlProtocol": "URL 链接",
|
||||
"about": "关于"
|
||||
@@ -255,6 +255,45 @@
|
||||
"uploadSuccess": "本地数据已上传到远程",
|
||||
"uploadFailed": "上传失败"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "云同步",
|
||||
"selectMethod": "选择同步方式",
|
||||
"selectMethodHint": "选择一种数据同步方式,两种方式互斥,切换后将使用新的同步通道。",
|
||||
"postgresql": "自建 PostgreSQL 数据库",
|
||||
"postgresqlDesc": "连接自建的 PostgreSQL 数据库,数据完全自主掌控,适合有服务器的用户。",
|
||||
"sectlCloud": "SECTL 云服务同步",
|
||||
"sectlCloudDesc": "通过 SECTL 云平台进行数据同步,无需自建数据库,开箱即用。",
|
||||
"loginRequired": "请先登录 SECTL Auth 账户",
|
||||
"loginHint": "使用 SECTL 云服务同步需要先登录 SECTL Auth 账户,请前往「账户」标签页登录。",
|
||||
"statusTitle": "同步状态",
|
||||
"configured": "已就绪",
|
||||
"notConfigured": "未就绪",
|
||||
"syncing": "同步中...",
|
||||
"lastSync": "上次同步",
|
||||
"never": "从未同步",
|
||||
"syncActions": "同步操作",
|
||||
"syncDirection": "同步方向",
|
||||
"bidirectional": "双向同步(推荐)— 自动合并本地和云端数据",
|
||||
"pushOnly": "仅上传 — 将本地数据覆盖到云端",
|
||||
"pullOnly": "仅下载 — 将云端数据覆盖到本地",
|
||||
"startSync": "开始同步",
|
||||
"uploadToCloud": "上传到云端",
|
||||
"downloadFromCloud": "从云端下载",
|
||||
"fullSync": "双向同步",
|
||||
"syncInProgress": "同步正在进行中,请稍候",
|
||||
"syncConfirm": "确认执行 {{direction}}?",
|
||||
"storageUsage": "存储用量",
|
||||
"usedStorage": "已使用",
|
||||
"table": "数据表",
|
||||
"records": "记录数",
|
||||
"size": "大小",
|
||||
"clickToLoadStats": "点击刷新按钮加载统计",
|
||||
"description": "SECTL 云服务同步说明",
|
||||
"descriptionPoint1": "使用 SECTL 云服务进行数据同步,无需自建数据库,数据通过 SECTL Auth 账户隔离。",
|
||||
"descriptionPoint2": "基于 KV 存储架构,每张数据表存储为一个键值对,支持高效的数据读写。",
|
||||
"descriptionPoint3": "支持双向同步、仅上传、仅下载三种模式,满足不同场景需求。",
|
||||
"descriptionPoint4": "数据传输使用 HTTPS 加密,结合 OAuth 2.0 认证保障数据安全。"
|
||||
},
|
||||
"data": {
|
||||
"title": "数据管理",
|
||||
"settlement": "结算",
|
||||
@@ -301,7 +340,39 @@
|
||||
"register": "注册 URL 协议",
|
||||
"registered": "URL 协议已注册",
|
||||
"registerFailed": "注册失败",
|
||||
"installerRequired": "需要安装版 SecScore,开发模式下可能无效。"
|
||||
"installerRequired": "需要安装版 SecScore,开发模式下可能无效。",
|
||||
"checkStatus": "检测状态",
|
||||
"reRegister": "重新注册",
|
||||
"unregister": "取消注册",
|
||||
"unregistered": "URL 协议已取消注册",
|
||||
"unregisterFailed": "取消注册失败",
|
||||
"statusRegistered": "已注册",
|
||||
"statusNotRegistered": "未注册",
|
||||
"statusCheckFailed": "状态检测失败",
|
||||
"retrying": "重试中 ({{current}}/{{max}})",
|
||||
"retryFailed": "已达最大重试次数,注册失败",
|
||||
"retrySuccess": "第 {{n}} 次重试成功",
|
||||
"operationLog": "操作日志",
|
||||
"logRegisterStart": "开始注册 URL 协议...",
|
||||
"logRegisterVerify": "注册命令已执行,正在验证...",
|
||||
"logRegisterSuccess": "注册验证成功",
|
||||
"logRegisterFailed": "注册验证失败",
|
||||
"logUnregisterStart": "正在取消注册...",
|
||||
"logUnregisterSuccess": "取消注册成功",
|
||||
"logUnregisterFailed": "取消注册失败",
|
||||
"logMaxRetries": "已达最大重试次数 (7次)"
|
||||
},
|
||||
"elevation": {
|
||||
"title": "管理员权限",
|
||||
"description": "检测当前应用是否以管理员/root 权限运行。部分功能(如端口监听、系统级操作)需要管理员权限。",
|
||||
"elevated": "已提权",
|
||||
"notElevated": "未提权",
|
||||
"request": "请求管理员权限",
|
||||
"requestSuccess": "已请求管理员权限,应用将重启",
|
||||
"requestFailed": "请求管理员权限失败",
|
||||
"windowsHint": "Windows:将弹出 UAC 提示框,确认后应用将以管理员身份重启。",
|
||||
"macosHint": "macOS:将弹出系统认证对话框,输入管理员密码后应用将重启。",
|
||||
"linuxHint": "Linux:将使用 pkexec 请求 root 权限,输入密码后应用将重启。"
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP 服务",
|
||||
|
||||
+29
-13
@@ -456,7 +456,6 @@ const api = {
|
||||
oauthExchangeCode: (
|
||||
code: string,
|
||||
platformId: string,
|
||||
platformSecret: string,
|
||||
callbackUrl: string,
|
||||
codeVerifier: string
|
||||
): Promise<{
|
||||
@@ -468,8 +467,7 @@ const api = {
|
||||
expires_in: number
|
||||
}
|
||||
message?: string
|
||||
}> =>
|
||||
invoke("oauth_exchange_code", { code, platformId, platformSecret, callbackUrl, codeVerifier }),
|
||||
}> => invoke("oauth_exchange_code", { code, platformId, callbackUrl, codeVerifier }),
|
||||
oauthGetUserInfo: (
|
||||
accessToken: string
|
||||
): Promise<{
|
||||
@@ -485,8 +483,7 @@ const api = {
|
||||
}> => invoke("oauth_get_user_info", { accessToken }),
|
||||
oauthRefreshToken: (
|
||||
refreshToken: string,
|
||||
platformId: string,
|
||||
platformSecret: string
|
||||
platformId: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
@@ -496,12 +493,11 @@ const api = {
|
||||
expires_in: number
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("oauth_refresh_token", { refreshToken, platformId, platformSecret }),
|
||||
}> => invoke("oauth_refresh_token", { refreshToken, platformId }),
|
||||
oauthRevokeToken: (
|
||||
token: string,
|
||||
tokenTypeHint: string | null,
|
||||
platformId: string,
|
||||
platformSecret: string
|
||||
platformId: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
@@ -510,12 +506,10 @@ const api = {
|
||||
token,
|
||||
tokenTypeHint,
|
||||
platformId,
|
||||
platformSecret,
|
||||
}),
|
||||
oauthIntrospectToken: (
|
||||
token: string,
|
||||
platformId: string,
|
||||
platformSecret: string
|
||||
platformId: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
@@ -535,7 +529,6 @@ const api = {
|
||||
invoke("oauth_introspect_token", {
|
||||
token,
|
||||
platformId,
|
||||
platformSecret,
|
||||
}),
|
||||
oauthStartCallbackServer: (): Promise<{
|
||||
success: boolean
|
||||
@@ -781,6 +774,30 @@ const api = {
|
||||
data?: { registered: boolean }
|
||||
message?: string
|
||||
}> => invoke("register_url_protocol"),
|
||||
checkUrlProtocolStatus: (): Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
registered: boolean
|
||||
protocol: string
|
||||
platform: string
|
||||
details: string
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("check_url_protocol_status"),
|
||||
unregisterUrlProtocol: (): Promise<{
|
||||
success: boolean
|
||||
data?: { registered: boolean }
|
||||
message?: string
|
||||
}> => invoke("unregister_url_protocol"),
|
||||
checkElevation: (): Promise<{
|
||||
success: boolean
|
||||
data?: { is_elevated: boolean; platform: string; can_request_elevation: boolean }
|
||||
message?: string
|
||||
}> => invoke("check_elevation"),
|
||||
requestElevation: (): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> => invoke("request_elevation"),
|
||||
appQuit: (): Promise<void> => invoke("app_quit"),
|
||||
appRestart: (): Promise<void> => invoke("app_restart"),
|
||||
|
||||
@@ -863,4 +880,3 @@ const api = {
|
||||
|
||||
export default api
|
||||
export { api }
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ class ScoreSyncService {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sectlKVStorage.getKV(DATA_KEYS.SETTINGS)
|
||||
const result = await sectlKVStorage.getKV<{ reasons: any[]; tags: any[] }>(DATA_KEYS.SETTINGS)
|
||||
return result.value || { reasons: [], tags: [] }
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("不存在")) {
|
||||
@@ -192,8 +192,12 @@ class ScoreSyncService {
|
||||
platform: string
|
||||
} | null> {
|
||||
try {
|
||||
const result = await sectlKVStorage.getKV(DATA_KEYS.SYNC_META)
|
||||
return result.value
|
||||
const result = await sectlKVStorage.getKV<{
|
||||
version: string
|
||||
lastSyncTime: string
|
||||
platform: string
|
||||
}>(DATA_KEYS.SYNC_META)
|
||||
return result.value ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
import { sectlKVStorage } from "./sectlKVStorage"
|
||||
import { sectlAuth } from "./sectlAuth"
|
||||
|
||||
const SYNC_PREFIX = "ss"
|
||||
|
||||
const SYNC_TABLES = [
|
||||
"students",
|
||||
"reasons",
|
||||
"score_events",
|
||||
"tags",
|
||||
"student_tags",
|
||||
"reward_settings",
|
||||
"reward_redemptions",
|
||||
] as const
|
||||
|
||||
type SyncTable = (typeof SYNC_TABLES)[number]
|
||||
|
||||
interface SyncMetadata {
|
||||
last_sync_at: string
|
||||
table_versions: Record<SyncTable, string>
|
||||
device_id: string
|
||||
}
|
||||
|
||||
interface TableSyncResult {
|
||||
table: SyncTable
|
||||
uploaded: number
|
||||
downloaded: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface CloudSyncResult {
|
||||
success: boolean
|
||||
message: string
|
||||
tables: TableSyncResult[]
|
||||
synced_at: string
|
||||
}
|
||||
|
||||
export interface CloudSyncStatus {
|
||||
is_configured: boolean
|
||||
last_sync_at: string | null
|
||||
last_error: string | null
|
||||
table_count: number
|
||||
is_syncing: boolean
|
||||
}
|
||||
|
||||
type SyncDirection = "push" | "pull" | "bidirectional"
|
||||
|
||||
const generateDeviceId = (): string => {
|
||||
const stored = localStorage.getItem("ss_cloud_device_id")
|
||||
if (stored) return stored
|
||||
const id = `dev_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
||||
localStorage.setItem("ss_cloud_device_id", id)
|
||||
return id
|
||||
}
|
||||
|
||||
const buildKey = (table: SyncTable): string => `${SYNC_PREFIX}:${table}`
|
||||
|
||||
const buildMetadataKey = (): string => `${SYNC_PREFIX}:__metadata`
|
||||
|
||||
const safeJsonParse = <T>(value: unknown): T | null => {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (value && typeof value === "object") return value as T
|
||||
return null
|
||||
}
|
||||
|
||||
class SectlCloudSyncService {
|
||||
private deviceId: string
|
||||
private isSyncing: boolean = false
|
||||
private lastSyncAt: string | null = null
|
||||
private lastError: string | null = null
|
||||
|
||||
constructor() {
|
||||
this.deviceId = generateDeviceId()
|
||||
}
|
||||
|
||||
getStatus(): CloudSyncStatus {
|
||||
const cached = localStorage.getItem("ss_cloud_sync_status")
|
||||
let lastSyncAt = this.lastSyncAt
|
||||
if (!lastSyncAt && cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached)
|
||||
lastSyncAt = parsed.last_sync_at || null
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return {
|
||||
is_configured: sectlAuth.isAuthenticated(),
|
||||
last_sync_at: lastSyncAt,
|
||||
last_error: this.lastError,
|
||||
table_count: SYNC_TABLES.length,
|
||||
is_syncing: this.isSyncing,
|
||||
}
|
||||
}
|
||||
|
||||
private saveStatus() {
|
||||
localStorage.setItem(
|
||||
"ss_cloud_sync_status",
|
||||
JSON.stringify({
|
||||
last_sync_at: this.lastSyncAt,
|
||||
last_error: this.lastError,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async fetchCloudData(table: SyncTable): Promise<unknown[] | null> {
|
||||
try {
|
||||
const key = buildKey(table)
|
||||
const result = await sectlKVStorage.getKV(key)
|
||||
if (result && "value" in result) {
|
||||
const parsed = safeJsonParse<unknown[]>(result.value)
|
||||
return Array.isArray(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async pushTableData(table: SyncTable, data: unknown[]): Promise<number> {
|
||||
try {
|
||||
const key = buildKey(table)
|
||||
const jsonValue = JSON.stringify(data)
|
||||
const result = await sectlKVStorage.setKV(key, jsonValue)
|
||||
return result.success ? data.length : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetadata(): Promise<SyncMetadata | null> {
|
||||
try {
|
||||
const key = buildMetadataKey()
|
||||
const result = await sectlKVStorage.getKV(key)
|
||||
if (result && "value" in result) {
|
||||
return safeJsonParse<SyncMetadata>(result.value)
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async pushMetadata(metadata: SyncMetadata): Promise<boolean> {
|
||||
try {
|
||||
const key = buildMetadataKey()
|
||||
const result = await sectlKVStorage.setKV(key, JSON.stringify(metadata))
|
||||
return result.success
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async getLocalData(table: SyncTable): Promise<unknown[]> {
|
||||
const api = (window as any).api
|
||||
if (!api) return []
|
||||
try {
|
||||
const apiMap: Record<SyncTable, string> = {
|
||||
students: "getStudents",
|
||||
reasons: "getReasons",
|
||||
score_events: "getScoreEvents",
|
||||
tags: "getTags",
|
||||
student_tags: "getStudentTags",
|
||||
reward_settings: "getRewardSettings",
|
||||
reward_redemptions: "getRewardRedemptions",
|
||||
}
|
||||
const methodName = apiMap[table]
|
||||
if (!methodName || !api[methodName]) return []
|
||||
const res = await api[methodName]()
|
||||
return res.success && Array.isArray(res.data) ? res.data : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async setLocalData(table: SyncTable, data: unknown[]): Promise<boolean> {
|
||||
const api = (window as any).api
|
||||
if (!api) return false
|
||||
try {
|
||||
const methodName = `import${table.charAt(0).toUpperCase() + table.slice(1)}Data`
|
||||
if (api[methodName]) {
|
||||
const res = await api[methodName](JSON.stringify(data))
|
||||
return res.success
|
||||
}
|
||||
const res = await api.importTableData(table, JSON.stringify(data))
|
||||
return res.success
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async syncToCloud(localDataMap?: Map<SyncTable, unknown[]>): Promise<CloudSyncResult> {
|
||||
if (this.isSyncing) {
|
||||
return {
|
||||
success: false,
|
||||
message: "同步正在进行中",
|
||||
tables: [],
|
||||
synced_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
if (!sectlAuth.isAuthenticated()) {
|
||||
return {
|
||||
success: false,
|
||||
message: "请先登录 SECTL Auth 账户",
|
||||
tables: [],
|
||||
synced_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
this.isSyncing = true
|
||||
const results: TableSyncResult[] = []
|
||||
const syncedAt = new Date().toISOString()
|
||||
|
||||
try {
|
||||
for (const table of SYNC_TABLES) {
|
||||
const tableResult: TableSyncResult = {
|
||||
table,
|
||||
uploaded: 0,
|
||||
downloaded: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
try {
|
||||
const localData = localDataMap?.get(table) ?? (await this.getLocalData(table))
|
||||
const uploaded = await this.pushTableData(table, localData)
|
||||
tableResult.uploaded = uploaded
|
||||
} catch (err: unknown) {
|
||||
tableResult.errors.push(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
|
||||
results.push(tableResult)
|
||||
}
|
||||
|
||||
const metadata: SyncMetadata = {
|
||||
last_sync_at: syncedAt,
|
||||
table_versions: Object.fromEntries(SYNC_TABLES.map((t) => [t, syncedAt])) as Record<
|
||||
SyncTable,
|
||||
string
|
||||
>,
|
||||
device_id: this.deviceId,
|
||||
}
|
||||
await this.pushMetadata(metadata)
|
||||
|
||||
this.lastSyncAt = syncedAt
|
||||
this.lastError = null
|
||||
this.saveStatus()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `已上传 ${results.reduce((s, r) => s + r.uploaded, 0)} 条记录到云端`,
|
||||
tables: results,
|
||||
synced_at: syncedAt,
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
this.lastError = msg
|
||||
this.saveStatus()
|
||||
return {
|
||||
success: false,
|
||||
message: `同步失败:${msg}`,
|
||||
tables: results,
|
||||
synced_at: syncedAt,
|
||||
}
|
||||
} finally {
|
||||
this.isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
async syncFromCloud(): Promise<CloudSyncResult> {
|
||||
if (this.isSyncing) {
|
||||
return {
|
||||
success: false,
|
||||
message: "同步正在进行中",
|
||||
tables: [],
|
||||
synced_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
if (!sectlAuth.isAuthenticated()) {
|
||||
return {
|
||||
success: false,
|
||||
message: "请先登录 SECTL Auth 账户",
|
||||
tables: [],
|
||||
synced_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
this.isSyncing = true
|
||||
const results: TableSyncResult[] = []
|
||||
const syncedAt = new Date().toISOString()
|
||||
|
||||
try {
|
||||
for (const table of SYNC_TABLES) {
|
||||
const tableResult: TableSyncResult = {
|
||||
table,
|
||||
uploaded: 0,
|
||||
downloaded: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
try {
|
||||
const cloudData = await this.fetchCloudData(table)
|
||||
if (cloudData) {
|
||||
const applied = await this.setLocalData(table, cloudData)
|
||||
if (applied) {
|
||||
tableResult.downloaded = cloudData.length
|
||||
} else {
|
||||
tableResult.errors.push("应用云端数据失败")
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
tableResult.errors.push(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
|
||||
results.push(tableResult)
|
||||
}
|
||||
|
||||
this.lastSyncAt = syncedAt
|
||||
this.lastError = null
|
||||
this.saveStatus()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `已从云端下载 ${results.reduce((s, r) => s + r.downloaded, 0)} 条记录`,
|
||||
tables: results,
|
||||
synced_at: syncedAt,
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
this.lastError = msg
|
||||
this.saveStatus()
|
||||
return {
|
||||
success: false,
|
||||
message: `同步失败:${msg}`,
|
||||
tables: results,
|
||||
synced_at: syncedAt,
|
||||
}
|
||||
} finally {
|
||||
this.isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
async fullSync(direction: SyncDirection = "bidirectional"): Promise<CloudSyncResult> {
|
||||
if (this.isSyncing) {
|
||||
return {
|
||||
success: false,
|
||||
message: "同步正在进行中",
|
||||
tables: [],
|
||||
synced_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
if (!sectlAuth.isAuthenticated()) {
|
||||
return {
|
||||
success: false,
|
||||
message: "请先登录 SECTL Auth 账户",
|
||||
tables: [],
|
||||
synced_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
this.isSyncing = true
|
||||
const results: TableSyncResult[] = []
|
||||
const syncedAt = new Date().toISOString()
|
||||
|
||||
try {
|
||||
const metadata = await this.fetchMetadata()
|
||||
const isRemoteDevice = metadata?.device_id !== this.deviceId
|
||||
|
||||
for (const table of SYNC_TABLES) {
|
||||
const tableResult: TableSyncResult = {
|
||||
table,
|
||||
uploaded: 0,
|
||||
downloaded: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
try {
|
||||
if (direction === "push") {
|
||||
const localData = await this.getLocalData(table)
|
||||
const uploaded = await this.pushTableData(table, localData)
|
||||
tableResult.uploaded = uploaded
|
||||
} else if (direction === "pull") {
|
||||
const cloudData = await this.fetchCloudData(table)
|
||||
if (cloudData) {
|
||||
const applied = await this.setLocalData(table, cloudData)
|
||||
if (applied) tableResult.downloaded = cloudData.length
|
||||
else tableResult.errors.push("应用云端数据失败")
|
||||
}
|
||||
} else {
|
||||
const cloudData = await this.fetchCloudData(table)
|
||||
const localData = await this.getLocalData(table)
|
||||
|
||||
if (!cloudData || cloudData.length === 0) {
|
||||
const uploaded = await this.pushTableData(table, localData)
|
||||
tableResult.uploaded = uploaded
|
||||
} else if (localData.length === 0) {
|
||||
const applied = await this.setLocalData(table, cloudData)
|
||||
if (applied) tableResult.downloaded = cloudData.length
|
||||
else tableResult.errors.push("应用云端数据失败")
|
||||
} else {
|
||||
const cloudTs = metadata?.table_versions?.[table]
|
||||
const cloudTime = cloudTs ? new Date(cloudTs).getTime() : 0
|
||||
const now = Date.now()
|
||||
|
||||
if (isRemoteDevice || cloudTime < now - 60_000) {
|
||||
const uploaded = await this.pushTableData(table, localData)
|
||||
tableResult.uploaded = uploaded
|
||||
} else {
|
||||
const applied = await this.setLocalData(table, cloudData)
|
||||
if (applied) tableResult.downloaded = cloudData.length
|
||||
else tableResult.errors.push("应用云端数据失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
tableResult.errors.push(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
|
||||
results.push(tableResult)
|
||||
}
|
||||
|
||||
const newMetadata: SyncMetadata = {
|
||||
last_sync_at: syncedAt,
|
||||
table_versions: Object.fromEntries(SYNC_TABLES.map((t) => [t, syncedAt])) as Record<
|
||||
SyncTable,
|
||||
string
|
||||
>,
|
||||
device_id: this.deviceId,
|
||||
}
|
||||
await this.pushMetadata(newMetadata)
|
||||
|
||||
this.lastSyncAt = syncedAt
|
||||
this.lastError = null
|
||||
this.saveStatus()
|
||||
|
||||
const totalUploaded = results.reduce((s, r) => s + r.uploaded, 0)
|
||||
const totalDownloaded = results.reduce((s, r) => s + r.downloaded, 0)
|
||||
return {
|
||||
success: true,
|
||||
message: `同步完成:上传 ${totalUploaded} 条,下载 ${totalDownloaded} 条`,
|
||||
tables: results,
|
||||
synced_at: syncedAt,
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
this.lastError = msg
|
||||
this.saveStatus()
|
||||
return {
|
||||
success: false,
|
||||
message: `同步失败:${msg}`,
|
||||
tables: results,
|
||||
synced_at: syncedAt,
|
||||
}
|
||||
} finally {
|
||||
this.isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
async getCloudStorageUsage(): Promise<{
|
||||
used: number
|
||||
total: number
|
||||
percentage: number
|
||||
table_stats: Array<{ table: SyncTable; count: number; size: number }>
|
||||
} | null> {
|
||||
try {
|
||||
const tableStats: Array<{ table: SyncTable; count: number; size: number }> = []
|
||||
let totalSize = 0
|
||||
|
||||
for (const table of SYNC_TABLES) {
|
||||
const key = buildKey(table)
|
||||
const result = await sectlKVStorage.getKV(key)
|
||||
if (result && "value" in result) {
|
||||
const size = (result as any).size || 0
|
||||
const parsed = safeJsonParse<unknown[]>(result.value)
|
||||
tableStats.push({
|
||||
table,
|
||||
count: Array.isArray(parsed) ? parsed.length : 0,
|
||||
size,
|
||||
})
|
||||
totalSize += size
|
||||
} else {
|
||||
tableStats.push({ table, count: 0, size: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
const maxStorage = 64 * 1024 * SYNC_TABLES.length
|
||||
return {
|
||||
used: totalSize,
|
||||
total: maxStorage,
|
||||
percentage: maxStorage > 0 ? Math.round((totalSize / maxStorage) * 100) : 0,
|
||||
table_stats: tableStats,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sectlCloudSync = new SectlCloudSyncService()
|
||||
+80
-200
@@ -1,22 +1,14 @@
|
||||
/**
|
||||
* SECTL KV 键值对存储服务
|
||||
* 基于 SECTL-One-Stop SDK 规范实现
|
||||
* 提供键值对存储功能,支持 JSON 格式和字段级操作
|
||||
*/
|
||||
|
||||
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
|
||||
|
||||
// KV 数据类型
|
||||
export interface KVItem {
|
||||
key: string
|
||||
value: any
|
||||
value: unknown
|
||||
size: number
|
||||
is_json: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// KV 列表选项
|
||||
export interface ListKVOptions {
|
||||
prefix?: string
|
||||
limit?: number
|
||||
@@ -24,12 +16,60 @@ export interface ListKVOptions {
|
||||
}
|
||||
|
||||
class SectlKVStorageService {
|
||||
/**
|
||||
* 设置 KV 键值对
|
||||
* @param key 键名(最大 255 字符)
|
||||
* @param value 值(任意 JSON 可序列化数据,最大 64KB)
|
||||
* @param ttl 过期时间(秒),可选
|
||||
*/
|
||||
private get platformId(): string {
|
||||
return SECTL_CONFIG.platformId
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const token = sectlAuth.getAccessToken()
|
||||
if (!token) throw new Error("未授权,请先登录")
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
private async makeRequest<T>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
options: { jsonData?: Record<string, unknown>; params?: Record<string, unknown> } = {}
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
...this.getAuthHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
let url = `${SECTL_CONFIG.baseUrl}${endpoint}`
|
||||
if (options.params) {
|
||||
const searchParams = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(options.params)) {
|
||||
if (v !== undefined && v !== null) searchParams.set(k, String(v))
|
||||
}
|
||||
const qs = searchParams.toString()
|
||||
if (qs) url += `?${qs}`
|
||||
}
|
||||
|
||||
const init: RequestInit = { method, headers }
|
||||
if (options.jsonData && method !== "GET") {
|
||||
init.body = JSON.stringify(options.jsonData)
|
||||
}
|
||||
|
||||
const response = await fetch(url, init)
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData: { error?: string; error_description?: string; message?: string } = {}
|
||||
try {
|
||||
errorData = await response.json()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const desc = errorData.error_description || errorData.message || `HTTP ${response.status}`
|
||||
const err = new Error(desc) as Error & { status?: number; error?: string }
|
||||
err.status = response.status
|
||||
err.error = errorData.error
|
||||
throw err
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
async setKV(
|
||||
key: string,
|
||||
value: unknown,
|
||||
@@ -43,51 +83,16 @@ class SectlKVStorageService {
|
||||
updated_at?: string
|
||||
message: string
|
||||
}> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv`
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
const jsonData: Record<string, unknown> = {
|
||||
client_id: this.platformId,
|
||||
key,
|
||||
value,
|
||||
}
|
||||
if (ttl !== undefined) jsonData.ttl = ttl
|
||||
|
||||
if (ttl !== undefined) {
|
||||
body.ttl = ttl
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "设置键值对失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("设置键值对失败:", error)
|
||||
throw error
|
||||
}
|
||||
return this.makeRequest("POST", "/api/cloud/kv", { jsonData })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 KV 键值对
|
||||
* @param key 键名
|
||||
* @param field JSON 字段路径,如 "theme" 或 "user.name",可选
|
||||
*/
|
||||
async getKV<T = unknown>(
|
||||
key: string,
|
||||
field?: string
|
||||
@@ -95,93 +100,25 @@ class SectlKVStorageService {
|
||||
| (KVItem & { key: string; value: T })
|
||||
| { key: string; field: string; value: T; is_json: boolean }
|
||||
> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
const params: Record<string, string> = { client_id: this.platformId }
|
||||
if (field) params.field = field
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
})
|
||||
|
||||
if (field) {
|
||||
params.append("field", field)
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}?${params.toString()}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "获取键值对失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("获取键值对失败:", error)
|
||||
throw error
|
||||
}
|
||||
return this.makeRequest("GET", `/api/cloud/kv/${encodeURIComponent(key)}`, { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 KV 键值对列表
|
||||
* @param options 列表选项
|
||||
*/
|
||||
async listKV(options: ListKVOptions = {}): Promise<{
|
||||
kv_list: KVItem[]
|
||||
total: number
|
||||
has_more: boolean
|
||||
}> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
async listKV(
|
||||
options: ListKVOptions = {}
|
||||
): Promise<{ kv_list: KVItem[]; total: number; has_more: boolean }> {
|
||||
const params: Record<string, unknown> = {
|
||||
client_id: this.platformId,
|
||||
limit: Math.min(options.limit || 100, 1000),
|
||||
offset: options.offset || 0,
|
||||
}
|
||||
if (options.prefix) params.prefix = options.prefix
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
limit: String(options.limit || 100),
|
||||
offset: String(options.offset || 0),
|
||||
})
|
||||
|
||||
if (options.prefix) {
|
||||
params.append("prefix", options.prefix)
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv?${params.toString()}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "获取键值对列表失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("获取键值对列表失败:", error)
|
||||
throw error
|
||||
}
|
||||
return this.makeRequest("GET", "/api/cloud/kv", { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 KV JSON 字段
|
||||
* @param key 键名
|
||||
* @param field JSON 字段路径,支持嵌套如 "user.name" 或数组索引 "items.0.id"
|
||||
* @param value 要设置的值
|
||||
*/
|
||||
async updateKVField(
|
||||
key: string,
|
||||
field: string,
|
||||
@@ -194,77 +131,20 @@ class SectlKVStorageService {
|
||||
updated_at: string
|
||||
message: string
|
||||
}> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
field,
|
||||
value,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "更新字段失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("更新字段失败:", error)
|
||||
throw error
|
||||
}
|
||||
return this.makeRequest("PATCH", `/api/cloud/kv/${encodeURIComponent(key)}`, {
|
||||
jsonData: { client_id: this.platformId, field, value },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 KV 键值对
|
||||
* @param key 键名
|
||||
*/
|
||||
async deleteKV(key: string): Promise<{ success: boolean; message: string }> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
return this.makeRequest("DELETE", `/api/cloud/kv/${encodeURIComponent(key)}`, {
|
||||
jsonData: { client_id: this.platformId },
|
||||
})
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "删除键值对失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("删除键值对失败:", error)
|
||||
throw error
|
||||
}
|
||||
getPlatformId(): string {
|
||||
return this.platformId
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const sectlKVStorage = new SectlKVStorageService()
|
||||
|
||||
Reference in New Issue
Block a user