feat(settings): 设置界面重构 — 分节化 / 精简 / Beta 渠道 / 麦克风 / 市场登录

将 2200 行的 Settings.tsx 单体拆为 pages/settings/ 下的按节文件(通用 / 服务 /
隐私 / 高级 / 关于),并对设置界面做整体精简与功能调整。

界面精简
- 移除各设置项冗长 / 多行描述(在 180px 标签列普遍折成多行),只留标签与控件
- 删除「个性化」独立 tab —— 仅剩字体大小一项、整页太空,并入「关于」
- 移除失效的「毛玻璃强度」滑块(其 CSS 变量无可见元素消费)

关于 / 更新
- 版本卡片图标右上方放「检查更新」按钮,固定查正式版(channel='stable')
- 「加入 Beta 渠道」迁到「高级」页底部、单独成节;开启后出现查测试版的检查按钮
- 检查中按钮内转圈;结果(已是最新 / 失败)只在按钮内呈现,卡片高度恒定,
  不再因状态文字 reflow 抽搐
- Rust app_check_update_with_channel 新增可选 channel 参数,两个按钮各查各渠道;
  后台 AutoUpdateGate 仍按 prefs.update_channel 走

麦克风选择
- 由全屏弹窗改为 SelectLite 下拉(与语言选择一致的「官方框」)
- SelectLite 增加 trailing / onOpenChange;新增 MicrophoneSelect —— 选中项右侧
  打勾、勾左侧实时音量条

市场登录
- 抽出共用 GithubLoginModal 与 ui/Modal,扩展市场与风格市场登录统一走弹窗
- 修复风格市场登录弹窗「先小后大」尺寸跳动(各阶段固定 minHeight)

弹窗动画
- 去掉开启动画里的 blur 滤镜(卡顿 / 闪烁),改纯 opacity + transform
- 设置弹窗切 tab 内容轻微淡入;各处弹窗动画时长 / 缓动统一到 global.css

网络
- 新增 net.rs:进程级共享 reqwest 客户端(连接池复用)+ 传输层失败指数退避重试
This commit is contained in:
baiqing committed 2026-05-22 20:56:06 +08:00
1 parent 82ab2439fb
commit 52c49fcfad
40 files changed
+2809 -3462

No files matched your search

+174 -157
View File
@@ -13,6 +13,7 @@ use crate::asr::local::foundry::{
};
use crate::asr::local::FoundryLocalRuntime;
use crate::coordinator::Coordinator;
use crate::net;
use crate::permissions::{self, PermissionStatus};
use crate::persistence::{
sync_style_pack_preferences, CredentialAccount, CredentialsSnapshot, CredentialsVault,
@@ -319,16 +320,13 @@ pub struct LatestBetaRelease {
/// 返回 `Ok(None)` = 当前没发过 Beta 版;`Err(String)` = 网络/解析故障。
#[tauri::command]
pub async fn fetch_latest_beta_release() -> Result<Option<LatestBetaRelease>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| format!("build http client: {e}"))?;
let resp = client
.get("https://github.com/appergb/openless/releases.atom")
.send()
.await
.map_err(|e| format!("fetch releases.atom: {e}"))?;
let resp = net::send_with_retry(|| {
net::http()
.get("https://github.com/appergb/openless/releases.atom")
.timeout(std::time::Duration::from_secs(15))
})
.await
.map_err(|e| format!("fetch releases.atom: {e}"))?;
if !resp.status().is_success() {
return Err(format!("releases.atom status {}", resp.status()));
}
@@ -408,17 +406,20 @@ pub struct AppUpdateMetadata {
pub raw_json: serde_json::Value,
}
/// 按 prefs.update_channel 决定 manifest 来源,再走 plugin-updater 的标准 check 流程。
/// 决定 manifest 来源后走 plugin-updater 的标准 check 流程。
/// 渠道:显式传入 `channel` 时用它(关于页固定查 Stable、高级页 Beta 区查 Beta);
/// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。
/// 返回 None = 当前是最新;Some(metadata) = 有新版可装。
#[tauri::command]
pub async fn app_check_update_with_channel<R: tauri::Runtime>(
coord: CoordinatorState<'_>,
webview: tauri::Webview<R>,
timeout_ms: Option<u64>,
channel: Option<UpdateChannel>,
) -> Result<Option<AppUpdateMetadata>, String> {
use tauri_plugin_updater::UpdaterExt;
let channel = coord.prefs().get().update_channel;
let channel = channel.unwrap_or_else(|| coord.prefs().get().update_channel);
let mut builder = webview.updater_builder();
if let Some(ms) = timeout_ms {
builder = builder.timeout(std::time::Duration::from_millis(ms));
@@ -483,27 +484,27 @@ pub struct NetworkCheckResult {
#[tauri::command]
pub async fn check_network() -> NetworkCheckResult {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build();
let client = match client {
Ok(c) => c,
Err(_) => return NetworkCheckResult { online: false, latency_ms: None },
};
// 探一个真实存在的接口。旧逻辑探 `/health` —— 这个接口实测返回 404,于是哪怕
// 链路完全正常也永远判定离线;且用 HEAD(后端只挂 GET)。改成 GET `/packs`,
// 走共享客户端 + 重试。只要拿到任意 HTTP 响应就说明链路通;连不上才算离线。
let url = format!("{MARKETPLACE_BASE_URL}/packs?limit=1");
let start = std::time::Instant::now();
let endpoints = [
"https://apic.openless.top/health",
"https://github.com",
];
for url in &endpoints {
if let Ok(resp) = client.head(*url).send().await {
if resp.status().is_success() || resp.status().is_redirection() {
let ms = start.elapsed().as_millis() as u64;
return NetworkCheckResult { online: true, latency_ms: Some(ms) };
}
}
match net::send_with_retry(|| {
net::http()
.get(&url)
.timeout(std::time::Duration::from_secs(8))
})
.await
{
Ok(_) => NetworkCheckResult {
online: true,
latency_ms: Some(start.elapsed().as_millis() as u64),
},
Err(_) => NetworkCheckResult {
online: false,
latency_ms: None,
},
}
NetworkCheckResult { online: false, latency_ms: None }
}
#[tauri::command]
@@ -976,31 +977,41 @@ fn active_foundry_asr_is_supported(provider: &str) -> bool {
async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Result<(), String> {
const MAX_ASR_VALIDATE_BODY_BYTES: usize = 1024 * 1024;
const MAX_ATTEMPTS: u32 = 6;
let url = asr_transcriptions_url(&config.base_url)?;
let wav = encode_wav_16k_mono_silence(250);
let wav_part = reqwest::multipart::Part::bytes(wav)
.file_name("openless-asr-check.wav")
.mime_str("audio/wav")
.map_err(|e| format!("请求体构建失败: {e}"))?;
let form = reqwest::multipart::Form::new()
.part("file", wav_part)
.text("model", model.to_string());
let client = http_client_builder(&url, 20)
.build()
.map_err(|_| "providerClientInitFailed".to_string())?;
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_key))
.multipart(form)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
"providerRequestTimeout".to_string()
} else {
"providerNetworkError".to_string()
// 连接 / 请求未送出类失败做指数退避重试 —— 这类失败请求尚未送达服务端,重试
// 安全。超时不重试(服务端可能已在处理)。multipart 是流式 body,每次重建。
let mut attempt: u32 = 0;
let response = loop {
attempt += 1;
let wav_part = reqwest::multipart::Part::bytes(wav.clone())
.file_name("openless-asr-check.wav")
.mime_str("audio/wav")
.map_err(|e| format!("请求体构建失败: {e}"))?;
let form = reqwest::multipart::Form::new()
.part("file", wav_part)
.text("model", model.to_string());
match client
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_key))
.multipart(form)
.send()
.await
{
Ok(resp) => break resp,
Err(e) if e.is_timeout() => return Err("providerRequestTimeout".to_string()),
Err(e) if (e.is_connect() || e.is_request()) && attempt < MAX_ATTEMPTS => {
let backoff = (200u64 * 2u64.pow((attempt - 1).min(3))).min(900);
tokio::time::sleep(std::time::Duration::from_millis(backoff)).await;
continue;
}
})?;
Err(_) => return Err("providerNetworkError".to_string()),
}
};
let status = response.status();
if !status.is_success() {
return Err(format!("providerHttpStatus:{}", status.as_u16()));
@@ -2492,13 +2503,13 @@ pub async fn marketplace_list(
if let Some(n) = limit {
url.query_pairs_mut().append_pair("limit", &n.to_string());
}
let client = reqwest::Client::new();
let resp = client
.get(url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("marketplace request failed: {e}"))?;
let resp = net::send_with_retry(|| {
net::http()
.get(url.clone())
.timeout(std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("marketplace request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
@@ -2519,13 +2530,14 @@ pub async fn marketplace_detail(
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let client = reqwest::Client::new();
let resp = client
.get(format!("{base}/packs/{pack_id}"))
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("marketplace request failed: {e}"))?;
let url = format!("{base}/packs/{pack_id}");
let resp = net::send_with_retry(|| {
net::http()
.get(&url)
.timeout(std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("marketplace request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
return Err(format!("marketplace HTTP {status}"));
@@ -2548,38 +2560,40 @@ pub async fn marketplace_install(
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let client = reqwest::Client::new();
// 先拉 detail 拿 authorLogin —— 装好后本地写 originAuthorLogin,
// 后续编辑+发布时 backend 据此判 supersede(原作者)vs derivative(他人 fork)。
let detail_url = format!("{base}/packs/{pack_id}");
let detail: serde_json::Value = client
.get(&detail_url)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("marketplace detail failed: {e}"))?
.error_for_status()
.map_err(|e| format!("marketplace detail HTTP error: {e}"))?
.json()
.await
.map_err(|e| format!("parse detail failed: {e}"))?;
let detail: serde_json::Value = net::send_with_retry(|| {
net::http()
.get(&detail_url)
.timeout(std::time::Duration::from_secs(15))
})
.await
.map_err(|e| format!("marketplace detail failed: {e}"))?
.error_for_status()
.map_err(|e| format!("marketplace detail HTTP error: {e}"))?
.json()
.await
.map_err(|e| format!("parse detail failed: {e}"))?;
let origin_author_login = detail
.get("authorLogin")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let bytes = client
.get(format!("{base}/packs/{pack_id}/download"))
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("marketplace download failed: {e}"))?
.error_for_status()
.map_err(|e| format!("marketplace HTTP error: {e}"))?
.bytes()
.await
.map_err(|e| format!("read body failed: {e}"))?;
let download_url = format!("{base}/packs/{pack_id}/download");
let bytes = net::send_with_retry(|| {
net::http()
.get(&download_url)
.timeout(std::time::Duration::from_secs(30))
})
.await
.map_err(|e| format!("marketplace download failed: {e}"))?
.error_for_status()
.map_err(|e| format!("marketplace HTTP error: {e}"))?
.bytes()
.await
.map_err(|e| format!("read body failed: {e}"))?;
// pack_id 已经过 UUID 白名单,拼临时文件路径安全。
let tmp = std::env::temp_dir().join(format!("openless-marketplace-{pack_id}.zip"));
@@ -2634,7 +2648,8 @@ pub async fn marketplace_upload(
let bytes = std::fs::read(&tmp).map_err(|e| format!("read exported zip: {e}"))?;
let _ = std::fs::remove_file(&tmp);
let client = reqwest::Client::new();
// multipart 上传:表单是流式 body,不走 send_with_retry 的闭包重试;改用共享
// 客户端 —— 之前 list/detail 命令若已打开过连接,这里直接复用连接池里的连接。
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(format!("{pack_id}.zip"))
.mime_str("application/zip")
@@ -2643,7 +2658,7 @@ pub async fn marketplace_upload(
if let Some(ref oid) = origin_pack_id {
form = form.text("origin_pack_id", oid.clone());
}
let resp = client
let resp = net::http()
.post(format!("{base}/packs"))
.header("X-Dev-User", dev_user)
.timeout(std::time::Duration::from_secs(30))
@@ -2694,14 +2709,15 @@ pub async fn marketplace_like(
if dev_user.is_empty() {
return Err("未登录:先在 Settings 填发布者名字".into());
}
let client = reqwest::Client::new();
let resp = client
.post(format!("{base}/packs/{pack_id}/like"))
.header("X-Dev-User", dev_user)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("like request failed: {e}"))?;
let like_url = format!("{base}/packs/{pack_id}/like");
let resp = net::send_with_retry(|| {
net::http()
.post(&like_url)
.header("X-Dev-User", dev_user.as_str())
.timeout(std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("like request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("like HTTP {}", resp.status()));
}
@@ -2726,14 +2742,15 @@ pub async fn marketplace_delete(
if dev_user.is_empty() {
return Err("未登录:先在 Settings 填发布者名字".into());
}
let client = reqwest::Client::new();
let resp = client
.delete(format!("{base}/packs/{pack_id}"))
.header("X-Dev-User", dev_user)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("delete request failed: {e}"))?;
let delete_url = format!("{base}/packs/{pack_id}");
let resp = net::send_with_retry(|| {
net::http()
.delete(&delete_url)
.header("X-Dev-User", dev_user.as_str())
.timeout(std::time::Duration::from_secs(15))
})
.await
.map_err(|e| format!("delete request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
@@ -2751,14 +2768,15 @@ pub async fn marketplace_my_likes(coord: CoordinatorState<'_>) -> Result<Vec<Str
if dev_user.is_empty() {
return Ok(Vec::new()); // 未登录就空集合,UI 渲染无红心
}
let client = reqwest::Client::new();
let resp = client
.get(format!("{base}/me/likes"))
.header("X-Dev-User", dev_user)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("my-likes request failed: {e}"))?;
let likes_url = format!("{base}/me/likes");
let resp = net::send_with_retry(|| {
net::http()
.get(&likes_url)
.header("X-Dev-User", dev_user.as_str())
.timeout(std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("my-likes request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("my-likes HTTP {}", resp.status()));
}
@@ -2778,14 +2796,15 @@ pub async fn marketplace_my_packs(
if dev_user.is_empty() {
return Ok(Vec::new());
}
let client = reqwest::Client::new();
let resp = client
.get(format!("{base}/me/packs"))
.header("X-Dev-User", dev_user)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("my-packs request failed: {e}"))?;
let packs_url = format!("{base}/me/packs");
let resp = net::send_with_retry(|| {
net::http()
.get(&packs_url)
.header("X-Dev-User", dev_user.as_str())
.timeout(std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("my-packs request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("my-packs HTTP {}", resp.status()));
}
@@ -2844,18 +2863,16 @@ pub struct GithubDeviceStartResponse {
#[tauri::command]
pub async fn github_device_flow_start() -> Result<GithubDeviceStartResponse, String> {
let client_id = get_github_oauth_client_id()?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("build http client: {e}"))?;
let resp = client
.post("https://github.com/login/device/code")
.header("Accept", "application/json")
.header("User-Agent", "OpenLess")
.form(&[("client_id", client_id.as_str()), ("scope", "read:user")])
.send()
.await
.map_err(|e| format!("调用 GitHub /login/device/code 失败:{e}"))?;
let resp = net::send_with_retry(|| {
net::http()
.post("https://github.com/login/device/code")
.header("Accept", "application/json")
.header("User-Agent", "OpenLess")
.timeout(std::time::Duration::from_secs(15))
.form(&[("client_id", client_id.as_str()), ("scope", "read:user")])
})
.await
.map_err(|e| format!("调用 GitHub /login/device/code 失败:{e}"))?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
@@ -2892,36 +2909,36 @@ pub async fn github_device_flow_poll(
device_code: String,
) -> Result<GithubDevicePollResult, String> {
let client_id = get_github_oauth_client_id()?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("build http client: {e}"))?;
let token_resp = client
.post("https://github.com/login/oauth/access_token")
.header("Accept", "application/json")
.header("User-Agent", "OpenLess")
.form(&[
("client_id", client_id.as_str()),
("device_code", device_code.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await
.map_err(|e| format!("调用 GitHub /login/oauth/access_token 失败:{e}"))?;
let token_resp = net::send_with_retry(|| {
net::http()
.post("https://github.com/login/oauth/access_token")
.header("Accept", "application/json")
.header("User-Agent", "OpenLess")
.timeout(std::time::Duration::from_secs(15))
.form(&[
("client_id", client_id.as_str()),
("device_code", device_code.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
})
.await
.map_err(|e| format!("调用 GitHub /login/oauth/access_token 失败:{e}"))?;
let body: serde_json::Value = token_resp
.json()
.await
.map_err(|e| format!("解析 access_token 响应失败:{e}"))?;
if let Some(token) = body["access_token"].as_str() {
let user_resp = client
.get("https://api.github.com/user")
.header("User-Agent", "OpenLess")
.header("Accept", "application/vnd.github+json")
.bearer_auth(token)
.send()
.await
.map_err(|e| format!("调用 GitHub /user 失败:{e}"))?;
let user_resp = net::send_with_retry(|| {
net::http()
.get("https://api.github.com/user")
.header("User-Agent", "OpenLess")
.header("Accept", "application/vnd.github+json")
.timeout(std::time::Duration::from_secs(15))
.bearer_auth(token)
})
.await
.map_err(|e| format!("调用 GitHub /user 失败:{e}"))?;
let user_body: serde_json::Value = user_resp
.json()
.await
+1
View File
@@ -24,6 +24,7 @@ mod insertion;
#[cfg(target_os = "linux")]
mod linux_fcitx;
mod llm_gemini;
mod net;
mod permissions;
mod persistence;
mod polish;
+68
View File
@@ -0,0 +1,68 @@
//! 共享 HTTP 客户端 + 带重试的请求发送。
//!
//! 背景:原先每个网络命令各自 `reqwest::Client::new()`,连接池互不复用 —— 一次
//! 成功的 TLS 连接用完即弃,下一个命令又得重新握手。在握手不稳定的网络下(代理
//! 分流等)首次握手经常被重置,用户得反复重试才能用。
//!
//! 这里提供两件东西:
//! - `http()`:进程级共享客户端。一次握手成功后的连接进连接池,后续命令直接复用,
//! 不再付握手成本。
//! - `send_with_retry`:对「连接 / 超时 / 请求未送出」这三类传输层失败做指数退避
//! 重试。这类失败发生在请求送达服务端之前,重试是幂等安全的。HTTP 4xx/5xx 不在
//! 重试范围 —— 服务端已经应答,状态码交给调用方判断。
use std::time::Duration;
use once_cell::sync::Lazy;
static HTTP: Lazy<reqwest::Client> = Lazy::new(|| {
reqwest::Client::builder()
// 握手单独限时:卡在握手上要尽快失败,好让 send_with_retry 立即重试。
.connect_timeout(Duration::from_secs(8))
// 连接池:一条握手成功的连接保留 90s 供后续命令复用。
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(8)
.tcp_keepalive(Duration::from_secs(30))
.user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION")))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
});
/// 进程级共享 HTTP 客户端。带连接池 —— 一次握手成功后的连接被后续请求复用。
pub fn http() -> &'static reqwest::Client {
&HTTP
}
/// 单次请求最多尝试的次数。失败本身很快(握手重置 ~0.5s),10 次总耗时仍可控。
const MAX_ATTEMPTS: u32 = 10;
/// 发送请求,对传输层失败(连接重置 / 超时 / 请求未送出)做指数退避重试。
///
/// `make` 每次尝试都重新构造 `RequestBuilder`(`send()` 会消耗它)。只重试
/// `is_connect()` / `is_timeout()` / `is_request()` —— 这三类失败时请求尚未送达
/// 服务端,重试幂等安全。拿到任意 HTTP 响应(含 4xx/5xx)即返回,状态码由调用方
/// 自行判断。
pub async fn send_with_retry<F>(make: F) -> reqwest::Result<reqwest::Response>
where
F: Fn() -> reqwest::RequestBuilder,
{
let mut attempt: u32 = 0;
loop {
attempt += 1;
match make().send().await {
Ok(resp) => return Ok(resp),
Err(err) => {
let retryable = err.is_connect() || err.is_timeout() || err.is_request();
if !retryable || attempt >= MAX_ATTEMPTS {
return Err(err);
}
// 150 / 300 / 600 / 900 / 900 … ms 退避。
let backoff = (150u64 * 2u64.pow((attempt - 1).min(3))).min(900);
log::warn!(
"[net] transient failure (attempt {attempt}/{MAX_ATTEMPTS}), retry in {backoff}ms: {err}"
);
tokio::time::sleep(Duration::from_millis(backoff)).await;
}
}
}
}
+4 -1
View File
@@ -1080,11 +1080,14 @@ const STRUCTURED_BUILTIN_PROMPT: &str = r#"# 角色
按可识别的事项数决定输出形态:
- **事项 ≤ 2 条** → 输出连贯段落,不硬塞层级。
- **事项仅 1 条** → 输出连贯段落。
- **事项 = 2 条** → **必须**用 1./2. 编号平列输出,每条一句完整陈述。不强制分主题子项,但仍需整理表达。
- **事项 ≥ 3 条** → **必须**按语义归类为 2–4 个主题,使用下文双层格式。**照抄原结构 = 失败。**
即使原文已经写成「1. 做 X 2. 做 Y 3. 做 Z」,也要按主题重新归类,把同主题事项收到同一组下做 (a)(b) 子项。
**重要:只要存在 2 条及以上可区分事项,就必须编号。不编号 = 失败。**
常见主题组合(按内容自动选取):
- 工程类:「代码与功能 / 文档与配置 / 界面与交互 / 项目清理」「后端 / 前端 / 部署 / 提示词」
+5 -4
View File
@@ -3,7 +3,7 @@ import { AutoUpdateGate } from './components/AutoUpdateGate';
import { Capsule } from './components/Capsule';
import { FloatingShell } from './components/FloatingShell';
import { Onboarding } from './components/Onboarding';
import { detectOS } from './components/WindowChrome';
import { detectOS, type OS } from './components/WindowChrome';
import {
checkAccessibilityPermission,
checkMicrophonePermission,
@@ -22,11 +22,12 @@ import { HotkeySettingsProvider } from './state/HotkeySettingsContext';
interface AppProps {
isCapsule: boolean;
isQa: boolean;
forcedOs?: OS | null;
}
type Gate = 'checking' | 'onboarding' | 'ready';
export function App({ isCapsule, isQa }: AppProps) {
export function App({ isCapsule, isQa, forcedOs }: AppProps) {
if (isCapsule) {
return <Capsule />;
}
@@ -34,7 +35,7 @@ export function App({ isCapsule, isQa }: AppProps) {
return <QaPanel />;
}
const os = detectOS();
const os = forcedOs ?? detectOS();
// Windows 启动不应被权限探测阻塞首屏。
const [gate, setGate] = useState<Gate>(isTauri ? 'checking' : 'ready');
@@ -173,7 +174,7 @@ export function App({ isCapsule, isQa }: AppProps) {
}
return (
<HotkeySettingsProvider>
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell />}
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell os={os} />}
{gate === 'ready' && <AutoUpdateGate />}
</HotkeySettingsProvider>
);
@@ -12,7 +12,7 @@ import { invoke } from '@tauri-apps/api/core';
import type { DownloadEvent } from '@tauri-apps/plugin-updater';
import { Update } from '@tauri-apps/plugin-updater';
import { useTranslation } from 'react-i18next';
import { isTauri, restartApp } from '../lib/ipc';
import { isTauri, restartApp, type UpdateChannel } from '../lib/ipc';
import { Btn } from '../pages/_atoms';
const UPDATE_CHECK_TIMEOUT_MS = 15_000;
@@ -45,8 +45,9 @@ export interface UseAutoUpdate {
checking: boolean;
busy: boolean;
errorMessage: string | null;
/** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 */
checkForUpdates: () => Promise<void>;
/** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。
* `channel` 显式指定查哪个渠道;省略时由 Rust 端回落到 prefs.update_channel。 */
checkForUpdates: (channel?: UpdateChannel) => Promise<void>;
/** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */
installUpdate: () => Promise<void>;
/** 关闭对话框(仅在非 busy 状态可用)。 */
@@ -88,7 +89,7 @@ export function useAutoUpdate(): UseAutoUpdate {
setContentLength(null);
};
const checkForUpdates = async () => {
const checkForUpdates = async (channel?: UpdateChannel) => {
setStatus('checking');
setVersion('');
setErrorMessage(null);
@@ -103,6 +104,7 @@ export function useAutoUpdate(): UseAutoUpdate {
// Beta → fetch_latest_beta_release 拼出 -beta manifest URL 后再 check。
const metadata = await invoke<AppUpdateMetadata | null>('app_check_update_with_channel', {
timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
channel: channel ?? null,
});
if (!metadata) {
setStatus('none');
@@ -30,7 +30,7 @@ import {
PROVIDER_SETUP_PROMPT_DEFERRED_KEY,
shouldShowProviderSetupPrompt,
} from '../lib/providerSetup';
import { type SettingsSectionId } from '../pages/Settings';
import { type SettingsSectionId } from './SettingsModal';
import { useAppState, type AppTab } from '../state/useAppState';
interface NavItem {
@@ -170,13 +170,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
const openProviderSettings = () => {
rememberProviderPrompt();
openSettings('providers');
openSettings('services');
};
const openHotkeyRecordingSettings = () => {
window.localStorage.setItem(HOTKEY_MODE_MIGRATION_ACK_KEY, '1');
setHotkeyModePromptOpen(false);
openSettings('recording');
openSettings('general');
};
return (
@@ -0,0 +1,218 @@
// GitHub 登录弹窗 —— 风格市场与扩展市场共用同一套登录界面。
// GitHub OAuth Device Flow:打开即 start → 展示 user code 等浏览器授权 →
// 轮询直到 authorized。各阶段内容套同一 minHeight 容器,窗口尺寸恒定,
// 不再出现「先弹小窗、过会儿变大窗」的跳动。
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
githubDeviceFlowPoll,
githubDeviceFlowStart,
openExternal,
} from '../lib/ipc';
import { Btn } from '../pages/_atoms';
import { Modal } from './ui/Modal';
type Phase =
| { kind: 'starting' }
| { kind: 'pending'; userCode: string; verificationUri: string; deviceCode: string }
| { kind: 'success'; login: string }
| { kind: 'error'; message: string };
interface GithubLoginModalProps {
onClose: () => void;
/** 授权成功回调(拿到 GitHub login)。 */
onSuccess: (login: string) => void;
}
export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>({ kind: 'starting' });
const [copied, setCopied] = useState(false);
const cancelledRef = useRef(false);
// 用 ref 持有回调,poll 副作用只依赖 phase,不因父组件重渲染而重启。
const onSuccessRef = useRef(onSuccess);
const onCloseRef = useRef(onClose);
useEffect(() => {
onSuccessRef.current = onSuccess;
onCloseRef.current = onClose;
});
const begin = useCallback(async () => {
cancelledRef.current = false;
setPhase({ kind: 'starting' });
try {
const start = await githubDeviceFlowStart();
if (cancelledRef.current) return;
setPhase({
kind: 'pending',
userCode: start.userCode,
verificationUri: start.verificationUri,
deviceCode: start.deviceCode,
});
// 自动拉起浏览器;失败不致命,用户可手动复制。
try { await openExternal(start.verificationUri); } catch { /* manual fallback */ }
} catch (err) {
if (cancelledRef.current) return;
setPhase({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
}
}, []);
// 打开即发起登录。
useEffect(() => {
void begin();
return () => { cancelledRef.current = true; };
}, [begin]);
// pending 阶段轮询 backend。
useEffect(() => {
if (phase.kind !== 'pending') return;
let cancelled = false;
let timer: number | null = null;
let interval = 5_000;
const deviceCode = phase.deviceCode;
const tick = async () => {
if (cancelled) return;
try {
const res = await githubDeviceFlowPoll(deviceCode);
if (cancelled) return;
if (res.kind === 'authorized') {
setPhase({ kind: 'success', login: res.login });
onSuccessRef.current(res.login);
window.setTimeout(() => { if (!cancelled) onCloseRef.current(); }, 1200);
} else if (res.kind === 'slowDown') {
interval = Math.min(interval + 5_000, 30_000);
timer = window.setTimeout(tick, interval);
} else if (res.kind === 'pending') {
timer = window.setTimeout(tick, interval);
} else {
setPhase({ kind: 'error', message: res.message });
}
} catch (err) {
if (cancelled) return;
setPhase({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
}
};
timer = window.setTimeout(tick, interval);
return () => {
cancelled = true;
if (timer != null) window.clearTimeout(timer);
};
}, [phase]);
const close = () => {
cancelledRef.current = true;
onClose();
};
const copyCode = async () => {
if (phase.kind !== 'pending') return;
try {
await navigator.clipboard.writeText(phase.userCode);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch { /* clipboard unavailable */ }
};
return (
<Modal onClose={close} zIndex={60} width="min(440px, 100%)">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 12 }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>{t('marketplace.oauth.title')}</h2>
<button
type="button"
aria-label={t('common.close')}
title={t('common.close')}
onClick={close}
style={{
width: 28, height: 28, borderRadius: 8,
display: 'inline-grid', placeItems: 'center',
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink-2)',
cursor: 'pointer',
fontSize: 16, lineHeight: 1,
}}
>×</button>
</div>
{/* 固定最小高度 —— 各阶段共用,窗口尺寸恒定。 */}
<div style={{ minHeight: 220, display: 'flex', flexDirection: 'column' }}>
{phase.kind === 'starting' && (
<div style={{ flex: 1, display: 'grid', placeItems: 'center', color: 'var(--ol-ink-3)', fontSize: 13 }}>
{t('marketplace.oauth.generating')}
</div>
)}
{phase.kind === 'pending' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6 }}>
{t('marketplace.oauth.browserHint', { uri: phase.verificationUri })}
</div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12,
padding: 18, borderRadius: 12,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface-2)',
}}>
<span style={{
fontFamily: 'var(--ol-font-mono)',
fontSize: 22, fontWeight: 700,
letterSpacing: 2,
color: 'var(--ol-blue)',
}}>{phase.userCode}</span>
<Btn variant="ghost" size="sm" onClick={() => void copyCode()}>
{copied ? t('marketplace.oauth.copied') : t('marketplace.oauth.copyBtn')}
</Btn>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<Btn variant="ghost" size="sm" onClick={() => void openExternal(phase.verificationUri)}>
{t('marketplace.oauth.openBrowserBtn')}
</Btn>
<Btn variant="ghost" size="sm" onClick={close}>
{t('marketplace.oauth.cancelBtn')}
</Btn>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: 999,
background: 'var(--ol-blue)', animation: 'ol-pulse 1.4s ease-in-out infinite',
}} />
{t('marketplace.oauth.waiting')}
</div>
<style>{`@keyframes ol-pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }`}</style>
</div>
)}
{phase.kind === 'success' && (
<div style={{ flex: 1, display: 'grid', placeItems: 'center', textAlign: 'center' }}>
<div>
<div style={{ fontSize: 24, color: 'var(--ol-blue)', marginBottom: 8 }}>✓</div>
<div style={{ fontSize: 14, fontWeight: 650, color: 'var(--ol-ink)' }}>
{t('marketplace.oauth.successAs', { login: phase.login })}
</div>
</div>
</div>
)}
{phase.kind === 'error' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{
padding: 12, borderRadius: 10,
border: '0.5px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.06)',
color: '#b91c1c',
fontSize: 12, lineHeight: 1.6,
whiteSpace: 'pre-wrap',
}}>
{phase.message}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Btn variant="ghost" size="sm" onClick={close}>{t('marketplace.oauth.closeBtn')}</Btn>
<Btn variant="blue" size="sm" onClick={() => void begin()}>{t('marketplace.oauth.retryBtn')}</Btn>
</div>
</div>
)}
</div>
</Modal>
);
}
+1
View File
@@ -48,6 +48,7 @@ export const ICONS: Record<string, string> = {
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', // User
mail: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zM22 6l-10 7L2 6', // Mail
info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 16v-4M12 8h.01', // Info
shield: 'M12 22s8-3 8-9V5l-8-3-8 3v8c0 6 8 9 8 9z', // Shield (privacy)
external: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3', // External link
close: 'M18 6L6 18M6 6l12 12', // Close / X
play: 'M5 3l14 9-14 9V3z', // Play
@@ -41,7 +41,7 @@ export function MarketplaceModal({ onClose }: MarketplaceModalProps) {
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 28,
zIndex: 50,
animation: 'ol-modal-fade .2s var(--ol-motion-soft)',
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
}}
>
<div
@@ -55,7 +55,7 @@ export function MarketplaceModal({ onClose }: MarketplaceModalProps) {
overflow: 'hidden',
position: 'relative',
display: 'flex', flexDirection: 'column',
animation: 'ol-modal-pop .28s var(--ol-motion-spring)',
animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
}}
>
<div
+118 -494
View File
@@ -1,57 +1,29 @@
// SettingsModal.tsx — centered sheet with sub-nav on the left.
// SettingsModal.tsx — 居中弹窗,左侧单层侧栏。
//
// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 / 启动项 /
// 开机自启)已从此弹窗移除,避免 "看似可点实际无效" 的负面体感。
// 待 backend 就位后再补回(参见 issue #69)。
// 重构(2026-05):原本是「外层弹窗侧栏 + 设置页内层侧栏」双层嵌套,用户点
// 「设置」还要再面对第二个侧栏。现在拍平成单层 —— 通用 / 服务 / 隐私 / 高级 /
// 个性化 / 关于 六个 tab + 帮助外链组。每个 tab 的内容见 pages/settings/。
//
// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 等)
// 不在此弹窗出现。
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { AboutUpdateControl, Settings as SettingsContent, Toggle, type SettingsSectionId } from '../pages/Settings';
import { Row } from './ui/Row';
import { SavedToast } from './SavedToast';
import { useSavedToastListener } from '../lib/savedEvent';
import { readFontScale, setFontScale, type FontScaleId } from '../lib/fontScale';
import {
exportErrorLog,
fetchLatestBetaRelease,
getUpdateChannel,
openExternal,
setUpdateChannel,
type LatestBetaRelease,
type UpdateChannel,
} from '../lib/ipc';
import { APP_VERSION } from '../lib/appVersion';
import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate';
import { openExternal } from '../lib/ipc';
import type { OS } from './WindowChrome';
import { GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
import { AboutSection } from '../pages/settings/AboutSection';
// 把 Beta tag 名(如 "v1.3.4-1-beta-tauri" / "v1.3.2-3-beta-tauri")解析回 semver 版本号
// "1.3.4-1" / "1.3.2-3"。失败返回 null(让 UI fallback 到字符串相等比对)。
function parseVersionFromBetaTag(tag: string): string | null {
const trimmed = tag.replace(/^v/, '').replace(/-beta-tauri$/, '');
return /^\d+\.\d+\.\d+(-\d+)?$/.test(trimmed) ? trimmed : null;
}
// 简化版 semver 比较 —— 只覆盖本仓库实际用到的两种形态:X.Y.Z 和 X.Y.Z-N。
// 返回 true 表示 a > b。按 SemVer 规则:major→minor→patch→prerelease(none > some)。
function semverGreater(a: string, b: string): boolean {
const parse = (v: string) => {
const [main, pre] = v.split('-');
const [major, minor, patch] = main.split('.').map(n => Number.parseInt(n, 10));
const preNum = pre === undefined ? null : Number.parseInt(pre, 10);
return { major, minor, patch, preNum };
};
const A = parse(a);
const B = parse(b);
if (A.major !== B.major) return A.major > B.major;
if (A.minor !== B.minor) return A.minor > B.minor;
if (A.patch !== B.patch) return A.patch > B.patch;
// pre: null(无 prerelease)> 任何 prerelease
if (A.preNum === B.preNum) return false;
if (A.preNum === null) return true;
if (B.preNum === null) return false;
return A.preNum > B.preNum;
}
// 稳定 tab ID(与 i18n key `modal.sections.*` 一致)。
export type SettingsSectionId =
| 'general'
| 'services'
| 'privacy'
| 'advanced'
| 'about';
interface SettingsModalProps {
os: OS;
@@ -59,9 +31,6 @@ interface SettingsModalProps {
initialSettingsSection?: SettingsSectionId;
}
// 稳定 ID(与 i18n key 一致,方便 modal.sections.* 渲染)。
type ModalSectionId = 'settings' | 'personalize' | 'about';
interface ModalNavItem {
id: string;
icon: string;
@@ -69,39 +38,33 @@ interface ModalNavItem {
href?: string;
}
interface ModalGroup {
items: ModalNavItem[];
}
const HELP_URL = 'https://github.com/appergb/openless#readme';
const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
// 第一组:可选中的 tab;第二组:外部链接(永远不 active)。
const TAB_ITEMS: ModalNavItem[] = [
{ id: 'general', icon: 'settings' },
{ id: 'services', icon: 'cloud' },
{ id: 'privacy', icon: 'shield' },
{ id: 'advanced', icon: 'bolt' },
{ id: 'about', icon: 'info' },
];
const LINK_ITEMS: ModalNavItem[] = [
{ id: 'helpCenter', icon: 'help', external: true, href: HELP_URL },
{ id: 'releaseNotes', icon: 'doc', external: true, href: RELEASE_NOTES_URL },
];
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
const { t } = useTranslation();
const [section, setSection] = useState<ModalSectionId>('settings');
const [section, setSection] = useState<SettingsSectionId>(initialSettingsSection ?? 'general');
const savedToast = useSavedToastListener();
const groups: ModalGroup[] = [
{
items: [
{ id: 'settings', icon: 'settings' },
{ id: 'personalize', icon: 'sparkle' },
{ id: 'about', icon: 'info' },
],
},
{
items: [
{ id: 'helpCenter', icon: 'help', external: true, href: HELP_URL },
{ id: 'releaseNotes', icon: 'doc', external: true, href: RELEASE_NOTES_URL },
],
},
];
// 与 sidebar nav 一致的滑动指示器:仅第一组(可选中)有 pill;外链组永远不 active 不画 pill。
const firstGroupRefs = useRef<Array<HTMLButtonElement | null>>([]);
// 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill。
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
useLayoutEffect(() => {
const idx = groups[0].items.findIndex(it => it.id === section);
const el = firstGroupRefs.current[idx];
const idx = TAB_ITEMS.findIndex(it => it.id === section);
const el = tabRefs.current[idx];
if (!el) return;
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
}, [section]);
@@ -117,7 +80,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 28,
zIndex: 50,
animation: 'ol-modal-fade .2s var(--ol-motion-soft)',
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
}}>
<div
@@ -129,11 +92,11 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
border: '0.5px solid rgba(0,0,0,.08)',
boxShadow: '0 30px 80px -20px rgba(15,17,22,.35), 0 0 0 0.5px rgba(0,0,0,.06)',
display: 'flex', overflow: 'hidden',
animation: 'ol-modal-pop .28s var(--ol-motion-spring)',
animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
position: 'relative',
}}>
{/* sub-sidebar */}
{/* ─── 单层侧栏 ────────────────────────────────────────────── */}
<aside
style={{
width: 200, flexShrink: 0,
@@ -143,70 +106,63 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
display: 'flex', flexDirection: 'column', gap: 14,
}}>
{groups.map((g, gi) => (
<div key={gi} style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1, paddingTop: gi === 1 ? 8 : 0, borderTop: gi === 1 ? '0.5px solid var(--ol-line-soft)' : 'none' }}>
{gi === 0 && pillRect && (
<div
aria-hidden
style={{
position: 'absolute',
left: 0,
right: 0,
top: pillRect.top,
height: pillRect.height,
background: '#fff',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
{g.items.map((it, idx) => {
const active = section === it.id && !it.external;
return (
<button
key={it.id}
ref={gi === 0 ? (el => { firstGroupRefs.current[idx] = el; }) : undefined}
onClick={() => {
if (it.external && it.href) {
void openExternal(it.href);
} else {
setSection(it.id as ModalSectionId);
}
}}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: 'transparent',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default', textAlign: 'left',
position: 'relative',
zIndex: 1,
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
}}>
{/* tab 组 */}
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
{pillRect && (
<div
aria-hidden
style={{
position: 'absolute',
left: 0,
right: 0,
top: pillRect.top,
height: pillRect.height,
background: '#fff',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
{TAB_ITEMS.map((it, idx) => {
const active = section === it.id;
return (
<button
key={it.id}
ref={el => { tabRefs.current[idx] = el; }}
onClick={() => setSection(it.id as SettingsSectionId)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={navBtnStyle}>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
</button>
);
})}
</div>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
{it.external && <Icon name="external" size={11} />}
</button>
);
})}
</div>
))}
{/* 外链组 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: 8, borderTop: '0.5px solid var(--ol-line-soft)' }}>
{LINK_ITEMS.map(it => (
<button
key={it.id}
onClick={() => { if (it.href) void openExternal(it.href); }}
className="ol-nav-btn"
style={navBtnStyle}>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
<Icon name="external" size={11} />
</button>
))}
</div>
</aside>
{/* content — 父容器 overflow:hidden + 列向 flex;X 和 h2 固定在头部,
只有最里层的 scroll wrapper 真正滚动。这样模态左 sidebar、关闭按钮、
section 标题都不会跟着内容一起飘。 */}
{/* ─── 内容区 ──────────────────────────────────────────────
父容器 overflow:hidden + 列向 flex;关闭按钮、section 标题固定在头部,
只有最里层的 scroll wrapper 真正滚动。 */}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
{/* "已保存"toast 在内容区右上角;right:54 避开 28×28 关闭按钮 + 12px gap。
弹窗内用 absolute 锚内容区、从上方滑入 / 滑回 —— 外层 overflow:hidden
会把它裁在面板顶边,读感即"从屏幕外上方来、回上方去"。
CredentialField 等通过 emitSaved 发事件,useSavedToastListener 接收。 */}
{/* "已保存" toast:right:54 避开 28×28 关闭按钮 + 12px gap。 */}
<SavedToast
saveState={savedToast.state}
message={savedToast.message}
@@ -226,373 +182,41 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.05)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
title={t('common.close')}>
<Icon name="close" size={14} />
</button>
<h2 style={{ margin: 0, padding: '22px 28px 8px', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', flexShrink: 0 }}>{t(`modal.sections.${section}`)}</h2>
<h2 style={{ margin: 0, padding: '22px 28px 8px', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', flexShrink: 0 }}>
{t(`modal.sections.${section}`)}
</h2>
{section === 'settings' ? (
// SettingsContent 自己接管 flex:1 + 内部右栏 scroll,外层不能再加 overflow:auto。
<div style={{ flex: 1, minHeight: 0, padding: '10px 28px 28px', display: 'flex', flexDirection: 'column' }}>
<SettingsContent embedded initialSection={initialSettingsSection} />
<div
className="ol-thinscroll"
style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '10px 28px 28px' }}>
{/* key=section 让切 tab 时整块重挂载,ol-tab-fade 轻微淡入。 */}
<div
key={section}
style={{ display: 'flex', flexDirection: 'column', gap: 12, animation: 'ol-tab-fade 0.2s var(--ol-motion-soft)' }}>
{section === 'general' && <GeneralTab />}
{section === 'services' && <ServicesTab />}
{section === 'privacy' && <PrivacyTab />}
{section === 'advanced' && <AdvancedTab />}
{section === 'about' && <AboutSection />}
</div>
) : (
// personalize / about 短内容:单一 scroll wrapper,超出时本块滚动。
<div className="ol-thinscroll" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '10px 28px 28px' }}>
{section === 'personalize' && <PersonalizeSection />}
{section === 'about' && <AboutMini />}
</div>
)}
</div>
</div>
</div>
<style>{`
@keyframes ol-modal-fade {
from { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); }
to { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); }
}
@keyframes ol-modal-pop {
from { opacity: 0; transform: translateY(8px) scale(.98); filter: blur(8px); }
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}
`}</style>
</div>
);
}
function PersonalizeSection() {
const { t } = useTranslation();
// 玻璃强度持久化到 localStorage,并实时写入 CSS var --ol-glass-blur。
// 这是 CSS-only 的层(影响 backdrop-filter 的内层强度);macOS NSVisualEffectView
// 是另一层,由 Tauri 在窗口创建时一次性配置,运行时改动需要重启 App。
const [blur, setBlur] = useState<number>(() => {
const saved = window.localStorage.getItem('ol.glassBlur');
return saved ? Number(saved) : 22;
});
useEffect(() => {
document.documentElement.style.setProperty('--ol-glass-blur', `${blur}px`);
window.localStorage.setItem('ol.glassBlur', String(blur));
}, [blur]);
const [fontScale, setFontScaleState] = useState<FontScaleId>(() => readFontScale());
const applyFontScaleChoice = (next: FontScaleId) => {
setFontScaleState(next);
setFontScale(next);
};
const fontOptions: Array<[FontScaleId, string]> = [
['small', t('modal.personalize.fontSmall')],
['medium', t('modal.personalize.fontMedium')],
['large', t('modal.personalize.fontLarge')],
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row label={t('modal.personalize.font')} desc={t('modal.personalize.fontDesc')}>
<div style={{ display: 'flex', gap: 4, padding: 2, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
{fontOptions.map(([id, label]) => {
const selected = fontScale === id;
return (
<button
key={id}
onClick={() => applyFontScaleChoice(id)}
style={{
minWidth: 64,
height: 28,
border: 0,
borderRadius: 6,
background: selected ? '#fff' : 'transparent',
color: selected ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
fontFamily: 'inherit',
fontSize: 12,
fontWeight: selected ? 600 : 500,
cursor: 'default',
boxShadow: selected ? '0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
padding: '0 12px',
}}
>
{label}
</button>
);
})}
</div>
</Row>
<Row label={t('modal.personalize.blur')} desc={t('modal.personalize.blurDesc')}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<input
type="range"
min="0"
max="48"
value={blur}
onChange={e => setBlur(Number(e.target.value))}
style={{ width: 200, accentColor: 'var(--ol-blue)' }}
/>
<span style={{ fontSize: 12, fontFamily: 'var(--ol-font-mono)', color: 'var(--ol-ink-3)', minWidth: 36 }}>
{blur}px
</span>
</div>
</Row>
</div>
);
}
function AboutMini() {
const { t } = useTranslation();
const [qqCopied, setQqCopied] = useState(false);
const qqCopiedRef = useRef<number | null>(null);
const [exportStatus, setExportStatus] = useState<'idle' | 'busy' | 'ok' | 'err'>('idle');
const [exportMessage, setExportMessage] = useState<string>('');
useEffect(() => () => {
if (qqCopiedRef.current) clearTimeout(qqCopiedRef.current);
}, []);
const copyQq = () => {
navigator.clipboard?.writeText('1078960553');
setQqCopied(true);
if (qqCopiedRef.current) clearTimeout(qqCopiedRef.current);
qqCopiedRef.current = window.setTimeout(() => setQqCopied(false), 1500);
};
const onExportLog = async () => {
setExportStatus('busy');
setExportMessage('');
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const target = await exportErrorLog(`openless-${ts}.log`);
if (target == null) {
setExportStatus('idle');
return;
}
setExportStatus('ok');
setExportMessage(target);
window.setTimeout(() => setExportStatus('idle'), 4000);
} catch (err) {
setExportStatus('err');
setExportMessage(err instanceof Error ? err.message : String(err));
}
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
<img src="AppIcon.png" alt="" style={{ width: 56, height: 56, borderRadius: 13, boxShadow: '0 4px 10px rgba(0,0,0,.10), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
<div>
<div style={{ fontSize: 17, fontWeight: 600 }}>OpenLess</div>
<AboutUpdateControl tagline={t('modal.about.tagline')} />
</div>
</div>
<Row label={t('modal.about.source')}>
<button
style={btnGhost}
onClick={() => openExternal('https://github.com/appergb/openless')}
>
GitHub
</button>
</Row>
<Row label={t('modal.about.docs')}>
<button
style={btnGhost}
onClick={() => openExternal('https://github.com/appergb/openless#readme')}
>
{t('modal.about.docsBtn')}
</button>
</Row>
<Row label={t('modal.about.feedback')}>
<button
style={btnGhost}
onClick={() => openExternal('https://github.com/appergb/openless/issues')}
>
{t('modal.about.feedbackBtn')}
</button>
</Row>
<Row label={t('modal.about.qq')} desc={t('modal.about.qqDesc')}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<kbd style={{
padding: '4px 10px', fontSize: 12, fontFamily: 'var(--ol-font-mono)',
borderRadius: 6, background: 'var(--ol-surface-2)',
border: '0.5px solid var(--ol-line-strong)',
boxShadow: '0 1px 0 rgba(0,0,0,0.04)',
color: 'var(--ol-ink-2)',
}}>1078960553</kbd>
<button onClick={copyQq} title={t('modal.about.copyQq')} style={btnGhost}>
<Icon name="copy" size={14} />
</button>
{qqCopied && <span style={{ fontSize: 11, color: 'var(--ol-ok)', whiteSpace: 'nowrap' }}>{t('common.copied')}</span>}
</div>
</Row>
<Row label={t('modal.about.exportErrorLog')} desc={t('modal.about.exportErrorLogDesc')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={btnGhost} onClick={onExportLog} disabled={exportStatus === 'busy'}>
{exportStatus === 'busy' ? t('modal.about.exporting') : t('modal.about.exportErrorLogBtn')}
</button>
{exportStatus === 'ok' && (
<span style={{ fontSize: 11, color: 'var(--ol-ok)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 220 }}
title={exportMessage}>
{t('modal.about.exportSuccess')}
</span>
)}
{exportStatus === 'err' && (
<span style={{ fontSize: 11, color: 'var(--ol-err)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 220 }}
title={exportMessage}>
{t('modal.about.exportFailed')}
</span>
)}
</div>
</Row>
<Row label={t('modal.about.privacy')} desc={t('modal.about.privacyDesc')}>
<span style={{ fontSize: 11, padding: '3px 8px', borderRadius: 999, background: 'var(--ol-blue-soft)', color: 'var(--ol-blue)', fontWeight: 500 }}>{t('modal.about.localFirst')}</span>
</Row>
<BetaChannelControl />
</div>
);
}
// Beta 渠道开关:物理隔离的 opt-in,**已接 auto-update**(PR feat/beta-auto-update)。
// - 关闭 = Stable 渠道,「检查更新」走 tauri.conf 默认 endpoints
// - 打开 = 写 prefs.update_channel = 'beta';Rust 端 app_check_update_with_channel
// 命令会自动拉最新 prerelease tag 拼成 -beta manifest URL,再走 plugin-updater
// 的 check/download/install 标准流程
// - 这里拉一次 fetch_latest_beta_release 仅用于「告诉用户最新 Beta 是哪个版本」做
// 信息透明;不再渲染手动下载按钮(auto-update 接管了那条路)。
// 不在 Beta 渠道时不发起 GitHub API 请求,避免空切换浪费配额。
function BetaChannelControl() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannel>('stable');
const [latest, setLatest] = useState<LatestBetaRelease | null>(null);
const [status, setStatus] = useState<'idle' | 'fetching' | 'empty' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string>('');
const updater = useAutoUpdate();
// 本机 vs 最新 Beta tag 比对:
// - 'unknown' → 还没拿到 latest 或解析失败,按"未知"渲染
// - 'up-to-date' → 本机版本 >= 远端最新 Beta,按"已是最新"渲染、隐藏更新按钮
// - 'newer' → 远端有更新版本,渲染「立即更新」按钮触发 auto-update 流程
const remoteVersion = latest ? parseVersionFromBetaTag(latest.tagName) : null;
const comparison: 'unknown' | 'up-to-date' | 'newer' = !remoteVersion
? 'unknown'
: semverGreater(remoteVersion, APP_VERSION)
? 'newer'
: 'up-to-date';
useEffect(() => {
let cancelled = false;
void getUpdateChannel()
.then(c => { if (!cancelled) setChannel(c); })
.catch(() => { /* fall back to stable already in initial state */ });
return () => { cancelled = true; };
}, []);
const fetchBeta = async () => {
setStatus('fetching');
setErrorMessage('');
try {
const info = await fetchLatestBetaRelease();
if (info == null) {
setLatest(null);
setStatus('empty');
} else {
setLatest(info);
setStatus('idle');
}
} catch (err) {
setStatus('error');
setErrorMessage(err instanceof Error ? err.message : String(err));
}
};
const onToggle = async (next: boolean) => {
const target: UpdateChannel = next ? 'beta' : 'stable';
setChannel(target);
try {
await setUpdateChannel(target);
} catch (err) {
setStatus('error');
setErrorMessage(err instanceof Error ? err.message : String(err));
// 写入失败时回滚 UI,免得用户以为切成功了。
setChannel(target === 'beta' ? 'stable' : 'beta');
return;
}
if (target === 'beta') {
void fetchBeta();
} else {
setLatest(null);
setStatus('idle');
setErrorMessage('');
}
};
return (
<>
<Row label={t('settings.about.betaChannelLabel')} desc={t('settings.about.betaChannelDesc')}>
<Toggle on={channel === 'beta'} onToggle={onToggle} />
</Row>
{channel === 'beta' && (
<div style={{ fontSize: 11, color: 'var(--ol-ink-3)', marginTop: -4, marginBottom: 12, lineHeight: 1.6 }}>
{status === 'fetching' && <span>{t('settings.about.betaChannelFetching')}</span>}
{status === 'empty' && <span>{t('settings.about.betaChannelNoBeta')}</span>}
{status === 'error' && (
<span style={{ color: 'var(--ol-err)' }} title={errorMessage}>
{t('settings.about.betaChannelFetchError')}
</span>
)}
{status === 'idle' && latest && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>
{t('settings.about.betaChannelLatestPrefix')} <code style={{ fontFamily: 'var(--ol-font-mono)' }}>{latest.tagName}</code>
</span>
{comparison === 'up-to-date' && (
<span style={{
fontSize: 11, padding: '2px 8px', borderRadius: 999,
background: 'var(--ol-blue-soft)', color: 'var(--ol-blue)', fontWeight: 500,
}}>{t('settings.about.betaChannelUpToDate')}</span>
)}
{comparison === 'newer' && (
<button
style={{ ...btnGhost, borderColor: 'var(--ol-blue)', color: 'var(--ol-blue)' }}
onClick={() => void updater.checkForUpdates()}
disabled={updater.checking || updater.busy}
title={t('settings.about.betaChannelUpdateNowTitle')}
>
{updater.checking
? t('settings.about.betaChannelChecking')
: t('settings.about.betaChannelUpdateNow')}
</button>
)}
<button style={btnGhost} onClick={fetchBeta} title={t('settings.about.betaChannelRefresh')}>
<Icon name="refresh" size={12} />
</button>
</div>
)}
{status === 'idle' && !latest && (
<button style={btnGhost} onClick={fetchBeta}>
{t('settings.about.betaChannelFetchBtn')}
</button>
)}
</div>
)}
{isDialogStatus(updater.status) && (
<UpdateDialog
status={updater.status}
version={updater.version}
progress={updater.progress}
downloaded={updater.downloaded}
contentLength={updater.contentLength}
onInstall={() => void updater.installUpdate()}
onClose={() => void updater.dismissDialog()}
/>
)}
</>
);
}
const btnGhost: CSSProperties = {
padding: '5px 10px', fontSize: 12, borderRadius: 6,
border: '0.5px solid var(--ol-line-strong)',
background: '#fff', color: 'var(--ol-ink-2)',
cursor: 'default', fontFamily: 'inherit',
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
const navBtnStyle = {
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: 'transparent',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default', textAlign: 'left' as const,
position: 'relative' as const,
zIndex: 1,
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
};
@@ -0,0 +1,51 @@
// Modal — 居中弹窗:backdrop + 卡片。风格市场详情 / 上传 / 我的发布 / GitHub 登录
// 等共用同一套弹出逻辑,避免每处各写一个。
//
// 动画沿用 global.css 的 ol-modal-backdrop-in / ol-modal-card-in(纯 opacity +
// transform,不碰 blur),与设置弹窗、各市场弹窗保持一致。
import type { ReactNode } from 'react';
interface ModalProps {
children: ReactNode;
onClose: () => void;
/** 默认 50;多层叠加时(如登录弹窗叠在「我的发布」之上)传更大的值。 */
zIndex?: number;
/** 卡片宽度,默认 'min(560px, 100%)'。 */
width?: string;
}
export function Modal({ children, onClose, zIndex = 50, width = 'min(560px, 100%)' }: ModalProps) {
return (
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.22)',
display: 'grid',
placeItems: 'center',
zIndex,
padding: 20,
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width,
maxHeight: '85vh',
overflow: 'auto',
borderRadius: 16,
background: 'var(--ol-surface)',
border: '0.5px solid var(--ol-line-strong)',
boxShadow: '0 18px 42px rgba(0,0,0,0.18)',
padding: 22,
animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
}}
>
{children}
</div>
</div>
);
}
@@ -25,6 +25,7 @@ import {
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { Icon } from '../Icon';
@@ -33,6 +34,8 @@ export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
/** 可选:渲染在选项标签右侧、勾选标记左侧(如麦克风音量条)。 */
trailing?: ReactNode;
}
interface SelectLiteProps {
@@ -43,6 +46,8 @@ interface SelectLiteProps {
disabled?: boolean;
style?: CSSProperties;
ariaLabel?: string;
/** 下拉打开 / 关闭时回调 —— 让调用方按开合状态启停副作用(如电平监听)。 */
onOpenChange?: (open: boolean) => void;
}
const DEFAULT_TRIGGER_STYLE: CSSProperties = {
@@ -74,6 +79,7 @@ export function SelectLite({
disabled = false,
style,
ariaLabel,
onOpenChange,
}: SelectLiteProps) {
const [open, setOpen] = useState(false);
// leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount)
@@ -194,10 +200,12 @@ export function SelectLite({
setHighlight(initial >= 0 ? initial : options.findIndex(opt => !opt.disabled));
setLeaving(false);
setOpen(true);
onOpenChange?.(true);
};
const closeMenu = () => {
if (!open) return;
onOpenChange?.(false);
setLeaving(true);
window.setTimeout(() => {
setOpen(false);
@@ -356,6 +364,7 @@ export function SelectLite({
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{option.label}
</span>
{option.trailing}
{isSelected && <Icon name="check" size={12} />}
</div>
);
+29 -27
View File
@@ -495,17 +495,31 @@ export const en: typeof zhCN = {
kicker: 'SETTINGS',
title: 'Settings',
desc: 'Recording, providers, shortcuts, and permissions.',
sections: {
recording: 'Recording',
providers: 'Providers',
shortcuts: 'Shortcuts',
permissions: 'Permissions',
language: 'Language',
advanced: 'Advanced',
about: 'About',
dataStorage: {
title: 'Data storage',
desc: 'Conversation history and context kept on this device.',
},
debug: {
title: 'Debug tools',
desc: 'For troubleshooting recognition issues; off by default.',
},
marketplace: {
title: 'Marketplace',
desc: 'Upload identity for the style marketplace. Browse and install styles on the Styles page.',
github: {
signIn: 'Sign in with GitHub',
signedIn: 'Signed in with GitHub',
signedOut: 'Sign in to upload styles and like packs.',
signOut: 'Sign out',
starting: 'Starting sign-in…',
codeHint: 'Enter this code on the GitHub page that just opened:',
openGithub: 'Open GitHub',
waiting: 'GitHub opened — you’ll be signed in once you authorize…',
failed: 'Sign-in failed, please retry',
},
},
recording: {
title: 'Recording',
title: 'Recording & input',
desc: 'Global recording hotkey and trigger mode.',
hotkeyLabel: 'Recording hotkey',
hotkeyDescAcc: 'Press to capture voice globally (requires Accessibility permission).',
@@ -765,8 +779,9 @@ export const en: typeof zhCN = {
privacy: 'Privacy',
privacyDesc: 'All data stays on this device. Cloud APIs do not retain recordings.',
localFirst: 'Local-first',
linksTitle: 'Documentation',
betaChannelLabel: 'Join Beta channel',
betaChannelDesc: 'Receive Beta updates when enabled. May be unstable — recommended only for early adopters.',
betaChannelDesc: 'Early access to new features',
betaChannelFetching: 'Fetching the latest Beta…',
betaChannelFetchBtn: 'Look up latest Beta',
betaChannelLatestPrefix: 'Latest Beta:',
@@ -807,25 +822,16 @@ export const en: typeof zhCN = {
},
modal: {
sections: {
account: 'Account',
settings: 'Settings',
general: 'General',
services: 'Services',
privacy: 'Privacy',
advanced: 'Advanced',
personalize: 'Personalize',
about: 'About',
helpCenter: 'Help center',
releaseNotes: 'Release notes',
},
account: {
localUser: 'Local user',
localUserDesc: 'Not signed in · all data stays local',
loginSync: 'Sign in / Sync',
footer: 'Runs fully locally by default. Sign in to sync vocabulary and style presets across devices.',
},
personalize: {
appearance: 'Appearance',
appearanceDesc: 'Follow system / Light / Dark',
appearanceSystem: 'Follow system',
appearanceLight: 'Light',
appearanceDark: 'Dark',
font: 'Font size',
fontDesc: 'Scale the entire UI font size — applies instantly.',
fontSmall: 'Small',
@@ -833,10 +839,6 @@ export const en: typeof zhCN = {
fontLarge: 'Large',
blur: 'Glass blur intensity',
blurDesc: 'Affects the inner backdrop-filter strength (the macOS system frosted layer can not be tuned at runtime).',
startupOpen: 'On launch',
startupOverview: 'Overview',
startupLast: 'Last position',
startupAtBoot: 'Launch at login',
},
about: {
tagline: 'Speak naturally, write perfectly',
+29 -27
View File
@@ -497,17 +497,31 @@ export const ja: typeof zhCN = {
kicker: 'SETTINGS',
title: '設定',
desc: '録音、プロバイダー、ショートカット、権限の設定。',
sections: {
recording: '録音',
providers: 'プロバイダー',
shortcuts: 'ショートカット',
permissions: '権限',
language: '言語',
advanced: '詳細設定',
about: '情報',
dataStorage: {
title: 'データ保存',
desc: 'この端末に保存される会話履歴とコンテキスト。',
},
debug: {
title: 'デバッグツール',
desc: '認識の問題を調査するときに使用。通常はオフのままで構いません。',
},
marketplace: {
title: '拡張マーケット',
desc: 'スタイルマーケットの投稿者 ID。スタイルの閲覧とインストールは「スタイル」ページで行います。',
github: {
signIn: 'GitHub でログイン',
signedIn: 'GitHub でログイン済み',
signedOut: 'ログインするとスタイルの投稿・いいねができます。',
signOut: 'ログアウト',
starting: 'ログインを開始しています…',
codeHint: '開いた GitHub ページでこのコードを入力してください:',
openGithub: 'GitHub を開く',
waiting: 'GitHub を開きました。承認するとログインします…',
failed: 'ログインに失敗しました。再試行してください',
},
},
recording: {
title: '録音',
title: '録音と入力',
desc: 'グローバル録音のショートカットとトリガー方式を定義します。',
hotkeyLabel: '録音ショートカット',
hotkeyDescAcc: '押すと音声キャプチャを開始(グローバル)。アクセシビリティ権限が必要です。',
@@ -767,8 +781,9 @@ export const ja: typeof zhCN = {
privacy: 'プライバシー',
privacyDesc: 'すべてのデータはローカルに保存。クラウド API は録音を保持しません。',
localFirst: 'ローカル優先',
linksTitle: 'ドキュメント',
betaChannelLabel: 'Beta チャンネルに参加',
betaChannelDesc: '有効にすると Beta 更新を受信。不安定な場合があります。',
betaChannelDesc: '新機能をいち早く体験',
betaChannelFetching: '最新 Beta 版を取得中…',
betaChannelFetchBtn: '最新 Beta を確認',
betaChannelLatestPrefix: '最新 Beta:',
@@ -809,25 +824,16 @@ export const ja: typeof zhCN = {
},
modal: {
sections: {
account: 'アカウント',
settings: '設定',
general: '一般',
services: 'サービス',
privacy: 'プライバシー',
advanced: '詳細設定',
personalize: 'パーソナライズ',
about: '情報',
helpCenter: 'ヘルプセンター',
releaseNotes: 'リリースノート',
},
account: {
localUser: 'ローカルユーザー',
localUserDesc: '未ログイン · すべてのデータはローカルに保存',
loginSync: 'ログイン / 同期',
footer: 'デフォルトで完全ローカル動作。ログインすると語彙とスタイルプリセットをデバイス間で同期。',
},
personalize: {
appearance: '外観',
appearanceDesc: 'システムに従う / ライト / ダーク',
appearanceSystem: 'システムに従う',
appearanceLight: 'ライト',
appearanceDark: 'ダーク',
font: 'フォントサイズ',
fontDesc: 'UI のフォントサイズを全体的にスケール。即時反映。',
fontSmall: '小',
@@ -835,10 +841,6 @@ export const ja: typeof zhCN = {
fontLarge: '大',
blur: 'すりガラス強度',
blurDesc: 'ウィンドウ内側の backdrop-filter 強度に影響(macOS のシステムフロスト層が動かない場合に調整)。',
startupOpen: '起動時に開く',
startupOverview: '概要',
startupLast: '前回の位置',
startupAtBoot: '起動時に自動起動',
},
about: {
tagline: '自然に話し、きれいに書く',
+29 -27
View File
@@ -497,17 +497,31 @@ export const ko: typeof zhCN = {
kicker: 'SETTINGS',
title: '설정',
desc: '녹음, 공급자, 단축키, 권한 설정.',
sections: {
recording: '녹음',
providers: '공급자',
shortcuts: '단축키',
permissions: '권한',
language: '언어',
advanced: '고급',
about: '정보',
dataStorage: {
title: '데이터 저장',
desc: '이 기기에 보관되는 대화 기록과 컨텍스트.',
},
debug: {
title: '디버그 도구',
desc: '인식 문제를 진단할 때 사용합니다. 평소에는 꺼두어도 됩니다.',
},
marketplace: {
title: '확장 마켓',
desc: '스타일 마켓 업로드 신원. 스타일 둘러보기와 설치는 「스타일」 페이지에서 합니다.',
github: {
signIn: 'GitHub로 로그인',
signedIn: 'GitHub로 로그인됨',
signedOut: '로그인하면 스타일 업로드와 좋아요를 할 수 있습니다.',
signOut: '로그아웃',
starting: '로그인을 시작하는 중…',
codeHint: '열린 GitHub 페이지에서 이 코드를 입력하세요:',
openGithub: 'GitHub 열기',
waiting: 'GitHub를 열었습니다. 승인하면 로그인됩니다…',
failed: '로그인 실패, 다시 시도하세요',
},
},
recording: {
title: '녹음',
title: '녹음 및 입력',
desc: '전역 녹음의 단축키와 트리거 방식을 정의합니다.',
hotkeyLabel: '녹음 단축키',
hotkeyDescAcc: '누르면 음성 캡처 시작(전역). 접근성 권한이 필요합니다.',
@@ -767,8 +781,9 @@ export const ko: typeof zhCN = {
privacy: '프라이버시',
privacyDesc: '모든 데이터는 로컬에 저장됩니다. 클라우드 API 는 녹음을 보관하지 않습니다.',
localFirst: '로컬 우선',
linksTitle: '문서 링크',
betaChannelLabel: 'Beta 채널 참여',
betaChannelDesc: '활성화 시 Beta 업데이트 수신. 불안정할 수 있으며 얼리 어답터만 권장.',
betaChannelDesc: '새 기능 미리 체험',
betaChannelFetching: '최신 Beta 버전을 가져오는 중…',
betaChannelFetchBtn: '최신 Beta 확인',
betaChannelLatestPrefix: '최신 Beta:',
@@ -809,25 +824,16 @@ export const ko: typeof zhCN = {
},
modal: {
sections: {
account: '계정',
settings: '설정',
general: '일반',
services: '서비스',
privacy: '프라이버시',
advanced: '고급',
personalize: '개인 설정',
about: '정보',
helpCenter: '도움말 센터',
releaseNotes: '릴리스 노트',
},
account: {
localUser: '로컬 사용자',
localUserDesc: '로그인하지 않음 · 모든 데이터는 로컬에 저장',
loginSync: '로그인 / 동기화',
footer: '기본적으로 완전 로컬 실행. 로그인하면 어휘와 스타일 프리셋을 기기 간 동기화.',
},
personalize: {
appearance: '모양',
appearanceDesc: '시스템 따라가기 / 라이트 / 다크',
appearanceSystem: '시스템 따라가기',
appearanceLight: '라이트',
appearanceDark: '다크',
font: '글꼴 크기',
fontDesc: 'UI 글꼴 크기를 전체 스케일. 즉시 반영.',
fontSmall: '소',
@@ -835,10 +841,6 @@ export const ko: typeof zhCN = {
fontLarge: '대',
blur: '서리유리 강도',
blurDesc: '창 내부 backdrop-filter 강도에 영향(macOS 시스템 서리 레이어가 작동하지 않을 때 조정).',
startupOpen: '시작 시 열기',
startupOverview: '개요',
startupLast: '마지막 위치',
startupAtBoot: '부팅 시 자동 시작',
},
about: {
tagline: '자연스럽게 말하고, 정확하게 작성하세요',
+30 -28
View File
@@ -493,17 +493,31 @@ export const zhCN = {
kicker: 'SETTINGS',
title: '设置',
desc: '录音、提供商、快捷键与权限配置。',
sections: {
recording: '录音',
providers: '提供商',
shortcuts: '快捷键',
permissions: '权限',
language: '语言',
advanced: '高级',
about: '关于',
dataStorage: {
title: '数据存储',
desc: '本机保留的历史会话与对话上下文。',
},
debug: {
title: '调试工具',
desc: '排查识别问题时使用,平时无需开启。',
},
marketplace: {
title: '扩展市场',
desc: '风格市场的上传身份。浏览与安装风格在「风格」页内完成。',
github: {
signIn: '用 GitHub 账号登录',
signedIn: '已通过 GitHub 登录',
signedOut: '登录后即可上传风格、给风格点赞。',
signOut: '退出登录',
starting: '正在发起登录…',
codeHint: '在打开的 GitHub 页面输入这个验证码:',
openGithub: '打开 GitHub',
waiting: '已打开 GitHub,完成授权后会自动登录…',
failed: '登录失败,请重试',
},
},
recording: {
title: '录音',
title: '录音与输入',
desc: '全局录音的快捷键与触发方式。',
hotkeyLabel: '录音快捷键',
hotkeyDescAcc: '按下开始捕获语音,全局生效(需辅助功能权限)。',
@@ -763,8 +777,9 @@ export const zhCN = {
privacy: '隐私',
privacyDesc: '所有数据仅保存在本机,云端 API 不保留录音。',
localFirst: '本地优先',
linksTitle: '文档链接',
betaChannelLabel: '加入 Beta 渠道',
betaChannelDesc: '开启后自动接收 Beta 版本。可能不稳定,仅推荐愿意尝鲜的用户。',
betaChannelDesc: '抢先体验新功能',
betaChannelFetching: '正在获取最新 Beta 版本…',
betaChannelFetchBtn: '查询最新 Beta',
betaChannelLatestPrefix: '最新 Beta:',
@@ -805,25 +820,16 @@ export const zhCN = {
},
modal: {
sections: {
account: '账户',
settings: '设置',
general: '通用',
services: '服务',
privacy: '隐私',
advanced: '高级',
personalize: '个性化',
about: '关于',
helpCenter: '帮助中心',
releaseNotes: '版本说明',
},
account: {
localUser: '本地用户',
localUserDesc: '未登录 · 所有数据保存在本机',
loginSync: '登录 / 同步',
footer: '默认完全本地运行。登录后可跨设备同步词汇表与风格预设。',
releaseNotes: '发布日志',
},
personalize: {
appearance: '外观',
appearanceDesc: '跟随系统 / 浅色 / 深色',
appearanceSystem: '跟随系统',
appearanceLight: '浅色',
appearanceDark: '深色',
font: '字体大小',
fontDesc: '整体缩放界面字号,立即生效。',
fontSmall: '小',
@@ -831,10 +837,6 @@ export const zhCN = {
fontLarge: '大',
blur: '毛玻璃强度',
blurDesc: '影响窗口内层 backdrop-filter 强度(macOS 系统磨砂层无法运行时调)。',
startupOpen: '启动时打开',
startupOverview: '概览',
startupLast: '上次位置',
startupAtBoot: '开机自启',
},
about: {
tagline: '自然说话,完美书写',
+30 -28
View File
@@ -495,17 +495,31 @@ export const zhTW: typeof zhCN = {
kicker: 'SETTINGS',
title: '設置',
desc: '錄音、提供商、快捷鍵與權限配置。',
sections: {
recording: '錄音',
providers: '提供商',
shortcuts: '快捷鍵',
permissions: '權限',
language: '語言',
advanced: '高級',
about: '關於',
dataStorage: {
title: '資料儲存',
desc: '本機保留的歷史會話與對話上下文。',
},
debug: {
title: '除錯工具',
desc: '排查辨識問題時使用,平時無需開啟。',
},
marketplace: {
title: '擴充市集',
desc: '風格市集的上傳身份。瀏覽與安裝風格在「風格」頁內完成。',
github: {
signIn: '用 GitHub 帳號登入',
signedIn: '已透過 GitHub 登入',
signedOut: '登入後即可上傳風格、為風格按讚。',
signOut: '登出',
starting: '正在發起登入…',
codeHint: '在開啟的 GitHub 頁面輸入這個驗證碼:',
openGithub: '開啟 GitHub',
waiting: '已開啟 GitHub,完成授權後會自動登入…',
failed: '登入失敗,請重試',
},
},
recording: {
title: '錄音',
title: '錄音與輸入',
desc: '定義全局錄音的快捷鍵與觸發方式。',
hotkeyLabel: '錄音快捷鍵',
hotkeyDescAcc: '按下即開始捕獲語音,全局生效。需要授予輔助功能權限。',
@@ -765,8 +779,9 @@ export const zhTW: typeof zhCN = {
privacy: '隱私',
privacyDesc: '所有資料僅保存在本機,雲端 API 不保留錄音。',
localFirst: '本地優先',
linksTitle: '文件連結',
betaChannelLabel: '加入 Beta 渠道',
betaChannelDesc: '開啟後自動接收 Beta 版本。可能不穩定,僅推薦願意嘗鮮的用戶。',
betaChannelDesc: '搶先體驗新功能',
betaChannelFetching: '正在獲取最新 Beta 版本…',
betaChannelFetchBtn: '查詢最新 Beta',
betaChannelLatestPrefix: '最新 Beta:',
@@ -807,25 +822,16 @@ export const zhTW: typeof zhCN = {
},
modal: {
sections: {
account: '賬戶',
settings: '設置',
general: '通用',
services: '服務',
privacy: '隱私',
advanced: '高級',
personalize: '個性化',
about: '關於',
helpCenter: '幫助中心',
releaseNotes: '版本說明',
},
account: {
localUser: '本地用戶',
localUserDesc: '未登錄 · 所有數據保存在本機',
loginSync: '登錄 / 同步',
footer: '預設完全本地運行。登入後可跨裝置同步詞彙表與風格預設。',
releaseNotes: '發佈日誌',
},
personalize: {
appearance: '外觀',
appearanceDesc: '跟隨系統 / 淺色 / 深色',
appearanceSystem: '跟隨系統',
appearanceLight: '淺色',
appearanceDark: '深色',
font: '字體大小',
fontDesc: '整體縮放界面字號,立即生效。',
fontSmall: '小',
@@ -833,10 +839,6 @@ export const zhTW: typeof zhCN = {
fontLarge: '大',
blur: '毛玻璃強度',
blurDesc: '影響窗口內層 backdrop-filter 強度(macOS 系統磨砂層無法運行時調)。',
startupOpen: '啓動時打開',
startupOverview: '概覽',
startupLast: '上次位置',
startupAtBoot: '開機自啓',
},
about: {
tagline: '自然說話,完美書寫',
+1 -1
View File
@@ -141,7 +141,7 @@ const mockFullStylePrompts: StyleSystemPrompts = {
语音输入整理器。把 AI 编程协作、技术排障和模型资讯口述整理成结构清楚、术语准确的文本。
# 任务(清晰结构 · AI 编程协作)
优先修正 ASR 造成的技术词、模型名、字段名错误;多事项按主题输出双层 list,操作指引输出连续步骤。
优先修正 ASR 造成的技术词、模型名、字段名错误;两个事项以上必须编号(1./2./3.),三事项以上按主题分组输出双层 list。
# 术语
Token、Secret Key、Access Token、API、App ID、Claude、Gemini、Cappuccino、Coder、LongCat、Codex、MCP、SSE、PR、CI、ASR、LLM、SOTA、FP8。保留命令、路径、环境变量、URL、true / false / null 和模型版本号。
+4 -1
View File
@@ -5,17 +5,20 @@ import i18n from "./i18n"; // 副作用:触发 i18next init
import "./styles/tokens.css";
import "./styles/global.css";
import type { OS } from "./components/WindowChrome";
const params = new URLSearchParams(window.location.search);
const windowKind = params.get("window");
const isCapsule = windowKind === "capsule";
const isQa = windowKind === "qa";
const osQuery = params.get("os") as OS | null;
const root = ReactDOM.createRoot(document.getElementById("root")!);
const renderApp = () => {
root.render(
<React.StrictMode>
<App isCapsule={isCapsule} isQa={isQa} />
<App isCapsule={isCapsule} isQa={isQa} forcedOs={osQuery} />
</React.StrictMode>,
);
};
+17 -261
View File
@@ -17,10 +17,10 @@ import { AnimatePresence, motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { SavedToast } from '../components/SavedToast';
import { GithubLoginModal } from '../components/GithubLoginModal';
import { Modal } from '../components/ui/Modal';
import {
fetchMarketplaceDetail,
githubDeviceFlowPoll,
githubDeviceFlowStart,
installMarketplacePack,
likeMarketplacePack,
listMarketplace,
@@ -28,7 +28,6 @@ import {
marketplaceDelete,
marketplaceMyLikes,
marketplaceMyPacks,
openExternal,
readMarketplaceDetailCache,
readMarketplaceListCache,
uploadMarketplacePack,
@@ -75,15 +74,8 @@ export function Marketplace() {
// 失败后只弹 toast,没有 inline 重试入口。
const [myPacksLoading, setMyPacksLoading] = useState(false);
const [myPacksError, setMyPacksError] = useState<string | null>(null);
// GitHub OAuth Device Flow 状态。点登录 chip → 'starting' → 'pending'(展示 user_code 等待
// 用户在浏览器授权)→ 'success'(自动保存 marketplaceDevLogin)/ 'error'。
type OAuthPhase =
| { phase: 'idle' }
| { phase: 'starting' }
| { phase: 'pending'; userCode: string; verificationUri: string; deviceCode: string }
| { phase: 'success'; login: string }
| { phase: 'error'; message: string };
const [oauth, setOauth] = useState<OAuthPhase>({ phase: 'idle' });
// GitHub 登录弹窗开关 —— 登录流程交给共用的 <GithubLoginModal />。
const [showLogin, setShowLogin] = useState(false);
// 当前用户赞过的 pack id 集合 —— 用于红心渲染 + 「我赞过的」过滤。
// 进入 marketplace 时拉一次;点星后本地 mutate。
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
@@ -402,67 +394,11 @@ export function Marketplace() {
}
};
// GitHub OAuth Device Flow 入口:点登录 chip 触发。
const beginGithubLogin = useCallback(async () => {
setOauth({ phase: 'starting' });
try {
const start = await githubDeviceFlowStart();
setOauth({
phase: 'pending',
userCode: start.userCode,
verificationUri: start.verificationUri,
deviceCode: start.deviceCode,
});
// 自动拉起浏览器到 verification_uri;失败不致命,用户可以手动复制点击
try { await openExternal(start.verificationUri); } catch { /* user can copy manually */ }
} catch (error) {
setOauth({ phase: 'error', message: errorMessage(error) });
}
}, []);
// OAuth 轮询:phase==='pending' 时每 interval 秒打 backend → GitHub 一次。
useEffect(() => {
if (oauth.phase !== 'pending') return;
let cancelled = false;
let timer: number | null = null;
let interval = 5_000;
const pendingDeviceCode = oauth.deviceCode;
const tick = async () => {
if (cancelled) return;
try {
const res = await githubDeviceFlowPoll(pendingDeviceCode);
if (cancelled) return;
if (res.kind === 'authorized') {
setOauth({ phase: 'success', login: res.login });
// 写入 prefs.marketplaceDevLogin,让后续 X-Dev-User 走真实 GitHub login。
try {
await updatePrefs(current => ({ ...current, marketplaceDevLogin: res.login }));
} catch (e) {
console.warn('[oauth] save login to prefs failed', e);
}
setActionMsg({ kind: 'ok', text: t('marketplace.oauth.successAs', { login: res.login }) });
window.setTimeout(() => {
if (!cancelled) setOauth({ phase: 'idle' });
}, 1500);
} else if (res.kind === 'slowDown') {
interval = Math.min(interval + 5_000, 30_000);
timer = window.setTimeout(tick, interval);
} else if (res.kind === 'pending') {
timer = window.setTimeout(tick, interval);
} else {
setOauth({ phase: 'error', message: res.message });
}
} catch (error) {
if (cancelled) return;
setOauth({ phase: 'error', message: errorMessage(error) });
}
};
timer = window.setTimeout(tick, interval);
return () => {
cancelled = true;
if (timer != null) window.clearTimeout(timer);
};
}, [oauth, updatePrefs]);
// GitHub 登录成功 → 写回 prefs.marketplaceDevLogin,让后续 X-Dev-User 走真实身份。
const onLoginSuccess = useCallback((nextLogin: string) => {
void updatePrefs(current => ({ ...current, marketplaceDevLogin: nextLogin }));
setActionMsg({ kind: 'ok', text: t('marketplace.oauth.successAs', { login: nextLogin }) });
}, [updatePrefs, t]);
const sortPills = useMemo<Array<{ id: SortMode; label: string }>>(
() => [
@@ -901,8 +837,7 @@ export function Marketplace() {
<button
type="button"
title={currentLogin ? t('marketplace.oauth.reloginTooltip', { login: currentLogin }) : t('marketplace.oauth.loginTooltip')}
onClick={() => void beginGithubLogin()}
disabled={oauth.phase === 'starting' || oauth.phase === 'pending'}
onClick={() => setShowLogin(true)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '5px 10px', borderRadius: 9,
@@ -910,9 +845,8 @@ export function Marketplace() {
background: currentLogin ? 'var(--ol-blue-soft)' : 'var(--ol-surface)',
color: currentLogin ? 'var(--ol-blue)' : 'var(--ol-ink-3)',
fontSize: 12, fontWeight: 650,
cursor: (oauth.phase === 'starting' || oauth.phase === 'pending') ? 'wait' : 'pointer',
cursor: 'pointer',
whiteSpace: 'nowrap',
opacity: (oauth.phase === 'starting' || oauth.phase === 'pending') ? 0.6 : 1,
}}
>
<span style={{
@@ -1070,195 +1004,17 @@ export function Marketplace() {
</Modal>
)}
{/* GitHub OAuth Device Flow 弹框(叠在「我的发布」之上)*/}
{oauth.phase !== 'idle' && (
<Modal onClose={() => {
// 关闭即放弃;正在 pending 也允许取消
setOauth({ phase: 'idle' });
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 12 }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>{t('marketplace.oauth.title')}</h2>
<button
type="button"
aria-label={t('common.close')}
title={t('common.close')}
onClick={() => setOauth({ phase: 'idle' })}
style={{
width: 28, height: 28, borderRadius: 8,
display: 'inline-grid', placeItems: 'center',
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink-2)',
cursor: 'pointer',
fontSize: 16, lineHeight: 1,
}}
>×</button>
</div>
{oauth.phase === 'starting' && (
<div style={{ padding: '24px 8px', textAlign: 'center', color: 'var(--ol-ink-3)', fontSize: 13 }}>
{t('marketplace.oauth.generating')}
</div>
)}
{oauth.phase === 'pending' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6 }}>
{(() => {
const parts = t('marketplace.oauth.browserHint', { uri: ' URI ' }).split(' URI ');
return (
<>
{parts[0]}
<span style={{ fontFamily: 'var(--ol-font-mono)', fontSize: 12, padding: '1px 5px', background: 'var(--ol-surface-2)', borderRadius: 4 }}>{oauth.verificationUri}</span>
{parts[1] ?? ''}
</>
);
})()}
</div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12,
padding: 18, borderRadius: 12,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface-2)',
}}>
<span style={{
fontFamily: 'var(--ol-font-mono)',
fontSize: 22, fontWeight: 700,
letterSpacing: 2,
color: 'var(--ol-blue)',
}}>{oauth.userCode}</span>
<Btn
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(oauth.userCode);
setActionMsg({ kind: 'ok', text: t('marketplace.oauth.copied') });
} catch (e) {
setActionMsg({ kind: 'err', text: t('marketplace.oauth.copyFailed', { err: errorMessage(e) }) });
}
}}
>
{t('marketplace.oauth.copyBtn')}
</Btn>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<Btn variant="ghost" size="sm" onClick={() => void openExternal(oauth.verificationUri)}>
{t('marketplace.oauth.openBrowserBtn')}
</Btn>
<Btn variant="ghost" size="sm" onClick={() => setOauth({ phase: 'idle' })}>
{t('marketplace.oauth.cancelBtn')}
</Btn>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: 999,
background: 'var(--ol-blue)', animation: 'ol-pulse 1.4s ease-in-out infinite',
}} />
{t('marketplace.oauth.waiting')}
</div>
<style>{`
@keyframes ol-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
`}</style>
</div>
)}
{oauth.phase === 'success' && (
<div style={{ padding: '24px 8px', textAlign: 'center' }}>
<div style={{ fontSize: 24, color: 'var(--ol-blue)', marginBottom: 8 }}>✓</div>
<div style={{ fontSize: 14, fontWeight: 650, color: 'var(--ol-ink)' }}>{t('marketplace.oauth.successAs', { login: oauth.login })}</div>
</div>
)}
{oauth.phase === 'error' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{
padding: 12, borderRadius: 10,
border: '0.5px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.06)',
color: '#b91c1c',
fontSize: 12, lineHeight: 1.6,
whiteSpace: 'pre-wrap',
}}>
{oauth.message}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Btn variant="ghost" size="sm" onClick={() => setOauth({ phase: 'idle' })}>{t('marketplace.oauth.closeBtn')}</Btn>
<Btn variant="blue" size="sm" onClick={() => void beginGithubLogin()}>{t('marketplace.oauth.retryBtn')}</Btn>
</div>
</div>
)}
</Modal>
{/* GitHub 登录弹窗 */}
{showLogin && (
<GithubLoginModal
onClose={() => setShowLogin(false)}
onSuccess={onLoginSuccess}
/>
)}
</div>
);
}
function Modal({
children,
onClose,
zIndex = 50,
}: {
children: React.ReactNode;
onClose: () => void;
/** 默认 50;多层叠加时(如上传 picker 在「我的发布」之上)传更大的值。*/
zIndex?: number;
}) {
return (
<div
className="ol-modal-backdrop"
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.22)',
display: 'grid',
placeItems: 'center',
zIndex,
padding: 20,
}}
>
<div
className="ol-modal-card"
onClick={e => e.stopPropagation()}
style={{
width: 'min(560px, 100%)',
maxHeight: '85vh',
overflow: 'auto',
borderRadius: 16,
background: 'var(--ol-surface)',
border: '0.5px solid var(--ol-line-strong)',
boxShadow: '0 18px 42px rgba(0,0,0,0.18)',
padding: 22,
}}
>
{children}
</div>
<style>{`
@keyframes ol-modal-backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ol-modal-card-in {
from { opacity: 0; transform: scale(.96) translateY(4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.ol-modal-backdrop {
animation: ol-modal-backdrop-in .14s ease-out;
will-change: opacity;
}
.ol-modal-card {
animation: ol-modal-card-in .18s cubic-bezier(.34,1.56,.64,1);
will-change: transform, opacity;
}
`}</style>
</div>
);
}
function statusLabel(state: string, t: (key: string) => string): string {
switch (state) {
case 'pending': return t('marketplace.state.pending');
+2 -2
View File
@@ -655,7 +655,7 @@ const contentStyle: CSSProperties = {
flex: 1,
minHeight: 0,
overflow: 'auto',
padding: '14px 16px',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 12,
@@ -754,7 +754,7 @@ const statusBarStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 14px',
padding: '0 16px',
borderTop: '0.5px solid rgba(0, 0, 0, 0.06)',
background: 'rgba(255,255,255,0.4)',
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,151 @@
// 关于 → 版本信息 / 检查更新 / 字体大小 / 文档链接。
// 「个性化」原本是独立 tab,但只剩字体大小一项、整页太空,遂并入「关于」。
// 「加入 Beta 渠道」已挪到「高级」页底部(见 BetaChannelSection),这里图标旁
// 只保留查正式版的「检查更新」按钮。
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../../components/Icon';
import { Row } from '../../components/ui/Row';
import { openExternal } from '../../lib/ipc';
import { APP_VERSION_LABEL } from '../../lib/appVersion';
import { readFontScale, setFontScale, type FontScaleId } from '../../lib/fontScale';
import { Card } from '../_atoms';
import { SectionTitle } from './shared';
import { CheckUpdateButton } from './CheckUpdateButton';
export function AboutSection() {
const { t } = useTranslation();
const [qqCopied, setQqCopied] = useState(false);
const qqCopiedRef = useRef<number | null>(null);
useEffect(() => () => {
if (qqCopiedRef.current) clearTimeout(qqCopiedRef.current);
}, []);
const copyQq = () => {
navigator.clipboard?.writeText('1078960553');
setQqCopied(true);
if (qqCopiedRef.current) clearTimeout(qqCopiedRef.current);
qqCopiedRef.current = window.setTimeout(() => setQqCopied(false), 1500);
};
return (
<>
{/* ─── 版本信息 + 检查更新(正式版)─────────────────────────────── */}
<Card>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<img
src="AppIcon.png"
alt=""
style={{ width: 56, height: 56, borderRadius: 13, boxShadow: '0 4px 10px rgba(0,0,0,.10), 0 0 0 0.5px rgba(0,0,0,.06)' }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 600 }}>OpenLess</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 2 }}>
{t('modal.about.tagline')} · {APP_VERSION_LABEL}
</div>
</div>
{/* 图标右上方:查正式版的检查更新按钮。Beta 渠道在「高级」页。 */}
<CheckUpdateButton channel="stable" />
</div>
</Card>
{/* ─── 个性化(字体大小)—— 原 personalize tab 并入此处 ──────────── */}
<Card>
<SectionTitle>{t('modal.sections.personalize')}</SectionTitle>
<FontSizeRow />
</Card>
{/* ─── 文档链接 ─────────────────────────────────────────────── */}
<Card>
<SectionTitle>{t('settings.about.linksTitle')}</SectionTitle>
<Row label={t('modal.about.source')}>
<button style={btnGhost} onClick={() => openExternal('https://github.com/appergb/openless')}>
GitHub
</button>
</Row>
<Row label={t('modal.about.docs')}>
<button style={btnGhost} onClick={() => openExternal('https://github.com/appergb/openless#readme')}>
{t('modal.about.docsBtn')}
</button>
</Row>
<Row label={t('modal.about.feedback')}>
<button style={btnGhost} onClick={() => openExternal('https://github.com/appergb/openless/issues')}>
{t('modal.about.feedbackBtn')}
</button>
</Row>
<Row label={t('modal.about.qq')}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<kbd style={{
padding: '4px 10px', fontSize: 12, fontFamily: 'var(--ol-font-mono)',
borderRadius: 6, background: 'var(--ol-surface-2)',
border: '0.5px solid var(--ol-line-strong)',
boxShadow: '0 1px 0 rgba(0,0,0,0.04)',
color: 'var(--ol-ink-2)',
}}>1078960553</kbd>
<button onClick={copyQq} title={t('modal.about.copyQq')} style={btnGhost}>
<Icon name="copy" size={14} />
</button>
{qqCopied && <span style={{ fontSize: 11, color: 'var(--ol-ok)', whiteSpace: 'nowrap' }}>{t('common.copied')}</span>}
</div>
</Row>
</Card>
</>
);
}
// 字体大小 —— 整体缩放界面字号,立即生效(fontScale.ts 走 html.style.zoom)。
function FontSizeRow() {
const { t } = useTranslation();
const [fontScale, setFontScaleState] = useState<FontScaleId>(() => readFontScale());
const applyFontScaleChoice = (next: FontScaleId) => {
setFontScaleState(next);
setFontScale(next);
};
const fontOptions: Array<[FontScaleId, string]> = [
['small', t('modal.personalize.fontSmall')],
['medium', t('modal.personalize.fontMedium')],
['large', t('modal.personalize.fontLarge')],
];
return (
<Row label={t('modal.personalize.font')}>
<div style={{ display: 'flex', gap: 4, padding: 2, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
{fontOptions.map(([id, label]) => {
const selected = fontScale === id;
return (
<button
key={id}
onClick={() => applyFontScaleChoice(id)}
style={{
minWidth: 64,
height: 28,
border: 0,
borderRadius: 6,
background: selected ? '#fff' : 'transparent',
color: selected ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
fontFamily: 'inherit',
fontSize: 12,
fontWeight: selected ? 600 : 500,
cursor: 'default',
boxShadow: selected ? '0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
padding: '0 12px',
}}
>
{label}
</button>
);
})}
</div>
</Row>
);
}
const btnGhost: CSSProperties = {
padding: '5px 10px', fontSize: 12, borderRadius: 6,
border: '0.5px solid var(--ol-line-strong)',
background: '#fff', color: 'var(--ol-ink-2)',
cursor: 'default', fontFamily: 'inherit',
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
};
@@ -1,53 +0,0 @@
// 关于面板里嵌入的"检查更新"控件。
// 注:原 Settings 内的 About tab 已并入 SettingsModal 的 AboutMini;这里只是版本号 + 检查按钮。
import { useTranslation } from 'react-i18next';
import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../../components/AutoUpdate';
import { APP_VERSION_LABEL } from '../../lib/appVersion';
import { Btn } from '../_atoms';
export function AboutUpdateControl({ tagline }: { tagline: string }) {
const { t } = useTranslation();
const u = useAutoUpdate();
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2 }}>
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>{tagline} 路 {APP_VERSION_LABEL}</span>
<Btn variant="ghost" size="sm" onClick={u.checkForUpdates} disabled={u.checking || u.busy}>
{u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
</Btn>
</div>
{u.status === 'none' && (
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>
{t('settings.about.upToDate')}
</div>
)}
{u.status === 'error' && (
<div style={{ marginTop: 4 }}>
<div style={{ fontSize: 11, color: 'var(--ol-err)' }}>
{t('settings.about.updateError')}
</div>
{u.errorMessage && (
<div style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', marginTop: 2, wordBreak: 'break-word' }}>
{u.errorMessage}
</div>
)}
<Btn variant="ghost" size="sm" onClick={u.checkForUpdates} style={{ marginTop: 4 }}>
{t('settings.about.retryBtn') ?? t('common.retry') ?? '重试'}
</Btn>
</div>
)}
{isDialogStatus(u.status) && (
<UpdateDialog
status={u.status}
version={u.version}
progress={u.progress}
downloaded={u.downloaded}
contentLength={u.contentLength}
onInstall={u.installUpdate}
onClose={u.dismissDialog}
/>
)}
</>
);
}
@@ -0,0 +1,51 @@
// 高级 → 加入 Beta 渠道。单独成一节,固定放在「高级」页最下面。
//
// 打开后写 prefs.update_channel='beta':后台 AutoUpdateGate 自动更新随之走 Beta,
// 同时本节出现「检查更新」按钮 —— 手动查测试版更新(CheckUpdateButton channel='beta')。
// 关于页的检查更新按钮固定查正式版(channel='stable'),两者互不影响。
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getUpdateChannel, setUpdateChannel, type UpdateChannel } from '../../lib/ipc';
import { Card } from '../_atoms';
import { SectionTitle, Toggle } from './shared';
import { CheckUpdateButton } from './CheckUpdateButton';
export function BetaChannelSection() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannel>('stable');
useEffect(() => {
let cancelled = false;
void getUpdateChannel()
.then(c => { if (!cancelled) setChannel(c); })
.catch(() => { /* fall back to stable already in initial state */ });
return () => { cancelled = true; };
}, []);
const onToggle = async (next: boolean) => {
const target: UpdateChannel = next ? 'beta' : 'stable';
setChannel(target);
try {
await setUpdateChannel(target);
} catch {
// 写入失败时回滚 UI,免得用户以为切成功了。
setChannel(target === 'beta' ? 'stable' : 'beta');
}
};
return (
<Card>
<SectionTitle>{t('settings.about.betaChannelLabel')}</SectionTitle>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, paddingTop: 2 }}>
<span style={{ fontSize: 12.5, color: 'var(--ol-ink-3)' }}>
{t('settings.about.betaChannelDesc')}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
{channel === 'beta' && <CheckUpdateButton channel="beta" />}
<Toggle on={channel === 'beta'} onToggle={onToggle} />
</div>
</div>
</Card>
);
}
@@ -0,0 +1,77 @@
// 检查更新按钮 —— 关于页查正式版(channel='stable')、高级页 Beta 区查测试版
// (channel='beta'),共用此组件。
//
// 检查中:按钮内图标转圈。结果(已是最新 / 失败)只在按钮内以图标 + 颜色短暂
// 呈现 2.5s 后自动回到 idle,绝不另起文字块、不改变所在卡片高度 —— 杜绝
// 「渲染框突然变大 / 抽搐」。发现新版则弹出固定定位的 UpdateDialog。
import { useEffect, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../../components/Icon';
import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../../components/AutoUpdate';
import type { UpdateChannel } from '../../lib/ipc';
export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
const { t } = useTranslation();
const updater = useAutoUpdate();
const { status, checking, busy } = updater;
useEffect(() => {
if (status === 'none' || status === 'error') {
const id = window.setTimeout(() => { void updater.dismissDialog(); }, 2500);
return () => window.clearTimeout(id);
}
return undefined;
}, [status, updater]);
const upToDate = status === 'none';
const failed = status === 'error';
const iconName = upToDate ? 'check' : 'refresh';
const color = upToDate ? 'var(--ol-ok)' : failed ? 'var(--ol-err)' : 'var(--ol-ink-2)';
const label = checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn');
return (
<>
<button
onClick={() => void updater.checkForUpdates(channel)}
disabled={checking || busy}
title={
failed
? (updater.errorMessage ?? t('settings.about.updateError'))
: upToDate
? t('settings.about.upToDate')
: undefined
}
style={{ ...checkBtnStyle, color, opacity: checking || busy ? 0.7 : 1 }}
>
<Icon
name={iconName}
size={12}
style={checking ? { animation: 'ol-spin 0.8s linear infinite' } : undefined}
/>
<span style={{ whiteSpace: 'nowrap' }}>{label}</span>
</button>
{isDialogStatus(status) && (
<UpdateDialog
status={status}
version={updater.version}
progress={updater.progress}
downloaded={updater.downloaded}
contentLength={updater.contentLength}
onInstall={() => void updater.installUpdate()}
onClose={() => void updater.dismissDialog()}
/>
)}
</>
);
}
const checkBtnStyle: CSSProperties = {
padding: '5px 10px', fontSize: 12, borderRadius: 6,
border: '0.5px solid var(--ol-line-strong)',
background: '#fff',
cursor: 'default', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
minWidth: 84,
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
};
@@ -0,0 +1,84 @@
// 隐私 → 数据存储:本地保留的历史会话与对话上下文窗口。
// 自 Settings.tsx 的 RecordingSection「历史与上下文」折叠组拆出,逻辑零改动。
import { useTranslation } from 'react-i18next';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Card } from '../_atoms';
import { SettingRow, SectionTitle, inputStyle } from './shared';
// 范围限制:retention 0-365 天,context window 0-60 分钟(再大对实际对话场景没意义且白烧 token)。
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
export function DataStorageSection() {
const { t } = useTranslation();
const { prefs, updatePrefs: savePrefs } = useHotkeySettings();
if (!prefs) {
return (
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
</Card>
);
}
// 空字符串时回滚到默认值。
const onHistoryRetentionChange = (raw: string) => {
const parsed = raw === '' ? 0 : Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) return;
void savePrefs({ ...prefs, historyRetentionDays: clamp(parsed, 0, 365) });
};
const onPolishContextWindowChange = (raw: string) => {
const parsed = raw === '' ? 0 : Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) return;
void savePrefs({ ...prefs, polishContextWindowMinutes: clamp(parsed, 0, 60) });
};
// 历史条数 200 是当前 HISTORY_CAP(persistence.rs:32),下限 5 是避免用户填 0 导致
// 写一条就立刻被清光;空字符串视为不限制,落回 null → 后端走 200 默认。
const onHistoryMaxEntriesChange = (raw: string) => {
const trimmed = raw.trim();
if (trimmed === '') {
void savePrefs({ ...prefs, historyMaxEntries: null });
return;
}
const parsed = Number.parseInt(trimmed, 10);
if (Number.isNaN(parsed)) return;
void savePrefs({ ...prefs, historyMaxEntries: clamp(parsed, 5, 200) });
};
return (
<Card>
<SectionTitle>{t('settings.dataStorage.title')}</SectionTitle>
<SettingRow label={t('settings.recording.historyRetentionLabel')}>
<input
type="number"
min={0}
max={365}
value={prefs.historyRetentionDays}
onChange={e => onHistoryRetentionChange(e.target.value)}
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
/>
</SettingRow>
<SettingRow label={t('settings.recording.historyMaxEntriesLabel')}>
<input
type="number"
min={5}
max={200}
placeholder="200"
value={prefs.historyMaxEntries ?? ''}
onChange={e => onHistoryMaxEntriesChange(e.target.value)}
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
/>
</SettingRow>
<SettingRow label={t('settings.recording.polishContextWindowLabel')}>
<input
type="number"
min={0}
max={60}
value={prefs.polishContextWindowMinutes}
onChange={e => onPolishContextWindowChange(e.target.value)}
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
/>
</SettingRow>
</Card>
);
}
@@ -0,0 +1,110 @@
// 高级 → 调试工具:保留原始录音、导出错误日志等排障入口。
// recordAudioForDebug 行自 Settings.tsx 的 RecordingSection 拆出;
// 导出错误日志自 SettingsModal 的 AboutMini 迁入 —— 调试相关集中到此处。
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { exportErrorLog } from '../../lib/ipc';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Btn, Card } from '../_atoms';
import { SettingRow, Toggle, SectionTitle, inputStyle } from './shared';
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
export function DebugToolsSection() {
const { t } = useTranslation();
const { prefs, updatePrefs: savePrefs } = useHotkeySettings();
const [exportStatus, setExportStatus] = useState<'idle' | 'busy' | 'ok' | 'err'>('idle');
const [exportMessage, setExportMessage] = useState<string>('');
const exportTimerRef = useRef<number | null>(null);
useEffect(() => () => {
if (exportTimerRef.current) clearTimeout(exportTimerRef.current);
}, []);
const onExportLog = async () => {
setExportStatus('busy');
setExportMessage('');
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const target = await exportErrorLog(`openless-${ts}.log`);
if (target == null) {
setExportStatus('idle');
return;
}
setExportStatus('ok');
setExportMessage(target);
if (exportTimerRef.current) clearTimeout(exportTimerRef.current);
exportTimerRef.current = window.setTimeout(() => setExportStatus('idle'), 4000);
} catch (err) {
setExportStatus('err');
setExportMessage(err instanceof Error ? err.message : String(err));
}
};
if (!prefs) {
return (
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
</Card>
);
}
const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) =>
savePrefs({ ...prefs, recordAudioForDebug });
// 留空视为不限制,落回 null → 后端走 200 默认。
const onAudioRecordingMaxEntriesChange = (raw: string) => {
const trimmed = raw.trim();
if (trimmed === '') {
void savePrefs({ ...prefs, audioRecordingMaxEntries: null });
return;
}
const parsed = Number.parseInt(trimmed, 10);
if (Number.isNaN(parsed)) return;
void savePrefs({ ...prefs, audioRecordingMaxEntries: clamp(parsed, 1, 200) });
};
return (
<Card>
<SectionTitle>{t('settings.debug.title')}</SectionTitle>
<SettingRow label={t('settings.recording.recordAudioForDebugLabel')}>
<Toggle on={prefs.recordAudioForDebug} onToggle={onRecordAudioForDebugChange} />
</SettingRow>
<SettingRow label={t('settings.recording.audioRecordingMaxEntriesLabel')}>
<input
type="number"
min={1}
max={200}
placeholder="200"
value={prefs.audioRecordingMaxEntries ?? ''}
onChange={e => onAudioRecordingMaxEntriesChange(e.target.value)}
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
disabled={!prefs.recordAudioForDebug}
/>
</SettingRow>
<SettingRow label={t('modal.about.exportErrorLog')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Btn variant="ghost" size="sm" disabled={exportStatus === 'busy'} onClick={onExportLog}>
{exportStatus === 'busy' ? t('modal.about.exporting') : t('modal.about.exportErrorLogBtn')}
</Btn>
{exportStatus === 'ok' && (
<span
style={{ fontSize: 11, color: 'var(--ol-ok)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 220 }}
title={exportMessage}
>
{t('modal.about.exportSuccess')}
</span>
)}
{exportStatus === 'err' && (
<span
style={{ fontSize: 11, color: 'var(--ol-err)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 220 }}
title={exportMessage}
>
{t('modal.about.exportFailed')}
</span>
)}
</div>
</SettingRow>
</Card>
);
}
@@ -46,9 +46,8 @@ export function LanguageSection() {
return (
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.language.title')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>{t('settings.language.desc')}</div>
<SettingRow label={t('settings.language.label')} desc={t('settings.language.labelDesc')}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>{t('settings.language.title')}</div>
<SettingRow label={t('settings.language.label')}>
<SelectLite
value={pref}
onChange={next => apply(next as SupportedLocale | typeof FOLLOW_SYSTEM)}
@@ -57,9 +56,6 @@ export function LanguageSection() {
style={{ maxWidth: 220, minWidth: 200 }}
/>
</SettingRow>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 8, lineHeight: 1.6 }}>
{t('settings.language.restartHint')}
</div>
</Card>
);
}
@@ -1,5 +1,5 @@
// 高级设置:流式输入开关 / 同步剪贴板 / 本地 ASR 模型启用与禁用。
// 拆出自 Settings.tsx,逻辑零改动;i18n key 全部保持 `settings.advanced.*`。
// 高级 → 本地模型:本地 ASR 推理引擎的启用 / 禁用 + 模型下载管理。
// 自 Settings.tsx 的 AdvancedSection 拆出(流式输入已挪到「录音与输入」),逻辑零改动。
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,13 +10,12 @@ import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Btn, Card } from '../_atoms';
import { SettingRow, Toggle, type AsrPresetId } from './shared';
export function AdvancedSection() {
export function LocalModelSection() {
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
const os = detectOS();
const isMac = os === 'mac';
const isWin = os === 'win';
const isLinux = os === 'linux';
const platformSupported = isMac || isWin;
const switchSeqRef = useRef(0);
const [busy, setBusy] = useState(false);
@@ -109,64 +108,15 @@ export function AdvancedSection() {
</div>
)}
{/* ─── 流式输入(全平台 opt-in) ───────────────────────────────────
润色 SSE 一边到达一边逐字模拟键盘事件落到光标。开启后用户感知到的处理
时延显著降低,但有几个限制(不满足时自动回落原一次性插入路径):
- macOS:CGEvent Unicode + 临时切到 ABC 输入源(CJK / 日文 IME 拦截兜底)
- Windows:SendInput Unicode,绕过 TSF / IME,不需要切输入法
- Linux:通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键
- 仅 OpenAI-compatible provider 实装;Gemini / Codex 透明降级
- 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落
每个平台用各自的 hint key,互相不显示对方平台的细节。 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
{t(isLinux
? 'settings.advanced.streamingInsertTitleLinux'
: 'settings.advanced.streamingInsertTitle')}
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 10 }}>
{t('settings.advanced.streamingInsertDesc')}
</div>
<SettingRow
label={t('settings.advanced.streamingInsertLabel')}
desc={t(
isMac
? 'settings.advanced.streamingInsertHintMac'
: isWin
? 'settings.advanced.streamingInsertHintWindows'
: 'settings.advanced.streamingInsertHintLinux'
)}>
<Toggle
on={!!prefs?.streamingInsert}
onToggle={(next) => {
if (prefs) void updatePrefs({ ...prefs, streamingInsert: next });
}}
/>
</SettingRow>
<SettingRow
label={t('settings.advanced.streamingInsertSaveClipboardLabel')}
desc={t('settings.advanced.streamingInsertSaveClipboardHint')}>
<Toggle
on={!!prefs?.streamingInsertSaveClipboard}
onToggle={(next) => {
if (prefs) void updatePrefs({ ...prefs, streamingInsertSaveClipboard: next });
}}
/>
</SettingRow>
</Card>
<Card>
{/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。
Windows:标题区整体灰显 —— "本地 ASR 模型(实验性)" 在 Win 上几乎只有
Qwen3 占位、本平台暂不支持;Foundry 走的是另一条独立路径,不属于"实验性"
框架。灰显视觉让用户知道这条"实验性"主线在 Win 不可用,关注点转到下方
{/* 标题 + 右上角 inline 警告小字。
Windows:标题区整体灰显 —— 「本地 ASR 模型(实验性)」在 Win 上几乎只有
Qwen3 占位、本平台暂不支持;Foundry 走的是另一条独立路径,不属于「实验性」
框架。灰显视觉让用户知道这条「实验性」主线在 Win 不可用,关注点转到下方
Foundry 行。 */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<div style={{ minWidth: 0, opacity: isWin ? 0.45 : 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.advanced.localAsrTitle')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 2 }}>
{t('settings.advanced.localAsrDesc')}
</div>
<div style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em' }}>{t('settings.advanced.localAsrTitle')}</div>
</div>
<div style={{
fontSize: 11,
@@ -190,16 +140,9 @@ export function AdvancedSection() {
) : (
<>
{/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off
+ 不可点 + desc=notSupportedHere,跟"本平台不可用"视觉一致。跨平台
异常(Windows profile 同步到 local-qwen3)时 active 状态靠下方独立
"禁用本地 ASR" 行兜底,避免 Toggle ON + desc 说不支持的自相矛盾感
(pr_agent #403 'Stale Windows state' 修法)。
Windows 整行灰显,跟"本地 ASR 实验性"标题区视觉对齐 —— 用户一眼看出
这条线在 Win 上不能用,关注点落到下方 Foundry 行。 */}
+ 不可点 + desc=notSupportedHere。整行灰显,跟「实验性」标题区对齐。 */}
<div style={{ opacity: isWin ? 0.45 : 1 }}>
<SettingRow
label={t('settings.providers.presets.asrLocalQwen3')}
desc={isMac ? t('settings.advanced.qwen3Desc') : t('settings.advanced.notSupportedHere')}>
<SettingRow label={t('settings.providers.presets.asrLocalQwen3')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Toggle
on={isMac && isOnLocalQwen3}
@@ -214,9 +157,7 @@ export function AdvancedSection() {
{/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */}
{isWin && (
<SettingRow
label={t('settings.providers.presets.asrFoundryLocalWhisper')}
desc={t('settings.advanced.foundryDesc')}>
<SettingRow label={t('settings.providers.presets.asrFoundryLocalWhisper')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Toggle
on={isOnFoundry}
@@ -231,15 +172,10 @@ export function AdvancedSection() {
</>
)}
{/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出:
- Linux / 不支持平台:根本没有任何引擎行
- 跨平台异常(macOS profile 同步到 foundry / Windows profile 同步到 qwen3):
本机引擎 Toggle 是 off,关不动异常 active 的对方引擎
否则平台本机 Toggle 自身就能 off → 关停,重复 disable 行徒增视觉。 */}
{/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出(Linux / 跨平台
异常 profile 同步)。否则平台本机 Toggle 自身就能 off。 */}
{isOnAnyLocal && !((isMac && isOnLocalQwen3) || (isWin && isOnFoundry)) && (
<SettingRow
label={t('settings.advanced.disableLocalLabel')}
desc={t('settings.advanced.disableLocalDesc')}>
<SettingRow label={t('settings.advanced.disableLocalLabel')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Btn
variant="primary"
@@ -251,11 +187,16 @@ export function AdvancedSection() {
</div>
</SettingRow>
)}
</Card>
{/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local)
inline 渲染——「模型设置」独立页已删,这里是唯一入口。 */}
{platformSupported && <LocalAsr embedded />}
{/* 模型下载 / 加载(镜像源 · 模型列表 · 下载 · 删除 · 设为默认 · Foundry Local)
—— 跟上面的启动开关收进同一个框:「实验性千问 ASR」是一个整体。
一条分隔线把「启动开关」区与「模型管理」区分开。 */}
{platformSupported && (
<div style={{ marginTop: 16, borderTop: '0.5px solid var(--ol-line)', paddingTop: 16 }}>
<LocalAsr embedded />
</div>
)}
</Card>
</>
);
}
@@ -0,0 +1,107 @@
// 服务 → 扩展市场:通过 GitHub 登录获取上传 / 点赞身份。
// 浏览与安装风格在「风格」页内完成,设置页只管登录身份。
//
// 登录走共用的 <GithubLoginModal />(GitHub OAuth Device Flow),与风格市场
// 完全一致 —— 点登录弹出统一登录窗口,授权成功写回 prefs.marketplaceDevLogin。
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Icon } from '../../components/Icon';
import { GithubLoginModal } from '../../components/GithubLoginModal';
import { Btn, Card } from '../_atoms';
import { SectionTitle } from './shared';
export function MarketplaceSection() {
const { t } = useTranslation();
const { prefs, updatePrefs: savePrefs } = useHotkeySettings();
const [showLogin, setShowLogin] = useState(false);
if (!prefs) {
return (
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
</Card>
);
}
const login = prefs.marketplaceDevLogin.trim();
const signedIn = login.length > 0;
const signOut = () => {
void savePrefs(current => ({ ...current, marketplaceDevLogin: '' }));
};
return (
<Card>
<SectionTitle>{t('settings.marketplace.title')}</SectionTitle>
{signedIn ? (
/* ── 已登录 ──────────────────────────────────────────────── */
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 12px',
borderRadius: 10,
background: 'var(--ol-surface-2)',
border: '0.5px solid var(--ol-line)',
}}
>
<div
style={{
width: 34,
height: 34,
borderRadius: 999,
background: 'var(--ol-ink)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon name="user" size={17} />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
{t('settings.marketplace.github.signedIn')}
</div>
<div
style={{
fontSize: 13.5,
fontWeight: 600,
color: 'var(--ol-ink)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
@{login}
</div>
</div>
<Btn variant="ghost" size="sm" onClick={signOut}>
{t('settings.marketplace.github.signOut')}
</Btn>
</div>
) : (
/* ── 未登录 ──────────────────────────────────────────────── */
<div>
<Btn variant="primary" size="sm" icon="user" onClick={() => setShowLogin(true)}>
{t('settings.marketplace.github.signIn')}
</Btn>
</div>
)}
{showLogin && (
<GithubLoginModal
onClose={() => setShowLogin(false)}
onSuccess={nextLogin => {
void savePrefs(current => ({ ...current, marketplaceDevLogin: nextLogin }));
}}
/>
)}
</Card>
);
}
@@ -0,0 +1,152 @@
// 麦克风选择 —— 复用 SelectLite「官方框」下拉:点开后弹出麦克风列表,
// 选中项最右侧打勾、勾左侧显示实时音量条。下拉打开时监听选中设备电平。
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
isTauri,
startMicrophoneLevelMonitor,
stopMicrophoneLevelMonitor,
} from '../../lib/ipc';
import type { MicrophoneDevice } from '../../lib/types';
import { SelectLite, type SelectOption } from '../../components/ui/SelectLite';
interface MicrophoneSelectProps {
devices: MicrophoneDevice[];
/** 当前生效的设备名;'' = 系统默认。 */
selectedName: string;
onSelect: (name: string) => void;
/** 下拉打开时回调 —— 用于刷新设备列表。 */
onOpen?: () => void;
}
export function MicrophoneSelect({ devices, selectedName, onSelect, onOpen }: MicrophoneSelectProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [level, setLevel] = useState(0);
// 串行化 start/stop —— 避免快速开合下监听器与 Rust 端状态错位。
const monitorQueueRef = useRef<Promise<void>>(Promise.resolve());
const enqueueMonitorTask = useCallback((task: () => Promise<void>) => {
const next = monitorQueueRef.current.catch(() => undefined).then(task);
monitorQueueRef.current = next.catch(() => undefined);
return next;
}, []);
// 下拉打开时监听选中设备电平;关闭即停止并清零。
useEffect(() => {
if (!open) {
setLevel(0);
return;
}
let unlisten: (() => void) | undefined;
let cancelled = false;
let timer: number | undefined;
async function start() {
await enqueueMonitorTask(async () => {
try {
if (isTauri) {
const { listen } = await import('@tauri-apps/api/event');
if (cancelled) return;
const stopListening = await listen<{ level: number }>('microphone:level', event => {
setLevel(Math.max(0, Math.min(1, event.payload.level ?? 0)));
});
if (cancelled) {
stopListening();
return;
}
unlisten = stopListening;
await startMicrophoneLevelMonitor(selectedName);
if (cancelled) {
unlisten?.();
unlisten = undefined;
await stopMicrophoneLevelMonitor();
}
} else {
const tick = window.setInterval(() => {
setLevel(0.25 + Math.random() * 0.55);
}, 120);
if (cancelled) {
window.clearInterval(tick);
return;
}
unlisten = () => window.clearInterval(tick);
}
} catch (err) {
console.warn('[settings] microphone level monitor failed', err);
}
});
}
timer = window.setTimeout(() => {
void start();
}, 140);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
void enqueueMonitorTask(async () => {
unlisten?.();
unlisten = undefined;
await stopMicrophoneLevelMonitor();
});
};
}, [enqueueMonitorTask, open, selectedName]);
// 选中项(默认麦克风或某条设备)右侧挂音量条,由 SelectLite 在其后再补打勾。
const options = useMemo<SelectOption[]>(() => {
const meter = <LevelMeter level={level} />;
return [
{
value: '',
label: t('settings.recording.microphoneDefault'),
trailing: selectedName === '' ? meter : undefined,
},
...devices.map(device => ({
value: device.name,
label: device.name,
trailing: selectedName === device.name ? meter : undefined,
})),
];
}, [devices, level, selectedName, t]);
return (
<SelectLite
value={selectedName}
onChange={onSelect}
options={options}
ariaLabel={t('settings.recording.microphoneLabel')}
onOpenChange={next => {
setOpen(next);
if (next) onOpen?.();
}}
style={{ width: 200, maxWidth: 200, minWidth: 0 }}
/>
);
}
function LevelMeter({ level }: { level: number }) {
const amplified = Math.min(1, Math.max(0, level * 4.5));
const bars = [0.4, 0.7, 1, 0.7, 0.4];
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, height: 14, flexShrink: 0 }}>
{bars.map((weight, index) => {
const intensity = Math.min(1, amplified * (0.85 + weight * 0.35));
const height = 4 + intensity * (10 * weight);
return (
<span
key={`${weight}-${index}`}
style={{
width: 3,
height,
borderRadius: 999,
background: intensity > 0.08 ? 'var(--ol-blue)' : 'rgba(0,0,0,0.14)',
opacity: 0.4 + intensity * 0.6,
transition: 'height 70ms linear, opacity 90ms ease, background 120ms ease',
}}
/>
);
})}
</span>
);
}
@@ -16,13 +16,11 @@ import {
} from '../../lib/ipc';
import type { NetworkCheckResult } from '../../lib/ipc';
import type {
HotkeyCapability,
HotkeyStatus,
PermissionStatus,
WindowsImeStatus,
} from '../../lib/types';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import i18n from '../../i18n';
import { Btn, Card, Pill } from '../_atoms';
import { SettingRow } from './shared';
@@ -103,17 +101,10 @@ export function PermissionsSection() {
refreshPermissions();
};
const desc = capability?.requiresAccessibilityPermission
? t('settings.permissions.descAcc')
: t('settings.permissions.descNoAcc');
return (
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.permissions.title')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>
{desc}
</div>
<SettingRow label={t('settings.permissions.micLabel')} desc={t('settings.permissions.micDesc')}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>{t('settings.permissions.title')}</div>
<SettingRow label={t('settings.permissions.micLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%' }}>
<PermissionPill status={microphone} />
{microphone !== 'granted' && microphone !== 'notApplicable' && microphone !== 'loading' && (
@@ -124,8 +115,8 @@ export function PermissionsSection() {
</div>
</SettingRow>
{capability?.requiresAccessibilityPermission && (
<SettingRow label={t('settings.permissions.accLabel')} desc={t('settings.permissions.accDesc')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<SettingRow label={t('settings.permissions.accLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%' }}>
<PermissionPill status={accessibility} />
{accessibility !== 'granted' && accessibility !== 'notApplicable' && (
<Btn variant="ghost" size="sm" onClick={reRequestAccessibility}>
@@ -135,10 +126,7 @@ export function PermissionsSection() {
</div>
</SettingRow>
)}
<SettingRow
label={t('settings.permissions.hotkeyLabel')}
desc={capability ? t('settings.permissions.hotkeyDescWithAdapter', { adapter: adapterDisplayName(capability.adapter) }) : t('settings.permissions.hotkeyDescPlain')}
>
<SettingRow label={t('settings.permissions.hotkeyLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, justifyContent: 'flex-end', width: '100%' }}>
{hotkey?.message && (
<span style={{
@@ -153,10 +141,7 @@ export function PermissionsSection() {
</div>
</SettingRow>
{windowsIme?.state !== 'notWindows' && (
<SettingRow
label={t('settings.permissions.windowsImeLabel')}
desc={t('settings.permissions.windowsImeDesc')}
>
<SettingRow label={t('settings.permissions.windowsImeLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, justifyContent: 'flex-end', width: '100%' }}>
{windowsIme && (
<span style={{
@@ -171,7 +156,7 @@ export function PermissionsSection() {
</div>
</SettingRow>
)}
<SettingRow label={t('settings.permissions.networkLabel')} desc={t('settings.permissions.networkDesc')}>
<SettingRow label={t('settings.permissions.networkLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%' }}>
{network && network.latencyMs != null && (
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
@@ -242,9 +227,3 @@ function NetworkStatusPill({ status }: { status: NetworkCheckResult | null }) {
}
return <Pill tone="outline">{t('settings.permissions.networkOffline') ?? '不可用'}</Pill>;
}
function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) {
if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap');
if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel');
return i18n.t('hotkey.adapter.fcitx5');
}
@@ -0,0 +1,772 @@
// 服务 → AI 提供商:LLM 润色模型 + ASR 语音转写两张卡片。
// 自 Settings.tsx 整体迁出,逻辑零改动;i18n key 全部保持 `settings.providers.*`。
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../../components/Icon';
import { detectOS } from '../../components/WindowChrome';
import {
listProviderModels,
readCredential,
setActiveAsrProvider,
setActiveLlmProvider,
setCredential,
validateProviderCredentials,
} from '../../lib/ipc';
import { emitSaved } from '../../lib/savedEvent';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { SelectLite } from '../../components/ui/SelectLite';
import { Card } from '../_atoms';
import { SettingRow, SectionTitle, Toggle, inputStyle, type AsrPresetId } from './shared';
function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) {
const { t } = useTranslation();
return (
<div
title={t('settings.providers.thinkingModeHint')}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
paddingLeft: 2,
whiteSpace: 'nowrap',
}}
>
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
{t('settings.providers.thinkingModeLabel')}
</span>
<Toggle on={enabled} onToggle={onToggle} />
<span style={{ fontSize: 11.5, color: enabled ? 'var(--ol-blue)' : 'var(--ol-ink-4)' }}>
{enabled ? t('settings.providers.thinkingModeOn') : t('settings.providers.thinkingModeOff')}
</span>
</div>
);
}
const LLM_PRESETS = [
{
id: 'ark',
nameKey: 'ark',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
modelPlaceholder: 'deepseek-v3-2',
},
{
id: 'deepseek',
nameKey: 'deepseek',
baseUrl: 'https://api.deepseek.com/v1',
modelPlaceholder: 'deepseek-v4-flash',
},
{
id: 'siliconflow',
nameKey: 'siliconflow',
baseUrl: 'https://api.siliconflow.cn/v1',
modelPlaceholder: 'Qwen/Qwen2.5-7B-Instruct',
},
{
id: 'openai',
nameKey: 'openai',
baseUrl: 'https://api.openai.com/v1',
modelPlaceholder: 'gpt-4o',
},
{
// 谷歌官方 Gemini API(原生 generateContent,不走 OpenAI 兼容 shim)。
// baseUrl 末尾 /v1beta 是当前 Generally Available 的 path(ai.google.dev/api)。
// 后端 llm_gemini.rs 会拼成 `{baseUrl}/models/{model}:generateContent`,
// 并按 Gemini 原生通道级 thinkingConfig 关闭或压低思考,不在前端维护模型适配表。
// 模型列表用 ProviderTools「拉取模型」按钮取,
// 由 commands.rs::fetch_provider_models 识别 generativelanguage 域名后按 Gemini shape 解析。
id: 'gemini',
nameKey: 'gemini',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
modelPlaceholder: 'gemini-2.5-flash',
},
{
id: 'codex_oauth',
nameKey: 'codexOAuth',
baseUrl: '',
modelPlaceholder: 'gpt-5.3-codex-spark',
},
{
id: 'mimo',
nameKey: 'mimo',
baseUrl: 'https://api.xiaomimimo.com/v1',
modelPlaceholder: 'xiaomi/mimo-v2-flash',
},
{
id: 'cometapi',
nameKey: 'cometapi',
baseUrl: 'https://api.cometapi.com/v1',
modelPlaceholder: 'gpt-4o',
},
{
id: 'openrouterFree',
nameKey: 'openrouterFree',
baseUrl: 'https://openrouter.ai/api/v1',
modelPlaceholder: 'qwen/qwen3-coder:free',
},
{
id: 'alibabaCoding',
nameKey: 'alibabaCoding',
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
modelPlaceholder: 'qwen3-coder-plus',
},
{
id: 'codingPlanX',
nameKey: 'codingPlanX',
baseUrl: 'https://api.codingplanx.ai/v1',
modelPlaceholder: 'gpt-5-mini',
},
{
id: 'custom',
nameKey: 'custom',
baseUrl: '',
modelPlaceholder: '',
},
] as const;
type LlmPresetId = typeof LLM_PRESETS[number]['id'];
const ASR_DEFAULT_RESOURCE_ID = 'volc.bigasr.sauc.duration';
// `volcengine` / `bailian` 走自建流式客户端;其余走 OpenAI 兼容
// `/audio/transcriptions`(`coordinator.rs::is_whisper_compatible_provider`)。
// 新增兼容厂商:
// 1. 在这里加一项 `{ id, nameKey, baseUrl, model }`;
// 2. `coordinator.rs::is_whisper_compatible_provider` 加同名 id;
// 3. 在 i18n 的 `settings.providers.presets.<nameKey>` 加文案。
// `AsrPresetId` 定义在 settings/shared.tsx,LocalModelSection / ProvidersSection 共用同一份。
const ASR_PRESETS: ReadonlyArray<{ id: AsrPresetId; nameKey: string; baseUrl: string; model: string }> = [
{ id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' },
{ id: 'bailian', nameKey: 'asrBailian', baseUrl: 'wss://dashscope.aliyuncs.com/api-ws/v1/inference/', model: 'fun-asr-realtime' },
{ id: 'siliconflow', nameKey: 'asrSiliconflow', baseUrl: 'https://api.siliconflow.cn/v1', model: 'FunAudioLLM/SenseVoiceSmall' },
{ id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' },
{ id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' },
{ id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' },
{ id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' },
// 本地 Qwen3-ASR:无 baseUrl/model 配置,模型在「高级 → 本地模型」里下载与切换。
{ id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' },
];
export function ProvidersSection() {
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
// `*Provider` 立即跟随 <select> 改动(受控组件必须实时反映用户输入);
// `committed*Provider` 才决定 CredentialField 的 key,仅在后端 active
// 切换 + 默认值写完后再 commit。两者拆开是为了同时满足:
// - <select> 立刻显示用户的选择(issue #220 P2:codex 指出受控选不应等 await)
// - CredentialField 不要在后端 active 切完前 remount(issue #219:避免读到旧 entry)
// `*SwitchSeq` 是 stale-write 守卫:用户 100ms 内连点两次时,先发的请求晚到不
// 会覆盖后发的 commit。
const [llmProvider, setLlmProvider] = useState<LlmPresetId>('ark');
const [asrProvider, setAsrProvider] = useState<AsrPresetId>('volcengine');
const [committedLlmProvider, setCommittedLlmProvider] = useState<LlmPresetId>('ark');
const [committedAsrProvider, setCommittedAsrProvider] = useState<AsrPresetId>('volcengine');
const llmSwitchSeqRef = useRef(0);
const asrSwitchSeqRef = useRef(0);
const [llmModelRevision, setLlmModelRevision] = useState(0);
const [asrModelRevision, setAsrModelRevision] = useState(0);
const os = detectOS();
// 主 ASR 下拉只列云端选项;本地推理(local-qwen3 / foundry-local-whisper)
// 移到「高级 → 本地模型」,防止新手误开 CPU 推理。
const visibleAsrPresets = ASR_PRESETS.filter(
p => p.id !== 'foundry-local-whisper' && p.id !== 'local-qwen3',
);
useEffect(() => {
if (!prefs) return;
const knownLlm = LLM_PRESETS.find(x => x.id === prefs.activeLlmProvider);
const llmId = knownLlm ? knownLlm.id : 'custom';
setLlmProvider(llmId);
setCommittedLlmProvider(llmId);
// ASR 在 ALL ASR_PRESETS 里查(不是 visibleAsrPresets)——本地选项虽然
// 从下拉里藏起来了,但若用户曾在「高级」里启用过 local-qwen3,主 Card
// 仍要识别出 active 是本地,并切到「正在使用本地 ASR」的 notice 渲染。
const knownAsr = ASR_PRESETS.find(x => x.id === prefs.activeAsrProvider);
const asrId = knownAsr ? knownAsr.id : 'volcengine';
setAsrProvider(asrId);
setCommittedAsrProvider(asrId);
}, [prefs, os]);
// issue #219 / #220 P2:
// 1. 立刻 setLlmProvider —— 受控 <select> 必须反映用户最新选择。
// 2. 用 seq 守卫每个 await:用户连点两次时旧请求晚到也不会盖掉新选择。
// 3. 仅 setCommittedLlmProvider 之后 CredentialField 才 remount 读新 entry,
// 此时后端 root.active.llm 已经是 id,lookup_account 落到正确 entry。
// 4. endpoint/model 默认值仅在该 provider entry 该字段为空时才填,不覆盖用户自定义。
const onLlmProviderChange = async (id: LlmPresetId) => {
setLlmProvider(id);
const seq = ++llmSwitchSeqRef.current;
emitSaved('saving', t('common.saving'));
try {
await setActiveLlmProvider(id);
if (seq !== llmSwitchSeqRef.current) return;
if (prefs) {
const next = { ...prefs, activeLlmProvider: id };
await updatePrefs(next);
if (seq !== llmSwitchSeqRef.current) return;
}
const preset = LLM_PRESETS.find(p => p.id === id);
// 修 bug:所有 LLM provider 共用 `ark.endpoint` / `ark.model_id` 一对凭据槽
// (persistence.rs 没做 per-provider 隔离)。旧逻辑只在槽空时填默认值,
// 老用户切换 preset 时槽里早有旧值——dropdown 看着切了,polish 实际还是
// 打老 endpoint。改成:切到任何非 custom 预设都强制覆盖 endpoint 与 model
// 到该预设的默认值,让"切换"真切到位。custom 预设没有默认值,跳过。
if (preset && preset.id !== 'custom') {
if (preset.baseUrl) {
await setCredential('ark.endpoint', preset.baseUrl);
if (seq !== llmSwitchSeqRef.current) return;
}
if (preset.modelPlaceholder) {
await setCredential('ark.model_id', preset.modelPlaceholder);
if (seq !== llmSwitchSeqRef.current) return;
}
}
setCommittedLlmProvider(id);
emitSaved('saved', t('common.saved'));
} catch (err) {
// seq 守卫:只有当前 call 还是最新时才把 saving 翻成 failed;
// 旧 call 早被 newer call 的 emitSaved('saving') 覆盖,再叠 failed 会
// 把 newer 正在跑的 saving 假伪成失败。
if (seq === llmSwitchSeqRef.current) {
emitSaved('failed', t('common.operationFailed'));
}
throw err;
}
};
const onLlmThinkingToggle = (enabled: boolean) => {
if (!prefs) return;
void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled })).catch(error => {
console.error('[settings] failed to update LLM thinking mode', error);
emitSaved('failed', t('common.operationFailed'));
});
};
const onAsrProviderChange = async (id: AsrPresetId) => {
setAsrProvider(id);
const seq = ++asrSwitchSeqRef.current;
emitSaved('saving', t('common.saving'));
try {
await setActiveAsrProvider(id);
if (seq !== asrSwitchSeqRef.current) return;
if (prefs) {
const next = { ...prefs, activeAsrProvider: id };
await updatePrefs(next);
if (seq !== asrSwitchSeqRef.current) return;
}
// OpenAI 兼容厂商首次切换时预填 baseUrl / model 默认值,省得用户必踩
// 「跨厂商 model 名根本不一样」的坑;但用户已自定义后就不再覆盖。
// volcengine 走另一套凭据,跳过。
const preset = ASR_PRESETS.find(p => p.id === id);
if (preset && preset.baseUrl) {
const existing = await readCredential('asr.endpoint');
if (seq !== asrSwitchSeqRef.current) return;
if (!existing) {
await setCredential('asr.endpoint', preset.baseUrl);
if (seq !== asrSwitchSeqRef.current) return;
}
}
if (preset && preset.model) {
const existing = await readCredential('asr.model');
if (seq !== asrSwitchSeqRef.current) return;
if (!existing) {
await setCredential('asr.model', preset.model);
if (seq !== asrSwitchSeqRef.current) return;
}
}
setCommittedAsrProvider(id);
emitSaved('saved', t('common.saved'));
} catch (err) {
// seq 守卫同上 onLlmProviderChange:旧 call 不要把 newer call 的 saving
// 伪造成 failed。
if (seq === asrSwitchSeqRef.current) {
emitSaved('failed', t('common.operationFailed'));
}
throw err;
}
};
// preset 决定 placeholder 与 default —— 必须跟着 committed*Provider 走,
// 否则受控 <select> 立刻切到新厂商,但凭据字段还在显示旧 entry,placeholder
// 会先于实际数据切换、视觉上对不上。
const preset = LLM_PRESETS.find(p => p.id === committedLlmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1];
const codexOAuthSelected = committedLlmProvider === 'codex_oauth';
const asrPreset = visibleAsrPresets.find(p => p.id === committedAsrProvider);
return (
<>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginBottom: 10 }}>
{t('settings.providers.credentialStorageNotice')}
</div>
<Card>
<div style={{ marginBottom: 10 }}>
<SectionTitle>{t('settings.providers.llmTitle')}</SectionTitle>
</div>
{/* desc 已去掉——'选择后将自动填入 Base URL 默认值' 在 180px label 列必换行成两行,
视觉上 label 区出现"字体单独占一行"。下拉自身已经表达了"切换"含义,desc 冗余。 */}
<SettingRow label={t('settings.providers.providerLabel')}>
<SelectLite
value={llmProvider}
onChange={next => onLlmProviderChange(next as LlmPresetId)}
options={LLM_PRESETS.map(p => ({
value: p.id,
label: t(`settings.providers.presets.${p.nameKey}`),
}))}
ariaLabel={t('settings.providers.providerLabel')}
style={{ ...inputStyle, width: '100%', maxWidth: 200 }}
/>
</SettingRow>
{codexOAuthSelected ? (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, margin: '2px 0 10px' }}>
{t('settings.providers.codexOAuthNotice')}
</div>
) : (
<>
<CredentialField key={`${committedLlmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
<CredentialField key={`${committedLlmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
placeholder={preset.baseUrl || 'https://your-endpoint/v1'} />
</>
)}
<CredentialField key={`${committedLlmProvider}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')} account="ark.model_id"
placeholder={preset.modelPlaceholder || 'model-name'} mono
trailing={(
<LlmThinkingToggle
enabled={prefs?.llmThinkingEnabled ?? false}
onToggle={onLlmThinkingToggle}
/>
)}
/>
<ProviderTools key={committedLlmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />
</Card>
<Card>
<div style={{ marginBottom: 10 }}>
<SectionTitle>{t('settings.providers.asrTitle')}</SectionTitle>
</div>
{/* 下拉只放云端选项;本地引擎激活时锁住 + 在下方放一行"ASR 提供商已被接管"提示,
未激活时不显示提示。 */}
<SettingRow label={t('settings.providers.providerLabel')}>
{(() => {
const isLocked =
committedAsrProvider === 'local-qwen3' ||
committedAsrProvider === 'foundry-local-whisper';
const selectedValue: AsrPresetId = isLocked ? committedAsrProvider : asrProvider;
// 跨机器同步异常兜底:committed 是本地但不在 visibleAsrPresets 里时,受控
// select 会回退到首项造成假象 —— 补一个 disabled option 让 select 找到当前值。
const anomalousLocal: AsrPresetId | null =
isLocked && !visibleAsrPresets.some(p => p.id === committedAsrProvider)
? committedAsrProvider
: null;
const anomalousNameKey = anomalousLocal === 'local-qwen3'
? 'asrLocalQwen3'
: anomalousLocal === 'foundry-local-whisper'
? 'asrFoundryLocalWhisper'
: null;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start', minWidth: 0 }}>
<SelectLite
value={selectedValue}
disabled={isLocked}
onChange={next => onAsrProviderChange(next as AsrPresetId)}
options={[
...visibleAsrPresets.map(p => ({
value: p.id,
label: t(`settings.providers.presets.${p.nameKey}`),
})),
...(anomalousLocal && anomalousNameKey
? [{
value: anomalousLocal,
label: t(`settings.providers.presets.${anomalousNameKey}`),
disabled: true,
}]
: []),
]}
ariaLabel={t('settings.providers.providerLabel')}
style={{ ...inputStyle, width: '100%', maxWidth: 200 }}
/>
{isLocked && (
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
{t('settings.providers.asrProviderTakenOver')}
</div>
)}
</div>
);
})()}
</SettingRow>
{committedAsrProvider === 'volcengine' ? (
<>
<CredentialField
key={`${committedAsrProvider}:app_key`}
label={t('settings.providers.volcengineAppKeyLabel')}
account="volcengine.app_key"
mono
mask
/>
<CredentialField
key={`${committedAsrProvider}:access_key`}
label={t('settings.providers.volcengineAccessKeyLabel')}
account="volcengine.access_key"
mono
mask
/>
<CredentialField
key={`${committedAsrProvider}:resource_id`}
label={t('settings.providers.volcengineResourceIdLabel')}
account="volcengine.resource_id"
mono
placeholder={ASR_DEFAULT_RESOURCE_ID} defaultValue={ASR_DEFAULT_RESOURCE_ID} />
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.volcengineMappingNote')}
</div>
</>
) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' ? (
// 用户已经在用本地 ASR——dropdown 行的 asrProviderTakenOver 已经把
// "在高级中切换或禁用"讲清楚了,body 不再重复。
// 模型管理 UI 唯一入口在「高级 → 本地模型」里的 <LocalAsr embedded />。
null
) : (
<>
<CredentialField key={`${committedAsrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" mono mask />
<CredentialField key={`${committedAsrProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="asr.endpoint"
placeholder={asrPreset?.baseUrl || 'https://api.openai.com/v1'}
defaultValue={asrPreset?.baseUrl || undefined} />
<CredentialField key={`${committedAsrProvider}:model:${asrModelRevision}`} label={t('settings.providers.modelLabel')} account="asr.model"
placeholder={asrPreset?.model || 'whisper-1'} />
{committedAsrProvider === 'bailian' && (
<>
<CredentialField
key={`${committedAsrProvider}:vocabulary_id`}
label={t('settings.providers.bailianVocabularyIdLabel')}
account="asr.vocabulary_id"
mono
placeholder="vocab-..."
/>
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.bailianVocabularyIdNote')}
</div>
</>
)}
<ProviderTools kind="asr" modelAccount="asr.model" onModelSelected={() => setAsrModelRevision(v => v + 1)} />
</>
)}
</Card>
</>
);
}
type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error';
function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | 'asr'; modelAccount: string; onModelSelected: () => void }) {
const { t } = useTranslation();
const [models, setModels] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [status, setStatus] = useState<ProviderToolStatus>('idle');
const [message, setMessage] = useState('');
const setResult = (next: ProviderToolStatus, nextMessage: string) => {
setStatus(next);
setMessage(nextMessage);
};
const validate = async () => {
setModels([]);
setSelectedModel('');
setResult('loading', t('settings.providers.validating'));
try {
const result = await validateProviderCredentials(kind);
setResult(result.ok ? 'success' : 'error', t('settings.providers.validateSuccess'));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if ((kind === 'llm' && message === 'llmModelMissing') || (kind === 'asr' && message === 'asrModelMissing')) {
setResult('empty', t('settings.providers.modelMissing'));
return;
}
if (message === 'modelsEmpty') {
setResult('empty', t('settings.providers.modelsEmpty'));
return;
}
setResult('error', providerErrorMessage(error, t));
}
};
const loadModels = async () => {
setResult('loading', t('settings.providers.loadingModels'));
try {
const result = await listProviderModels(kind);
setModels(result.models);
if (result.models.length === 0) {
setResult('empty', t('settings.providers.modelsEmpty'));
} else {
setSelectedModel('');
setResult('success', t('settings.providers.modelsLoaded', { count: result.models.length }));
}
} catch (error) {
setModels([]);
setResult('error', providerErrorMessage(error, t));
}
};
const applyModel = async (model: string) => {
setResult('loading', t('common.saving'));
try {
await setCredential(modelAccount, model);
setSelectedModel(model);
onModelSelected();
setResult('success', t('settings.providers.modelSaved', { model }));
} catch (error) {
setResult('error', providerErrorMessage(error, t));
}
};
return (
<SettingRow label={t('settings.providers.toolsLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: 420 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
<button onClick={loadModels} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.fetchModels')}</button>
{models.length > 0 && (
<SelectLite
value={selectedModel}
onChange={applyModel}
disabled={status === 'loading'}
options={models.map(model => ({ value: model, label: model }))}
placeholder={t('settings.providers.selectModel')}
ariaLabel={t('settings.providers.selectModel')}
style={{ ...inputStyle, maxWidth: 220 }}
/>
)}
</div>
{message && (
<span style={{ fontSize: 11, color: status === 'error' ? 'var(--ol-warn)' : status === 'empty' ? 'var(--ol-ink-4)' : 'var(--ol-ok)', lineHeight: 1.4 }}>
{message}
</span>
)}
</div>
</SettingRow>
);
}
function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslation>['t']): string {
const message = error instanceof Error ? error.message : String(error);
if (message.startsWith('providerHttpStatus:')) {
return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' });
}
if (message === 'endpointMustUseHttps') return t('settings.providers.endpointMustUseHttps');
if (message === 'endpointInvalid') return t('settings.providers.endpointInvalid');
if (message === 'providerResponseTooLarge') return t('settings.providers.responseTooLarge');
if (message === 'asrInvalidJson') return t('settings.providers.asrInvalidJson');
if (message === 'asrMissingTextField') return t('settings.providers.asrMissingTextField');
if (message === 'providerNetworkError') return t('common.networkError');
if (message === 'providerReadResponseFailed' || message === 'providerClientInitFailed') return t('common.operationFailed');
if (message === 'providerRequestTimeout') return t('settings.providers.requestTimeout');
if (message.includes('API Key')) return t('settings.providers.apiKeyMissing');
if (message.includes('Endpoint')) return t('settings.providers.endpointMissing');
if (message.includes('timeout') || message.includes('超时')) return t('settings.providers.requestTimeout');
return t('common.operationFailed');
}
type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError';
interface CredentialFieldProps {
label: string;
account: string;
placeholder?: string;
mono?: boolean;
mask?: boolean;
defaultValue?: string;
trailing?: ReactNode;
}
function CredentialField({ label, account, placeholder, mono, mask, defaultValue, trailing }: CredentialFieldProps) {
const { t } = useTranslation();
const [value, setValue] = useState('');
const [revealed, setRevealed] = useState(false);
const [loaded, setLoaded] = useState(false);
const [dirty, setDirty] = useState(false);
const [status, setStatus] = useState<CredentialFieldStatus>('idle');
const debounceRef = useRef<number | null>(null);
const statusRef = useRef<number | null>(null);
useEffect(() => {
let cancelled = false;
setLoaded(false);
setDirty(false);
setStatus('idle');
setValue('');
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
}
readCredential(account)
.then(v => {
if (cancelled) return;
setValue(v ?? '');
setLoaded(true);
})
.catch(error => {
if (cancelled) return;
console.error('[settings] failed to read credential', account, error);
setLoaded(true);
setStatus('readError');
});
return () => {
cancelled = true;
};
}, [account]);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (statusRef.current) clearTimeout(statusRef.current);
};
}, []);
// 改造:除 readError(持续错误,留在输入旁标识字段不可用)外,所有 saving / saved /
// saveError / copied / copyError 一律发到右上角 SavedToast。原内联文案太挤、跟其它
// 页面 toast 风格不统一。
const showTemporaryStatus = (next: CredentialFieldStatus) => {
if (next === 'saving') {
emitSaved('saving', t('common.saving'));
} else if (next === 'saved') {
emitSaved('saved', t('common.saved'));
} else if (next === 'saveError') {
emitSaved('failed', t('common.operationFailed'));
} else if (next === 'copied') {
emitSaved('saved', t('common.copied'));
} else if (next === 'copyError') {
emitSaved('failed', t('common.operationFailed'));
}
setStatus(next);
if (statusRef.current) clearTimeout(statusRef.current);
statusRef.current = window.setTimeout(() => setStatus('idle'), 1600);
};
const save = async (v: string, force = false) => {
if (!loaded || (!dirty && !force)) return;
setStatus('saving');
emitSaved('saving', t('common.saving'));
try {
await setCredential(account, v);
setDirty(false);
showTemporaryStatus('saved');
} catch (error) {
console.error('[settings] failed to save credential', account, error);
showTemporaryStatus('saveError');
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setValue(v);
if (!loaded) return;
setDirty(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(() => save(v, true), 300);
};
const onBlur = () => {
if (!loaded || !dirty) return;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
}
save(value, true);
};
const fillDefault = async () => {
if (!loaded || !defaultValue) return;
setValue(defaultValue);
setDirty(true);
await save(defaultValue, true);
};
const onCopy = async () => {
if (!value || !loaded) return;
try {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API unavailable');
}
await navigator.clipboard.writeText(value);
showTemporaryStatus('copied');
} catch (error) {
console.error('[settings] failed to copy credential', account, error);
showTemporaryStatus('copyError');
}
};
const inputType = mask && !revealed ? 'password' : 'text';
const disabled = !loaded;
return (
<SettingRow label={label}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', maxWidth: 420 }}>
<input
type={inputType}
value={value}
placeholder={loaded ? placeholder : t('common.loading')}
onChange={handleChange}
onBlur={onBlur}
disabled={disabled}
style={{ ...inputStyle, fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
/>
{defaultValue && !value && loaded && (
<button onClick={fillDefault} title={t('settings.providers.fillDefault')} style={iconBtnStyle} disabled={!loaded}>
<Icon name="check" size={13} />
</button>
)}
{trailing}
{mask && (
<button
onClick={() => setRevealed(r => !r)}
title={revealed ? t('common.hide') : t('common.show')}
style={iconBtnStyle}
disabled={disabled}
>
<Icon name="eye" size={14} />
</button>
)}
<button
onClick={onCopy}
title={t('common.copy')}
style={iconBtnStyle}
disabled={!value || disabled}
>
<Icon name="copy" size={14} />
</button>
{/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用;
其它瞬态状态(saving / saved / saveError / copied / copyError)都通过
emitSaved 发到右上角统一 toast,不再内联占位。 */}
{status === 'readError' && (
<span
style={{
fontSize: 11,
color: 'var(--ol-warn)',
whiteSpace: 'nowrap',
}}
>
{t('settings.providers.readFailed')}
</span>
)}
</div>
</SettingRow>
);
}
const miniBtnStyle: CSSProperties = {
height: 32, padding: '0 12px',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8, background: 'var(--ol-surface)',
boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset',
color: 'var(--ol-ink-2)', cursor: 'default', flexShrink: 0,
fontSize: 12.5, fontWeight: 500, letterSpacing: '0.01em',
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.16s var(--ol-motion-quick)',
};
const iconBtnStyle: CSSProperties = {
width: 32, height: 32,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8, background: 'var(--ol-surface)',
boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--ol-ink-3)', cursor: 'default', flexShrink: 0,
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)',
};
@@ -0,0 +1,336 @@
// 通用 → 录音与输入:录音快捷键 / 方式 / 麦克风 / 胶囊 / 静音,
// 外加「插入与剪贴板」「启动」两个折叠组。流式输入也并到这里(属于插入行为)。
// 自 Settings.tsx 的 RecordingSection 拆出,录音相关逻辑零改动。
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
import { isHotkeyModeMigrationNoticeActive } from '../../lib/hotkeyMigration';
import {
isTauri,
listMicrophoneDevices,
setDictationHotkey,
} from '../../lib/ipc';
import type { HotkeyMode, MicrophoneDevice, PasteShortcut } from '../../lib/types';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { SelectLite } from '../../components/ui/SelectLite';
import { Card, Collapsible } from '../_atoms';
import { SettingRow, Toggle, inputStyle } from './shared';
import { MicrophoneSelect } from './MicrophoneSelect';
async function autostartIsEnabled(): Promise<boolean> {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<boolean>('plugin:autostart|is_enabled');
}
async function autostartEnable(): Promise<void> {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('plugin:autostart|enable');
}
async function autostartDisable(): Promise<void> {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('plugin:autostart|disable');
}
export function RecordingInputSection() {
const { t } = useTranslation();
const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings();
const [microphoneDevices, setMicrophoneDevices] = useState<MicrophoneDevice[]>([]);
const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false);
const [microphoneDevicesError, setMicrophoneDevicesError] = useState<string | null>(null);
const loadMicrophoneDevices = useCallback(async (
signal?: { cancelled: boolean },
options: { showLoading?: boolean } = {},
) => {
if (options.showLoading ?? true) {
setMicrophoneDevicesLoaded(false);
}
setMicrophoneDevicesError(null);
try {
const devices = await listMicrophoneDevices();
if (signal?.cancelled) return;
setMicrophoneDevices(devices);
setMicrophoneDevicesLoaded(true);
} catch (err) {
console.error('[settings] list microphone devices failed', err);
if (signal?.cancelled) return;
setMicrophoneDevices([]);
setMicrophoneDevicesError(err instanceof Error ? err.message : String(err));
setMicrophoneDevicesLoaded(true);
}
}, []);
useEffect(() => {
const signal = { cancelled: false };
void loadMicrophoneDevices(signal);
return () => {
signal.cancelled = true;
};
}, [loadMicrophoneDevices]);
useEffect(() => {
if (!isTauri) return;
let cancelled = false;
let unlisten: (() => void) | undefined;
async function listenForDeviceChanges() {
const { listen } = await import('@tauri-apps/api/event');
if (cancelled) return;
const stopListening = await listen('microphone:devices-changed', () => {
void loadMicrophoneDevices(undefined, { showLoading: false });
});
if (cancelled) {
stopListening();
return;
}
unlisten = stopListening;
}
void listenForDeviceChanges();
return () => {
cancelled = true;
unlisten?.();
};
}, [loadMicrophoneDevices]);
if (!prefs || !capability) {
return (
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
</Card>
);
}
const onModeChange = (mode: HotkeyMode) =>
savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } });
const onShowCapsuleChange = (showCapsule: boolean) =>
savePrefs({ ...prefs, showCapsule });
const onMuteDuringRecordingChange = (muteDuringRecording: boolean) =>
savePrefs({ ...prefs, muteDuringRecording });
const onMicrophoneDeviceChange = (microphoneDeviceName: string) =>
savePrefs({ ...prefs, microphoneDeviceName });
const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
savePrefs({ ...prefs, restoreClipboardAfterPaste });
const onPasteShortcutChange = (pasteShortcut: PasteShortcut) =>
savePrefs({ ...prefs, pasteShortcut });
const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) =>
savePrefs({ ...prefs, allowNonTsfInsertionFallback });
const onStartMinimizedChange = (startMinimized: boolean) =>
savePrefs({ ...prefs, startMinimized });
const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) =>
savePrefs({ ...prefs, autoUpdateCheck });
const choices: Array<[HotkeyMode, string]> = [
['toggle', t('settings.recording.modeToggle')],
['hold', t('settings.recording.modeHold')],
];
const preferredMicrophoneAvailable = Boolean(
prefs.microphoneDeviceName
&& microphoneDevices.some(device => device.name === prefs.microphoneDeviceName),
);
const effectiveMicrophoneDeviceName = prefs.microphoneDeviceName
&& (!microphoneDevicesLoaded || preferredMicrophoneAvailable)
? prefs.microphoneDeviceName
: '';
return (
<>
<Card>
<div style={{ marginBottom: 6 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)', letterSpacing: '-0.01em' }}>
{t('settings.recording.title')}
</div>
</div>
{isHotkeyModeMigrationNoticeActive() && (
<div
style={{
marginTop: 4,
marginBottom: 8,
padding: '12px 14px',
borderRadius: 10,
background: 'rgba(37,99,235,0.08)',
border: '0.5px solid rgba(37,99,235,0.18)',
}}
>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>
{t('settings.recording.migrationNoticeTitle')}
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
{t('settings.recording.migrationNoticeDesc')}
</div>
</div>
)}
<SettingRow label={t('settings.recording.hotkeyLabel')}>
<ShortcutRecorder
value={prefs.dictationHotkey}
onSave={async binding => {
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });
}}
/>
</SettingRow>
<SettingRow label={t('settings.recording.modeLabel')}>
<div style={{ display: 'inline-flex', padding: 2, borderRadius: 8, background: 'rgba(0,0,0,0.05)' }}>
{choices.map(([v, l]) => (
<button
key={v}
onClick={() => onModeChange(v)}
style={{
padding: '5px 14px', fontSize: 12, fontWeight: 500,
border: 0, borderRadius: 6, fontFamily: 'inherit',
background: prefs.hotkey.mode === v ? '#fff' : 'transparent',
color: prefs.hotkey.mode === v ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
boxShadow: prefs.hotkey.mode === v ? '0 1px 2px rgba(0,0,0,.08)' : 'none',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
}}
>
{l}
</button>
))}
</div>
</SettingRow>
<SettingRow label={t('settings.recording.microphoneLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<MicrophoneSelect
devices={microphoneDevices}
selectedName={effectiveMicrophoneDeviceName}
onSelect={onMicrophoneDeviceChange}
onOpen={() => { void loadMicrophoneDevices(undefined, { showLoading: false }); }}
/>
{microphoneDevicesError && (
<div style={{ fontSize: 11, color: 'var(--ol-err)', lineHeight: 1.5 }}>
{t('settings.recording.microphoneLoadError', { message: microphoneDevicesError })}
</div>
)}
</div>
</SettingRow>
<SettingRow label={t('settings.recording.capsuleLabel')}>
<Toggle on={prefs.showCapsule} onToggle={onShowCapsuleChange} />
</SettingRow>
<SettingRow label={t('settings.recording.muteDuringRecordingLabel')}>
<Toggle on={prefs.muteDuringRecording} onToggle={onMuteDuringRecordingChange} />
</SettingRow>
</Card>
{/* ─── 插入与剪贴板(折叠) ──────────────────────────────────── */}
<Collapsible title={t('settings.recording.insertGroupTitle')}>
<SettingRow label={t('settings.recording.restoreClipboardLabel')}>
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
</SettingRow>
{capability.adapter !== 'macEventTap' && (
<SettingRow label={t('settings.recording.pasteShortcutLabel')}>
<SelectLite
value={prefs.pasteShortcut}
onChange={next => onPasteShortcutChange(next as PasteShortcut)}
options={[
{ value: 'ctrlV', label: t('settings.recording.pasteShortcutCtrlV') },
{ value: 'ctrlShiftV', label: t('settings.recording.pasteShortcutCtrlShiftV') },
{ value: 'shiftInsert', label: t('settings.recording.pasteShortcutShiftInsert') },
]}
ariaLabel={t('settings.recording.pasteShortcutLabel')}
style={{ ...inputStyle, maxWidth: 220 }}
/>
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel' && (
<SettingRow label={t('settings.recording.allowNonTsfFallbackLabel')}>
<Toggle
on={prefs.allowNonTsfInsertionFallback}
onToggle={onAllowNonTsfFallbackChange}
/>
</SettingRow>
)}
{/* 流式输入:润色 SSE 一边到达一边模拟键盘逐字落到光标,降低感知延迟。
不满足条件时自动回落一次性插入。属于「插入行为」,故归到本组。 */}
<SettingRow label={t('settings.advanced.streamingInsertLabel')}>
<Toggle
on={!!prefs.streamingInsert}
onToggle={(next) => void savePrefs({ ...prefs, streamingInsert: next })}
/>
</SettingRow>
<SettingRow label={t('settings.advanced.streamingInsertSaveClipboardLabel')}>
<Toggle
on={!!prefs.streamingInsertSaveClipboard}
onToggle={(next) => void savePrefs({ ...prefs, streamingInsertSaveClipboard: next })}
/>
</SettingRow>
</Collapsible>
{/* ─── 启动(折叠) ──────────────────────────────────────────── */}
<Collapsible title={t('settings.recording.startupGroupTitle')}>
<AutostartRow />
<SettingRow label={t('settings.recording.startMinimizedLabel')}>
<Toggle on={prefs.startMinimized} onToggle={onStartMinimizedChange} />
</SettingRow>
<SettingRow label={t('settings.recording.autoUpdateCheckLabel')}>
<Toggle on={prefs.autoUpdateCheck} onToggle={onAutoUpdateCheckChange} />
</SettingRow>
{capability.statusHint && (
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
{capability.statusHint}
</div>
)}
</Collapsible>
</>
);
}
// 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop /
// windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。
function AutostartRow() {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(false);
const [loaded, setLoaded] = useState(false);
// 切 plist / 注册表失败时给用户看的错误。null = 没有失败/上次操作已成功。
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isTauri) {
setLoaded(true);
return;
}
let cancelled = false;
autostartIsEnabled()
.then((v: boolean) => {
if (!cancelled) {
setEnabled(v);
setLoaded(true);
}
})
.catch((err: unknown) => {
console.error('[autostart] isEnabled failed', err);
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
const onToggle = async (next: boolean) => {
setEnabled(next);
setError(null);
try {
if (!isTauri) return;
if (next) await autostartEnable();
else await autostartDisable();
} catch (err) {
console.error('[autostart] toggle failed', err);
setEnabled(!next);
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<SettingRow label={t('settings.recording.startupAtBoot')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{loaded ? <Toggle on={enabled} onToggle={onToggle} /> : null}
{error && (
<div style={{ fontSize: 11, color: 'var(--ol-err)', marginTop: 4, lineHeight: 1.5 }}>
{t('settings.recording.startupAtBootError', { message: error })}
</div>
)}
</div>
</SettingRow>
);
}
@@ -26,17 +26,13 @@ export function ShortcutsSection() {
);
}
const desc = capability.requiresAccessibilityPermission
? t('settings.shortcuts.descAcc')
: t('settings.shortcuts.descNoAcc');
const readonlyRows: Array<[string, string]> = [
[t('settings.shortcuts.cancel'), 'Esc'],
[t('settings.shortcuts.confirm'), t('settings.shortcuts.confirmHint')],
];
return (
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.shortcuts.title')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>{desc}</div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>{t('settings.shortcuts.title')}</div>
<SettingRow label={t('settings.shortcuts.startStop')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
<ShortcutRecorder
@@ -1,5 +1,5 @@
// 共享在 Settings 各 section 间的原子(SettingRow / Toggle / inputStyle)。
// AsrPresetId 也放在这里,让 AdvancedSection 与 Settings.tsx 都从一处来源拿。
// 共享在设置各 section 间的原子(SettingRow / Toggle / inputStyle)。
// AsrPresetId 也放在这里,让 settings/ 下各 section 都从同一处来源拿。
import type { CSSProperties, ReactNode } from 'react';
@@ -63,8 +63,8 @@ export const inputStyle: CSSProperties = {
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
};
// ASR provider id 集合,跟 Settings.tsx::ASR_PRESETS 一一对应。
// 拆成独立类型让 AdvancedSection / ProvidersSection 都能用同一份不互相依赖。
// ASR provider id 集合,跟 ProvidersSection.tsx::ASR_PRESETS 一一对应。
// 拆成独立类型让 LocalModelSection / ProvidersSection 都能用同一份不互相依赖。
export type AsrPresetId =
| 'volcengine'
| 'bailian'
@@ -0,0 +1,78 @@
// 设置弹窗里每个侧栏 tab 对应的内容页。每个 tab 就是若干 section 卡片的纵向堆叠;
// 真正的逻辑都在各 *Section 文件里,这里只负责"哪些 section 归到哪个 tab"。
import { useTranslation } from 'react-i18next';
import { RecordingInputSection } from './RecordingInputSection';
import { ShortcutsSection } from './ShortcutsSection';
import { LanguageSection } from './LanguageSection';
import { ProvidersSection } from './ProvidersSection';
import { MarketplaceSection } from './MarketplaceSection';
import { PermissionsSection } from './PermissionsSection';
import { DataStorageSection } from './DataStorageSection';
import { LocalModelSection } from './LocalModelSection';
import { DebugToolsSection } from './DebugToolsSection';
import { BetaChannelSection } from './BetaChannelSection';
// 通用:录音与输入 · 快捷键 · 语言。
export function GeneralTab() {
return (
<>
<RecordingInputSection />
<ShortcutsSection />
<LanguageSection />
</>
);
}
// 服务:AI 提供商 · 扩展市场。
export function ServicesTab() {
return (
<>
<ProvidersSection />
<MarketplaceSection />
</>
);
}
// 隐私:本地优先说明 + 权限管理 · 数据存储。
export function PrivacyTab() {
const { t } = useTranslation();
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 12px',
borderRadius: 10,
background: 'var(--ol-blue-soft)',
marginBottom: 2,
}}
>
<span style={{
fontSize: 11, padding: '3px 8px', borderRadius: 999,
background: '#fff', color: 'var(--ol-blue)', fontWeight: 600, flexShrink: 0,
}}>
{t('modal.about.localFirst')}
</span>
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
{t('modal.about.privacyDesc')}
</span>
</div>
<PermissionsSection />
<DataStorageSection />
</>
);
}
// 高级:本地模型 · 调试工具 · 加入 Beta 渠道(固定在最下面)。
export function AdvancedTab() {
return (
<>
<LocalModelSection />
<DebugToolsSection />
<BetaChannelSection />
</>
);
}
+23 -4
View File
@@ -77,14 +77,33 @@ a { color: inherit; text-decoration: none; }
}
}
/* 弹窗动画统一入口 —— 纯 opacity / transform,合成器友好。
背景不再逐帧动画 backdrop-filter(会卡顿、闪烁);模糊由宿主元素静态设定,
动画只负责淡入淡出。设置弹窗 / 各市场弹窗 / GitHub 登录弹窗共用这套。 */
@keyframes ol-modal-backdrop-in {
from { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); }
to { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); }
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ol-modal-backdrop-out {
from { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); }
to { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); }
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes ol-modal-card-in {
from { opacity: 0; transform: translateY(8px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* 设置弹窗切换 tab 时右侧内容的轻微淡入。 */
@keyframes ol-tab-fade {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* 通用旋转 —— 「检查更新」按钮检查中的转圈动画等。 */
@keyframes ol-spin {
to { transform: rotate(360deg); }
}
@keyframes ol-modal-drawer-in {