dudu MVP:五端语音输入法初始提交
- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调 - desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导) - android:Compose 主 App + IME(键盘内录音直传) - ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写) - design/design-pipeline:设计系统 + token 导出 iOS/Android 主题 - doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>反馈问题</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/feedback/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dudu 设置</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/settings/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>dudu — 登录</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/login/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>欢迎使用 dudu</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/onboarding/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>dudu</title>
|
||||
<style>html, body { margin: 0; background: transparent; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/overlay/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2036
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "dudu-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.4.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.4.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+6019
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "dudu-desktop"
|
||||
version = "0.1.0"
|
||||
description = "dudu 语音输入法桌面端"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "dudu_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
cpal = "0.15"
|
||||
arboard = "3"
|
||||
enigo = "0.2"
|
||||
parking_lot = "0.12"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"], default-features = false }
|
||||
base64 = "0.22"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "所有窗口的基础能力:核心 IPC(invoke/listen)+ 全局快捷键",
|
||||
"windows": ["settings", "overlay", "tray", "login", "feedback", "onboarding"],
|
||||
"permissions": ["core:default", "global-shortcut:default"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"所有窗口的基础能力:核心 IPC(invoke/listen)+ 全局快捷键","local":true,"windows":["settings","overlay","tray","login","feedback","onboarding"],"permissions":["core:default","global-shortcut:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,292 @@
|
||||
//! 后端 HTTP API 封装 + 窗口控制命令。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// 全局暂停开关(11B 托盘菜单"暂停使用"):暂停时快捷键不再触发听写。
|
||||
#[derive(Default)]
|
||||
pub struct Paused(AtomicBool);
|
||||
|
||||
pub fn is_app_paused(app: &AppHandle) -> bool {
|
||||
app.state::<Paused>().0.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_paused(app: AppHandle) -> bool {
|
||||
is_app_paused(&app)
|
||||
}
|
||||
|
||||
/// 切换暂停状态,返回新状态。
|
||||
#[tauri::command]
|
||||
pub fn toggle_pause(app: AppHandle) -> bool {
|
||||
let paused = &app.state::<Paused>().0;
|
||||
let next = !paused.load(Ordering::Relaxed);
|
||||
paused.store(next, Ordering::Relaxed);
|
||||
if next {
|
||||
crate::dictation::stop(&app, true); // 录音中暂停 → 直接取消
|
||||
}
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
let _ = tray.set_tooltip(Some(if next { "dudu — 已暂停" } else { "dudu — 就绪" }));
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
fn base(app: &AppHandle) -> (String, String) {
|
||||
let s = app.state::<crate::settings::SettingsStore>().get();
|
||||
(s.server_url, s.token)
|
||||
}
|
||||
|
||||
async fn get_json(app: &AppHandle, path: &str, authed: bool) -> Option<Value> {
|
||||
let (url, token) = base(app);
|
||||
let mut req = reqwest::Client::new().get(format!("{url}{path}"));
|
||||
if authed {
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
let resp = req.send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
resp.json().await.ok()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_me(app: AppHandle) -> Option<Value> {
|
||||
get_json(&app, "/v1/me", true).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_packs(app: AppHandle) -> Option<Value> {
|
||||
let v = get_json(&app, "/v1/packs", false).await?;
|
||||
v.get("packs").cloned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login_qr_create(app: AppHandle) -> Option<Value> {
|
||||
let (url, _) = base(&app);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{url}/v1/auth/qr"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
.json()
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login_qr_poll(app: AppHandle, state: String) -> Option<Value> {
|
||||
let v = get_json(&app, &format!("/v1/auth/qr/{state}"), false).await?;
|
||||
if v.get("status").and_then(|s| s.as_str()) == Some("confirmed") {
|
||||
if let Some(token) = v.get("token").and_then(|t| t.as_str()) {
|
||||
let store = app.state::<crate::settings::SettingsStore>();
|
||||
let mut s = store.get();
|
||||
s.token = token.to_string();
|
||||
store.set(s);
|
||||
// 通知 ws 重连携带新 token
|
||||
let _ = app.state::<crate::ws::WsHandle>().tx.send(crate::ws::WsCmd::Reconnect);
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_order(app: AppHandle, pack_id: String) -> Option<Value> {
|
||||
let (url, token) = base(&app);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{url}/v1/orders"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({"pack_id": pack_id, "channel": "native"}))
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
.json()
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn order_status(app: AppHandle, order_id: String) -> Option<Value> {
|
||||
get_json(&app, &format!("/v1/orders/{order_id}"), true).await
|
||||
}
|
||||
|
||||
// ─── 反馈(11E)──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct FeedbackImage {
|
||||
pub name: String,
|
||||
/// base64(不含 data: 前缀)
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
fn image_mime(name: &str) -> &'static str {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if lower.ends_with(".webp") {
|
||||
"image/webp"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> String {
|
||||
std::process::Command::new("sw_vers")
|
||||
.arg("-productVersion")
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn os_version() -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// multipart POST /v1/feedback:文字 + 图片(≤3 张 ≤5MB)+ 可选诊断信息。
|
||||
/// 错误以字符串 code 返回:NOT_LOGGED_IN / FEEDBACK_RATE_LIMITED / HTTP_xxx / 网络错误描述。
|
||||
#[tauri::command]
|
||||
pub async fn submit_feedback(
|
||||
app: AppHandle,
|
||||
content: String,
|
||||
images: Vec<FeedbackImage>,
|
||||
include_diagnostics: bool,
|
||||
) -> Result<Value, String> {
|
||||
let (url, token) = base(&app);
|
||||
if token.is_empty() {
|
||||
return Err("NOT_LOGGED_IN".into());
|
||||
}
|
||||
let app_version = app.package_info().version.to_string();
|
||||
|
||||
let mut form = reqwest::multipart::Form::new().text("content", content);
|
||||
for img in images.into_iter().take(3) {
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(img.data.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let part = reqwest::multipart::Part::bytes(bytes)
|
||||
.mime_str(image_mime(&img.name))
|
||||
.map_err(|e| e.to_string())?
|
||||
.file_name(img.name);
|
||||
form = form.part("images[]", part);
|
||||
}
|
||||
if include_diagnostics {
|
||||
let s = app.state::<crate::settings::SettingsStore>().get();
|
||||
let diag = json!({
|
||||
"app_version": app_version,
|
||||
"platform": std::env::consts::OS,
|
||||
"os_version": os_version(),
|
||||
"device_id": s.device_id,
|
||||
});
|
||||
form = form.text("diagnostics", diag.to_string());
|
||||
}
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("{url}/v1/feedback"))
|
||||
.bearer_auth(token)
|
||||
.header("X-Platform", std::env::consts::OS)
|
||||
.header("X-App-Version", app_version)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
match resp.status().as_u16() {
|
||||
200 => resp.json().await.map_err(|e| e.to_string()),
|
||||
429 => Err("FEEDBACK_RATE_LIMITED".into()),
|
||||
s => Err(format!("HTTP_{s}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 窗口控制 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_settings(app: AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("settings") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_login(app: AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("login") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn close_login(app: AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("login") {
|
||||
let _ = w.hide();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_feedback(app: AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("feedback") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn close_feedback(app: AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("feedback") {
|
||||
let _ = w.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成首次启动引导(11F):置 onboarding_done 并关闭引导窗。
|
||||
#[tauri::command]
|
||||
pub fn finish_onboarding(app: AppHandle) {
|
||||
let store = app.state::<crate::settings::SettingsStore>();
|
||||
let mut s = store.get();
|
||||
if !s.onboarding_done {
|
||||
s.onboarding_done = true;
|
||||
store.set(s);
|
||||
}
|
||||
if let Some(w) = app.get_webview_window("onboarding") {
|
||||
let _ = w.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查更新:GET /v1/app/latest,有新版则打开下载页;返回结果供 UI 提示。
|
||||
#[tauri::command]
|
||||
pub async fn check_update(app: AppHandle) -> Option<Value> {
|
||||
let current = app.package_info().version.to_string();
|
||||
#[cfg(target_os = "macos")]
|
||||
let platform = "mac";
|
||||
#[cfg(target_os = "windows")]
|
||||
let platform = "win";
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
let platform = "linux";
|
||||
let latest = get_json(&app, &format!("/v1/app/latest?platform={platform}"), false).await?;
|
||||
let version = latest.get("version").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let dl = latest.get("url").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let available = !version.is_empty() && version != current;
|
||||
if available && !dl.is_empty() {
|
||||
open_external(dl);
|
||||
}
|
||||
Some(json!({"current": current, "latest": version, "update_available": available, "url": dl}))
|
||||
}
|
||||
|
||||
/// 用系统默认方式打开外部链接。
|
||||
fn open_external(url: &str) {
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = std::process::Command::new("open").arg(url).spawn();
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = std::process::Command::new("cmd").args(["/C", "start", "", url]).spawn();
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn quit_app(app: AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 音频采集:cpal 输入流 → 单声道 → 线性重采样 16kHz i16 → 100ms 帧(3200B)。
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
pub const TARGET_RATE: u32 = 16000;
|
||||
pub const FRAME_SAMPLES: usize = 1600; // 100ms @16k
|
||||
|
||||
pub fn list_devices() -> Vec<String> {
|
||||
let host = cpal::default_host();
|
||||
host.input_devices()
|
||||
.map(|it| it.filter_map(|d| d.name().ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 触发麦克风授权(11F 引导页):短暂起一个输入流,首次会弹系统授权框。
|
||||
/// 返回 true 表示流可建立(设备可用 / 已授权或用户刚授权)。
|
||||
#[tauri::command]
|
||||
pub async fn request_microphone() -> bool {
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
match start("", tx) {
|
||||
Ok(capture) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
drop(rx);
|
||||
drop(capture);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 采集句柄:drop 即停止。cpal::Stream 在 macOS 上非 Send,
|
||||
/// 因此流由专用线程持有,句柄只保留停止信号端。
|
||||
pub struct Capture {
|
||||
stop: Option<Sender<()>>,
|
||||
}
|
||||
|
||||
impl Drop for Capture {
|
||||
fn drop(&mut self) {
|
||||
self.stop.take(); // 关闭通道 → 采集线程退出并销毁流
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动采集;每凑满 100ms 帧经 `tx` 发出。
|
||||
pub fn start(device_name: &str, tx: Sender<Vec<u8>>) -> Result<Capture, String> {
|
||||
let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||
let device_name = device_name.to_string();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let built = build_stream(&device_name, tx);
|
||||
match built {
|
||||
Ok(stream) => {
|
||||
if let Err(e) = stream.play() {
|
||||
let _ = ready_tx.send(Err(e.to_string()));
|
||||
return;
|
||||
}
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
// 阻塞直至句柄 drop(发送端关闭 → recv Err)
|
||||
let _ = stop_rx.recv();
|
||||
drop(stream);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ready_rx
|
||||
.recv()
|
||||
.map_err(|_| "采集线程异常退出".to_string())??;
|
||||
Ok(Capture { stop: Some(stop_tx) })
|
||||
}
|
||||
|
||||
fn build_stream(device_name: &str, tx: Sender<Vec<u8>>) -> Result<cpal::Stream, String> {
|
||||
let host = cpal::default_host();
|
||||
let device = if device_name.is_empty() {
|
||||
host.default_input_device()
|
||||
} else {
|
||||
host.input_devices()
|
||||
.ok()
|
||||
.and_then(|mut it| it.find(|d| d.name().map(|n| n == device_name).unwrap_or(false)))
|
||||
.or_else(|| host.default_input_device())
|
||||
}
|
||||
.ok_or("没有可用麦克风")?;
|
||||
|
||||
let config = device.default_input_config().map_err(|e| e.to_string())?;
|
||||
let src_rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let mut resampler = Resampler::new(src_rate, channels, tx);
|
||||
|
||||
let err_fn = |e| log::error!("audio stream error: {e}");
|
||||
match config.sample_format() {
|
||||
cpal::SampleFormat::F32 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[f32], _| resampler.push_f32(data),
|
||||
err_fn,
|
||||
None,
|
||||
),
|
||||
cpal::SampleFormat::I16 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[i16], _| resampler.push_i16(data),
|
||||
err_fn,
|
||||
None,
|
||||
),
|
||||
f => return Err(format!("不支持的采样格式: {f:?}")),
|
||||
}
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 线性插值重采样 + 分帧。
|
||||
struct Resampler {
|
||||
src_rate: u32,
|
||||
channels: usize,
|
||||
pos: f64,
|
||||
step: f64,
|
||||
prev: f32,
|
||||
buf: Vec<i16>,
|
||||
tx: Sender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Resampler {
|
||||
fn new(src_rate: u32, channels: usize, tx: Sender<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
src_rate,
|
||||
channels,
|
||||
pos: 0.0,
|
||||
step: src_rate as f64 / TARGET_RATE as f64,
|
||||
prev: 0.0,
|
||||
buf: Vec::with_capacity(FRAME_SAMPLES * 2),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_f32(&mut self, data: &[f32]) {
|
||||
let mono: Vec<f32> = data
|
||||
.chunks(self.channels)
|
||||
.map(|c| c.iter().sum::<f32>() / self.channels as f32)
|
||||
.collect();
|
||||
self.resample(&mono);
|
||||
}
|
||||
|
||||
fn push_i16(&mut self, data: &[i16]) {
|
||||
let mono: Vec<f32> = data
|
||||
.chunks(self.channels)
|
||||
.map(|c| c.iter().map(|&s| s as f32 / 32768.0).sum::<f32>() / self.channels as f32)
|
||||
.collect();
|
||||
self.resample(&mono);
|
||||
}
|
||||
|
||||
fn resample(&mut self, mono: &[f32]) {
|
||||
if self.src_rate == TARGET_RATE {
|
||||
for &s in mono {
|
||||
self.emit(s);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for &s in mono {
|
||||
while self.pos < 1.0 {
|
||||
let v = self.prev + (s - self.prev) * self.pos as f32;
|
||||
self.emit(v);
|
||||
self.pos += self.step;
|
||||
}
|
||||
self.pos -= 1.0;
|
||||
self.prev = s;
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&mut self, sample: f32) {
|
||||
let v = (sample.clamp(-1.0, 1.0) * 32767.0) as i16;
|
||||
self.buf.push(v);
|
||||
if self.buf.len() >= FRAME_SAMPLES {
|
||||
let frame: Vec<u8> = self.buf[..FRAME_SAMPLES]
|
||||
.iter()
|
||||
.flat_map(|s| s.to_le_bytes())
|
||||
.collect();
|
||||
self.buf.drain(..FRAME_SAMPLES);
|
||||
let _ = self.tx.send(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! 听写编排(push-to-talk 核心):
|
||||
//! 快捷键按下 → 浮层弹出(光标附近,不抢焦点)→ 采集推流;
|
||||
//! 松开 → stop → 等尾部 final → 注入 committed 文本 → 浮层淡出。
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DictationState {
|
||||
inner: Mutex<Option<Session>>,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
id: String,
|
||||
capture: Option<crate::audio::Capture>,
|
||||
final_text: String,
|
||||
partial_text: String,
|
||||
started: Instant,
|
||||
got_first_partial: bool,
|
||||
}
|
||||
|
||||
pub fn start(app: &AppHandle) {
|
||||
let state = app.state::<DictationState>();
|
||||
if state.inner.lock().is_some() {
|
||||
return; // 已在录音
|
||||
}
|
||||
if crate::api::is_app_paused(app) {
|
||||
return; // 已暂停使用
|
||||
}
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
set_tray_tooltip(app, "dudu — 录音中");
|
||||
let _ = app.emit("hotkey", json!({"state": "down"}));
|
||||
show_overlay(app);
|
||||
|
||||
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
|
||||
let _ = ws.send(crate::ws::WsCmd::Start {
|
||||
session_id: session_id.clone(),
|
||||
});
|
||||
|
||||
// 音频帧经 std channel → 转发 tokio channel
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||
let mic = app.state::<crate::settings::SettingsStore>().get().mic;
|
||||
let t0 = Instant::now();
|
||||
let capture = match crate::audio::start(&mic, tx) {
|
||||
Ok(c) => {
|
||||
let m = app.state::<crate::metrics::Metrics>();
|
||||
m.record("audio.start_ms", json!({"ms": t0.elapsed().as_millis() as i64}));
|
||||
Some(c)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("audio start failed: {e}");
|
||||
let _ = app.emit("asr", json!({"type":"error","code":"AUDIO","message": e}));
|
||||
None
|
||||
}
|
||||
};
|
||||
{
|
||||
let ws = ws.clone();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(frame) = rx.recv() {
|
||||
if ws.send(crate::ws::WsCmd::Audio(frame)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
*state.inner.lock() = Some(Session {
|
||||
id: session_id,
|
||||
capture,
|
||||
final_text: String::new(),
|
||||
partial_text: String::new(),
|
||||
started: Instant::now(),
|
||||
got_first_partial: false,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn stop(app: &AppHandle, canceled: bool) {
|
||||
let state = app.state::<DictationState>();
|
||||
let Some(mut sess) = state.inner.lock().take() else {
|
||||
return;
|
||||
};
|
||||
let _ = app.emit("hotkey", json!({"state": "up"}));
|
||||
set_tray_tooltip(app, "dudu — 就绪");
|
||||
sess.capture.take(); // 停止采集
|
||||
|
||||
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
|
||||
let cmd = if canceled {
|
||||
crate::ws::WsCmd::Cancel { session_id: sess.id.clone() }
|
||||
} else {
|
||||
crate::ws::WsCmd::Stop { session_id: sess.id.clone() }
|
||||
};
|
||||
let _ = ws.send(cmd);
|
||||
|
||||
let app = app.clone();
|
||||
let release_at = Instant::now();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if !canceled {
|
||||
// 等网关 flush 尾部 final(句间已实时累计,这里只兜尾部)
|
||||
tokio::time::sleep(Duration::from_millis(350)).await;
|
||||
let committed = {
|
||||
let state = app.state::<DictationState>();
|
||||
let pending = state.inner.lock();
|
||||
drop(pending);
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
buf.take()
|
||||
};
|
||||
if !committed.is_empty() {
|
||||
if !crate::inject::accessibility_ok() {
|
||||
// 无辅助功能权限(macOS):注入必失败 → 浮层引导去授权(10E)
|
||||
let _ = app.emit(
|
||||
"asr",
|
||||
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(2400)).await; // 让用户看清引导
|
||||
} else {
|
||||
let t = committed.clone();
|
||||
let injected =
|
||||
tauri::async_runtime::spawn_blocking(move || crate::inject::inject_text(&t)).await;
|
||||
match injected {
|
||||
Ok(Ok(())) => {
|
||||
let m = app.state::<crate::metrics::Metrics>();
|
||||
m.record(
|
||||
"asr.release_to_commit_ms",
|
||||
json!({"ms": release_at.elapsed().as_millis() as i64}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
log::error!("inject failed: {injected:?}");
|
||||
if !crate::inject::accessibility_ok() {
|
||||
let _ = app.emit(
|
||||
"asr",
|
||||
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(2400)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.state::<CommitBuffer>().take();
|
||||
}
|
||||
hide_overlay(&app);
|
||||
});
|
||||
}
|
||||
|
||||
/// CommitBuffer 跨 ws 任务与停止流程共享的"待注入文本"。
|
||||
#[derive(Default)]
|
||||
pub struct CommitBuffer {
|
||||
text: Mutex<(String, String)>, // (final 累计, 最新 partial)
|
||||
}
|
||||
|
||||
impl CommitBuffer {
|
||||
fn take(&self) -> String {
|
||||
let mut t = self.text.lock();
|
||||
let committed = format!("{}{}", t.0, t.1);
|
||||
*t = (String::new(), String::new());
|
||||
committed
|
||||
}
|
||||
}
|
||||
|
||||
/// ws 下行回调:维护文本缓冲 + 首字延迟打点(在 ws.rs 收包处调用)。
|
||||
pub fn on_server_msg(app: &AppHandle, v: &Value) {
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
match v.get("type").and_then(|t| t.as_str()) {
|
||||
Some("partial") => {
|
||||
if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
|
||||
buf.text.lock().1 = text.to_string();
|
||||
}
|
||||
let state = app.state::<DictationState>();
|
||||
let mut guard = state.inner.lock();
|
||||
if let Some(sess) = guard.as_mut() {
|
||||
if !sess.got_first_partial {
|
||||
sess.got_first_partial = true;
|
||||
let m = app.state::<crate::metrics::Metrics>();
|
||||
m.record(
|
||||
"asr.first_partial_ms",
|
||||
json!({"ms": sess.started.elapsed().as_millis() as i64}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("final") => {
|
||||
if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
|
||||
let mut t = buf.text.lock();
|
||||
t.0.push_str(text);
|
||||
t.1.clear();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 托盘提示按录音状态切换(11B)。
|
||||
fn set_tray_tooltip(app: &AppHandle, text: &str) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
let _ = tray.set_tooltip(Some(text));
|
||||
}
|
||||
}
|
||||
|
||||
fn show_overlay(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("overlay") {
|
||||
// 跟随光标:浮层出现在指针下方 24px,水平居中
|
||||
if let Ok(pos) = app.cursor_position() {
|
||||
let _ = w.set_position(tauri::PhysicalPosition::new(pos.x - 180.0, pos.y + 24.0));
|
||||
}
|
||||
let _ = w.show();
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_overlay(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("overlay") {
|
||||
let _ = w.hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! 文字注入(10E/10F):剪贴板写入 → 模拟粘贴(mac ⌘V / win Ctrl+V)→ 恢复剪贴板。
|
||||
//! 兼容性最好的方案;macOS 需辅助功能权限(首次启动引导授予)。
|
||||
|
||||
use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings};
|
||||
use std::{thread, time::Duration};
|
||||
|
||||
// ─── macOS 辅助功能权限(10E)────────────────────────────────────────────────
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[link(name = "ApplicationServices", kind = "framework")]
|
||||
extern "C" {
|
||||
/// 进程是否已被授予辅助功能权限(不弹系统提示)。
|
||||
fn AXIsProcessTrusted() -> u8;
|
||||
}
|
||||
|
||||
/// 是否已具备注入所需的辅助功能权限;非 macOS 恒为 true。
|
||||
pub fn accessibility_ok() -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
unsafe { AXIsProcessTrusted() != 0 }
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_accessibility() -> bool {
|
||||
accessibility_ok()
|
||||
}
|
||||
|
||||
/// 打开系统设置的辅助功能面板(macOS);其他平台 no-op。
|
||||
#[tauri::command]
|
||||
pub fn open_accessibility_settings() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = std::process::Command::new("open")
|
||||
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inject_text(text: &str) -> Result<(), String> {
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
|
||||
let saved = clipboard.get_text().ok();
|
||||
|
||||
clipboard.set_text(text).map_err(|e| e.to_string())?;
|
||||
thread::sleep(Duration::from_millis(30)); // 等剪贴板生效
|
||||
|
||||
let mut enigo = Enigo::new(&EnigoSettings::default()).map_err(|e| e.to_string())?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let modifier = Key::Meta;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let modifier = Key::Control;
|
||||
|
||||
enigo.key(modifier, Direction::Press).map_err(|e| e.to_string())?;
|
||||
enigo
|
||||
.key(Key::Unicode('v'), Direction::Click)
|
||||
.map_err(|e| e.to_string())?;
|
||||
enigo
|
||||
.key(modifier, Direction::Release)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 粘贴完成后恢复用户剪贴板
|
||||
thread::sleep(Duration::from_millis(120));
|
||||
if let Some(old) = saved {
|
||||
let _ = clipboard.set_text(old);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! dudu 桌面端(Tauri 2)。模块划分见 doc/frontend-design.html 4.1。
|
||||
|
||||
pub mod api;
|
||||
pub mod audio;
|
||||
pub mod dictation;
|
||||
pub mod inject;
|
||||
pub mod metrics;
|
||||
pub mod settings;
|
||||
pub mod ws;
|
||||
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_global_shortcut::{Shortcut, ShortcutState};
|
||||
|
||||
/// 托盘图标 rect → 菜单窗口左上角物理坐标(图标正下方、水平居中)。
|
||||
fn tray_menu_position(rect: &tauri::Rect, scale: f64, win_width: f64) -> (f64, f64) {
|
||||
let (x, y) = match rect.position {
|
||||
tauri::Position::Physical(p) => (p.x as f64, p.y as f64),
|
||||
tauri::Position::Logical(p) => (p.x * scale, p.y * scale),
|
||||
};
|
||||
let (w, h) = match rect.size {
|
||||
tauri::Size::Physical(s) => (s.width as f64, s.height as f64),
|
||||
tauri::Size::Logical(s) => (s.width * scale, s.height * scale),
|
||||
};
|
||||
(x + w / 2.0 - win_width / 2.0, y + h + 4.0 * scale)
|
||||
}
|
||||
|
||||
/// 左键点击托盘图标 → 在图标位置弹出 / 收起自绘菜单窗(11B)。
|
||||
fn toggle_tray_menu(app: &tauri::AppHandle, rect: &tauri::Rect) {
|
||||
let Some(w) = app.get_webview_window("tray") else {
|
||||
return;
|
||||
};
|
||||
if w.is_visible().unwrap_or(false) {
|
||||
let _ = w.hide();
|
||||
return;
|
||||
}
|
||||
let scale = w.scale_factor().unwrap_or(1.0);
|
||||
let win_width = w
|
||||
.outer_size()
|
||||
.map(|s| s.width as f64)
|
||||
.unwrap_or(248.0 * scale);
|
||||
let (x, y) = tray_menu_position(rect, scale, win_width);
|
||||
let _ = w.set_position(tauri::PhysicalPosition::new(x, y));
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus(); // 获焦后才能在失焦时自动收起
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
env_logger::init();
|
||||
tauri::Builder::default()
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(|app, _shortcut: &Shortcut, event| {
|
||||
match event.state() {
|
||||
ShortcutState::Pressed => dictation::start(app),
|
||||
ShortcutState::Released => dictation::stop(app, false),
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.on_window_event(|window, event| match event {
|
||||
// 托盘菜单窗失焦自动收起
|
||||
tauri::WindowEvent::Focused(false) if window.label() == "tray" => {
|
||||
let _ = window.hide();
|
||||
}
|
||||
// 常驻托盘:功能窗口"关闭"仅隐藏,应用不退出
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
match window.label() {
|
||||
"settings" | "login" | "feedback" => {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
"onboarding" => {
|
||||
// 关闭引导窗 = 完成引导(11F)
|
||||
api.prevent_close();
|
||||
api::finish_onboarding(window.app_handle().clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
// 设置 → 管理状态
|
||||
app.manage(settings::SettingsStore::load(&handle));
|
||||
app.manage(dictation::DictationState::default());
|
||||
app.manage(dictation::CommitBuffer::default());
|
||||
app.manage(api::Paused::default());
|
||||
// WS 预连接 + 打点队列
|
||||
app.manage(ws::spawn(handle.clone()));
|
||||
app.manage(metrics::spawn(handle.clone()));
|
||||
// 系统托盘(11B):左键弹出自绘菜单窗,tooltip 随录音状态切换
|
||||
let icon = app
|
||||
.default_window_icon()
|
||||
.cloned()
|
||||
.expect("missing app icon");
|
||||
TrayIconBuilder::with_id("main")
|
||||
.icon(icon)
|
||||
.tooltip("dudu — 就绪")
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
rect,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
toggle_tray_menu(tray.app_handle(), &rect);
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
// 注册 push-to-talk 快捷键
|
||||
let s = app.state::<settings::SettingsStore>().get();
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
if let Err(e) = app.global_shortcut().register(s.hotkey.as_str()) {
|
||||
log::error!("register hotkey failed: {e}");
|
||||
}
|
||||
// 首次启动 → 引导窗(11F);之后均静默启动(仅托盘常驻)
|
||||
if !s.onboarding_done {
|
||||
if let Some(w) = app.get_webview_window("onboarding") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
settings::get_settings,
|
||||
settings::set_settings,
|
||||
settings::list_mic_devices,
|
||||
audio::request_microphone,
|
||||
inject::check_accessibility,
|
||||
inject::open_accessibility_settings,
|
||||
api::fetch_me,
|
||||
api::fetch_packs,
|
||||
api::login_qr_create,
|
||||
api::login_qr_poll,
|
||||
api::create_order,
|
||||
api::order_status,
|
||||
api::submit_feedback,
|
||||
api::is_paused,
|
||||
api::toggle_pause,
|
||||
api::open_settings,
|
||||
api::open_login,
|
||||
api::close_login,
|
||||
api::open_feedback,
|
||||
api::close_feedback,
|
||||
api::finish_onboarding,
|
||||
api::check_update,
|
||||
api::quit_app,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running dudu");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
dudu_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! 客户端打点(10H):内存队列,满 20 条或 60s 批量上报;失败丢弃不影响功能。
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Metrics {
|
||||
queue: Arc<Mutex<Vec<Value>>>,
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub fn record(&self, event: &str, props: Value) {
|
||||
let mut q = self.queue.lock();
|
||||
q.push(json!({
|
||||
"event": event,
|
||||
"props": props,
|
||||
"client_ts": std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0),
|
||||
}));
|
||||
if q.len() > 1000 {
|
||||
let overflow = q.len() - 1000;
|
||||
q.drain(..overflow); // 上限丢最旧
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(app: AppHandle) -> Metrics {
|
||||
let m = Metrics {
|
||||
queue: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let queue = m.queue.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let client = reqwest::Client::new();
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(10));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let (events, url, device_id) = {
|
||||
let mut q = queue.lock();
|
||||
if q.is_empty() || (q.len() < 20 && tickcount_skip(&q)) {
|
||||
continue;
|
||||
}
|
||||
let events: Vec<Value> = q.drain(..).collect();
|
||||
let s = app.state::<crate::settings::SettingsStore>().get();
|
||||
(events, format!("{}/v1/metrics/batch", s.server_url), s.device_id)
|
||||
};
|
||||
let body = json!({
|
||||
"device_id": device_id,
|
||||
"platform": std::env::consts::OS,
|
||||
"app_version": env!("CARGO_PKG_VERSION"),
|
||||
"os_version": "",
|
||||
"events": events,
|
||||
});
|
||||
let _ = client.post(&url).json(&body).send().await; // 失败即丢弃
|
||||
}
|
||||
});
|
||||
m
|
||||
}
|
||||
|
||||
// 不满 20 条时按 60s 节奏(tick 10s × 6 次取模近似)上报;保持实现极简。
|
||||
fn tickcount_skip(_q: &[Value]) -> bool {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
N.fetch_add(1, Ordering::Relaxed) % 6 != 0
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! 应用设置:本地 JSON 持久化(MVP;token 后续迁系统钥匙串)。
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub hotkey: String,
|
||||
pub mic: String,
|
||||
pub sound: bool,
|
||||
pub autostart: bool,
|
||||
pub theme: String,
|
||||
pub server_url: String,
|
||||
pub token: String,
|
||||
pub device_id: String,
|
||||
/// 首次启动引导是否已完成(11F)。
|
||||
pub onboarding_done: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hotkey: "CmdOrCtrl+Shift+Space".into(),
|
||||
mic: String::new(),
|
||||
sound: false,
|
||||
autostart: true,
|
||||
theme: "system".into(),
|
||||
server_url: "http://localhost:8080".into(),
|
||||
token: String::new(),
|
||||
device_id: uuid::Uuid::new_v4().to_string(),
|
||||
onboarding_done: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SettingsStore {
|
||||
path: PathBuf,
|
||||
pub current: Mutex<Settings>,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
pub fn load(app: &tauri::AppHandle) -> Self {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.unwrap_or_else(|_| std::env::temp_dir());
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("settings.json");
|
||||
let current = std::fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
let store = Self {
|
||||
path,
|
||||
current: Mutex::new(current),
|
||||
};
|
||||
store.save(); // 首次生成 device_id 后立即落盘
|
||||
store
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let s = self.current.lock().clone();
|
||||
if let Ok(b) = serde_json::to_vec_pretty(&s) {
|
||||
let _ = std::fs::write(&self.path, b);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self) -> Settings {
|
||||
self.current.lock().clone()
|
||||
}
|
||||
|
||||
pub fn set(&self, s: Settings) {
|
||||
*self.current.lock() = s;
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_settings(store: tauri::State<SettingsStore>) -> Settings {
|
||||
store.get()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_settings(store: tauri::State<SettingsStore>, settings: Settings) {
|
||||
store.set(settings);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_mic_devices() -> Vec<String> {
|
||||
crate::audio::list_devices()
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! WS 客户端(10C):预连接 + 心跳 + 指数退避重连;
|
||||
//! 上行音频二进制帧 / 控制 JSON,下行 ServerMsg → emit("asr") + 维护听写文本状态。
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WsCmd {
|
||||
Start { session_id: String },
|
||||
Audio(Vec<u8>),
|
||||
Stop { session_id: String },
|
||||
Cancel { session_id: String },
|
||||
Reconnect, // 设置变更(token/服务器)后强制重连
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WsHandle {
|
||||
pub tx: mpsc::UnboundedSender<WsCmd>,
|
||||
}
|
||||
|
||||
/// 启动常驻连接任务(应用启动即预连接,按键时零握手)。
|
||||
pub fn spawn(app: AppHandle) -> WsHandle {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<WsCmd>();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut backoff = Duration::from_millis(500);
|
||||
loop {
|
||||
let (url, token, device_id) = {
|
||||
let store = app.state::<crate::settings::SettingsStore>();
|
||||
let s = store.get();
|
||||
let ws_url = s.server_url.replace("http://", "ws://").replace("https://", "wss://");
|
||||
(format!("{ws_url}/v1/asr/stream"), s.token, s.device_id)
|
||||
};
|
||||
if token.is_empty() {
|
||||
// 未登录:等待命令但丢弃音频,定期重查
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
while let Ok(cmd) = rx.try_recv() {
|
||||
drop(cmd);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut req = match url.clone().into_client_request() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("bad ws url: {e}");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
req.headers_mut().insert(
|
||||
"Authorization",
|
||||
format!("Bearer {token}").parse().unwrap(),
|
||||
);
|
||||
req.headers_mut()
|
||||
.insert("X-Device-ID", device_id.parse().unwrap());
|
||||
|
||||
let conn = tokio_tungstenite::connect_async(req).await;
|
||||
let (mut sink, mut stream) = match conn {
|
||||
Ok((ws, _)) => {
|
||||
log::info!("ws connected");
|
||||
backoff = Duration::from_millis(500);
|
||||
ws.split()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("ws connect failed: {e}; retry in {backoff:?}");
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(8));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut ping = tokio::time::interval(Duration::from_secs(30));
|
||||
'conn: loop {
|
||||
tokio::select! {
|
||||
cmd = rx.recv() => {
|
||||
let Some(cmd) = cmd else { return };
|
||||
let msg = match cmd {
|
||||
WsCmd::Start { session_id } => Message::Text(
|
||||
json!({"type":"start","session_id":session_id,"sample_rate":16000,"format":"pcm16"}).to_string()),
|
||||
WsCmd::Audio(frame) => Message::Binary(frame),
|
||||
WsCmd::Stop { session_id } => Message::Text(
|
||||
json!({"type":"stop","session_id":session_id}).to_string()),
|
||||
WsCmd::Cancel { session_id } => Message::Text(
|
||||
json!({"type":"cancel","session_id":session_id}).to_string()),
|
||||
WsCmd::Reconnect => break 'conn,
|
||||
};
|
||||
if sink.send(msg).await.is_err() {
|
||||
let _ = app.emit("asr", json!({"type":"error","code":"NETWORK"}));
|
||||
break 'conn;
|
||||
}
|
||||
}
|
||||
incoming = stream.next() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
crate::dictation::on_server_msg(&app, &v);
|
||||
let _ = app.emit("asr", v);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Ping(p))) => { let _ = sink.send(Message::Pong(p)).await; }
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("ws read error: {e}");
|
||||
let _ = app.emit("asr", json!({"type":"error","code":"NETWORK"}));
|
||||
break 'conn;
|
||||
}
|
||||
None => break 'conn,
|
||||
}
|
||||
}
|
||||
_ = ping.tick() => {
|
||||
if sink.send(Message::Ping(Vec::new())).await.is_err() { break 'conn; }
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(8));
|
||||
}
|
||||
});
|
||||
WsHandle { tx }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "dudu",
|
||||
"version": "0.1.0",
|
||||
"identifier": "app.dudu.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:5181",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "settings",
|
||||
"title": "dudu 设置",
|
||||
"url": "index.html",
|
||||
"width": 440,
|
||||
"height": 560,
|
||||
"resizable": false,
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"label": "overlay",
|
||||
"title": "dudu",
|
||||
"url": "overlay.html",
|
||||
"width": 360,
|
||||
"height": 140,
|
||||
"visible": false,
|
||||
"transparent": true,
|
||||
"decorations": false,
|
||||
"alwaysOnTop": true,
|
||||
"focus": false,
|
||||
"skipTaskbar": true,
|
||||
"shadow": false
|
||||
},
|
||||
{
|
||||
"label": "login",
|
||||
"title": "dudu — 登录",
|
||||
"url": "login.html",
|
||||
"width": 400,
|
||||
"height": 520,
|
||||
"resizable": false,
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"label": "tray",
|
||||
"title": "dudu",
|
||||
"url": "tray.html",
|
||||
"width": 248,
|
||||
"height": 300,
|
||||
"resizable": false,
|
||||
"visible": false,
|
||||
"transparent": true,
|
||||
"decorations": false,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"shadow": false
|
||||
},
|
||||
{
|
||||
"label": "feedback",
|
||||
"title": "反馈问题",
|
||||
"url": "feedback.html",
|
||||
"width": 460,
|
||||
"height": 560,
|
||||
"resizable": false,
|
||||
"center": true,
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"label": "onboarding",
|
||||
"title": "欢迎使用 dudu",
|
||||
"url": "onboarding.html",
|
||||
"width": 560,
|
||||
"height": 480,
|
||||
"resizable": false,
|
||||
"center": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": ["icons/icon.png"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// 反馈窗口(11E):文字 + 图片(粘贴 / 拖拽 / 选择,≤3 张 ≤5MB)+ 诊断信息开关。
|
||||
// 规格见 doc/frontend-design.html 第八章;提交走 multipart POST /v1/feedback。
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@dudu/design/styles.css';
|
||||
import { Button } from '@dudu/design/components/core/Button';
|
||||
import { Switch } from '@dudu/design/components/forms/Switch';
|
||||
import { SettingRow } from '@dudu/design/components/forms/SettingRow';
|
||||
import { invoke } from '../shared/tauri';
|
||||
import { setThemePref } from '../shared/theme';
|
||||
|
||||
const MAX_IMAGES = 3;
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_LEN = 1000;
|
||||
const OK_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
const ERR_TEXT = {
|
||||
NOT_LOGGED_IN: '先登录再反馈',
|
||||
FEEDBACK_RATE_LIMITED: '今天反馈次数已达上限',
|
||||
};
|
||||
|
||||
// File → { name, dataUrl, base64 }
|
||||
function readImage(file) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result;
|
||||
resolve({ name: file.name || 'screenshot.png', dataUrl, base64: dataUrl.split(',')[1] });
|
||||
};
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function Thumb({ img, onRemove }) {
|
||||
return (
|
||||
<div style={{ position: 'relative', width: 64, height: 64, flex: 'none' }}>
|
||||
<img src={img.dataUrl} alt={img.name} style={{
|
||||
width: 64, height: 64, objectFit: 'cover', borderRadius: 6,
|
||||
border: '1px solid var(--border-1)', display: 'block',
|
||||
}} />
|
||||
<button type="button" onClick={onRemove} aria-label="删除图片" style={{
|
||||
position: 'absolute', top: -7, right: -7, width: 20, height: 20,
|
||||
borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||
background: 'var(--text-1)', color: 'var(--bg-app)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
|
||||
}}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddBox({ onClick, dragging }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const on = hover || dragging;
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: 64, height: 64, flex: 'none', borderRadius: 6, cursor: 'pointer',
|
||||
border: `2px dashed ${on ? 'var(--accent)' : 'var(--border-2)'}`,
|
||||
background: on ? 'var(--accent-soft)' : 'transparent',
|
||||
color: on ? 'var(--accent-text)' : 'var(--text-3)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color var(--dur-fast) var(--ease-out)',
|
||||
}}>
|
||||
{/* Lucide image-plus */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 5h6"/><path d="M19 2v6"/><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/>
|
||||
<circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessView({ onAgain }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14, paddingTop: 110 }}>
|
||||
<span style={{
|
||||
width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
</span>
|
||||
<div style={{ fontSize: 17, fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>已收到 · 谢谢你</div>
|
||||
<Button variant="ghost" size="sm" onClick={onAgain}>继续反馈</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackApp() {
|
||||
const [content, setContent] = useState('');
|
||||
const [images, setImages] = useState([]);
|
||||
const [diag, setDiag] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [loggedIn, setLoggedIn] = useState(true);
|
||||
const fileRef = useRef(null);
|
||||
const closeTimer = useRef(null);
|
||||
|
||||
const refreshLogin = () => {
|
||||
invoke('get_settings').then((s) => {
|
||||
if (s) { setThemePref(s.theme); setLoggedIn(!!s.token); }
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
refreshLogin();
|
||||
window.addEventListener('focus', refreshLogin);
|
||||
return () => window.removeEventListener('focus', refreshLogin);
|
||||
}, []);
|
||||
|
||||
const addFiles = async (files) => {
|
||||
setErr('');
|
||||
const list = Array.from(files).filter((f) => OK_TYPES.includes(f.type));
|
||||
if (!list.length) return;
|
||||
const room = MAX_IMAGES - images.length;
|
||||
if (room <= 0) { setErr(`最多 ${MAX_IMAGES} 张图片`); return; }
|
||||
const next = [];
|
||||
for (const f of list.slice(0, room)) {
|
||||
if (f.size > MAX_BYTES) { setErr('单张图片不超过 5MB'); continue; }
|
||||
const img = await readImage(f);
|
||||
if (img) next.push(img);
|
||||
}
|
||||
if (next.length) setImages((cur) => [...cur, ...next].slice(0, MAX_IMAGES));
|
||||
};
|
||||
|
||||
// 粘贴截图(Cmd+V)
|
||||
useEffect(() => {
|
||||
const onPaste = (e) => {
|
||||
const files = Array.from(e.clipboardData?.items || [])
|
||||
.filter((it) => it.kind === 'file').map((it) => it.getAsFile()).filter(Boolean);
|
||||
if (files.length) addFiles(files);
|
||||
};
|
||||
document.addEventListener('paste', onPaste);
|
||||
return () => document.removeEventListener('paste', onPaste);
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
setSubmitting(true);
|
||||
setErr('');
|
||||
try {
|
||||
const r = await invoke('submit_feedback', {
|
||||
content: content.trim(),
|
||||
images: images.map((i) => ({ name: i.name, data: i.base64 })),
|
||||
includeDiagnostics: diag,
|
||||
});
|
||||
if (r && r.feedback_id) {
|
||||
setDone(true);
|
||||
setContent(''); setImages([]);
|
||||
closeTimer.current = setTimeout(() => invoke('close_feedback'), 1800);
|
||||
} else {
|
||||
setErr('提交失败,请稍后再试');
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(ERR_TEXT[String(e)] || '提交失败,请稍后再试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const frame = (children) => (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}
|
||||
style={{
|
||||
fontFamily: 'var(--font-sans)', background: 'var(--bg-app)', minHeight: '100vh',
|
||||
boxSizing: 'border-box', padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (done) {
|
||||
return frame(<SuccessView onAgain={() => { clearTimeout(closeTimer.current); setDone(false); }} />);
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
return frame(
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14, paddingTop: 130 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>先登录再反馈</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>登录后我们才能跟进你的问题</div>
|
||||
<Button variant="primary" onClick={() => invoke('open_login')}>微信登录</Button>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
const count = [...content].length;
|
||||
return frame(
|
||||
<>
|
||||
<div style={{ fontSize: 17, fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>告诉我们哪里不对</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<textarea
|
||||
value={content}
|
||||
maxLength={MAX_LEN}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="说说遇到的问题或建议"
|
||||
style={{
|
||||
width: '100%', height: 170, resize: 'none', boxSizing: 'border-box',
|
||||
padding: '10px 12px 26px', borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--border-1)', background: 'var(--surface-card)',
|
||||
color: 'var(--text-1)', fontSize: 'var(--text-base)', fontFamily: 'var(--font-sans)',
|
||||
lineHeight: 1.6, outline: 'none',
|
||||
}} />
|
||||
<span style={{ position: 'absolute', right: 12, bottom: 10, fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{count} / {MAX_LEN}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
{images.map((img, i) => (
|
||||
<Thumb key={`${img.name}-${i}`} img={img} onRemove={() => setImages(images.filter((_, j) => j !== i))} />
|
||||
))}
|
||||
{images.length < MAX_IMAGES && <AddBox dragging={dragging} onClick={() => fileRef.current?.click()} />}
|
||||
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/webp" multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}>
|
||||
粘贴截图、拖进来或点击添加 · 最多 3 张 · 单张不超过 5MB
|
||||
</div>
|
||||
<SettingRow label="附带诊断信息" description="版本、系统、设备标识">
|
||||
<Switch checked={diag} onChange={setDiag} />
|
||||
</SettingRow>
|
||||
{err && <div style={{ fontSize: 'var(--text-sm)', color: 'var(--danger)' }}>{err}</div>}
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<Button variant="primary" block disabled={!content.trim() || submitting} onClick={submit}>
|
||||
{submitting ? '提交中' : '提交'}
|
||||
</Button>
|
||||
</div>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<FeedbackApp />);
|
||||
@@ -0,0 +1,160 @@
|
||||
// 登录 / 购买窗口四步流:扫码登录 → 时长包 → 支付二维码 → 完成
|
||||
// 规格见 doc/frontend-design.html 4.2;二维码内容来自后端,轮询自动推进。
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@dudu/design/styles.css';
|
||||
import { Button } from '@dudu/design/components/core/Button';
|
||||
import { Badge } from '@dudu/design/components/core/Badge';
|
||||
import { invoke } from '../shared/tauri';
|
||||
|
||||
function Center({ children }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14,
|
||||
padding: '26px 24px 30px', fontFamily: 'var(--font-sans)',
|
||||
background: 'var(--bg-app)', minHeight: '100vh', boxSizing: 'border-box',
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 简易二维码占位:真实渲染由 qr 内容字符串绘制(接入 qrcode 库或后端返图)。
|
||||
function QrBox({ value }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 180, height: 180, borderRadius: 'var(--radius-md)', border: '1px solid var(--border-1)',
|
||||
background: 'var(--surface-card)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'var(--text-xs)', color: 'var(--text-3)', padding: 12, textAlign: 'center',
|
||||
wordBreak: 'break-all', overflow: 'hidden',
|
||||
}}>{value || '加载中'}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginApp() {
|
||||
const [step, setStep] = useState('login'); // login | plans | pay | done
|
||||
const [qr, setQr] = useState(null);
|
||||
const [packs, setPacks] = useState([]);
|
||||
const [sel, setSel] = useState('pack_500');
|
||||
const [order, setOrder] = useState(null);
|
||||
const [me, setMe] = useState(null);
|
||||
const timer = useRef(null);
|
||||
|
||||
// 扫码登录:创建二维码 + 轮询
|
||||
useEffect(() => {
|
||||
if (step !== 'login') return undefined;
|
||||
let stop = false;
|
||||
invoke('login_qr_create').then((r) => {
|
||||
if (!r || stop) return;
|
||||
setQr(r);
|
||||
timer.current = setInterval(async () => {
|
||||
const st = await invoke('login_qr_poll', { state: r.state });
|
||||
if (st && st.status === 'confirmed') {
|
||||
clearInterval(timer.current);
|
||||
const m = await invoke('fetch_me');
|
||||
setMe(m);
|
||||
const ps = await invoke('fetch_packs');
|
||||
setPacks(ps || []);
|
||||
setStep('plans');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
return () => { stop = true; clearInterval(timer.current); };
|
||||
}, [step]);
|
||||
|
||||
// 支付轮询
|
||||
useEffect(() => {
|
||||
if (step !== 'pay' || !order) return undefined;
|
||||
const t = setInterval(async () => {
|
||||
const st = await invoke('order_status', { orderId: order.order_id });
|
||||
if (st && st.status === 'paid') {
|
||||
clearInterval(t);
|
||||
setMe(await invoke('fetch_me'));
|
||||
setStep('done');
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [step, order]);
|
||||
|
||||
const pack = packs.find((p) => p.id === sel);
|
||||
|
||||
if (step === 'login') {
|
||||
return (
|
||||
<Center>
|
||||
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>微信扫码登录</div>
|
||||
<QrBox value={qr && qr.qr_url} />
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>打开微信扫一扫,确认后自动登录</div>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (step === 'plans') {
|
||||
return (
|
||||
<Center>
|
||||
<div style={{ alignSelf: 'stretch', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>购买时长</span>
|
||||
{me && <span style={{ display: 'inline-flex', gap: 8, alignItems: 'center', fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>
|
||||
{me.nickname_masked} <Badge tone="blue">{me.account_state === 'ok' ? '余额充足' : '试用中'}</Badge>
|
||||
</span>}
|
||||
</div>
|
||||
<div style={{ alignSelf: 'stretch', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{packs.map((p) => {
|
||||
const active = sel === p.id;
|
||||
return (
|
||||
<button key={p.id} type="button" onClick={() => setSel(p.id)} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center', textAlign: 'left',
|
||||
padding: '13px 16px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
|
||||
border: active ? '1.5px solid var(--accent)' : '1px solid var(--border-1)',
|
||||
background: active ? 'var(--accent-soft)' : 'var(--surface-card)', fontFamily: 'var(--font-sans)',
|
||||
}}>
|
||||
<span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{p.minutes} 分钟</span>
|
||||
{p.tag && <span style={{ fontSize: 10, padding: '1px 7px', borderRadius: 'var(--radius-full)', background: 'var(--warning-soft)', color: 'var(--warning)', fontWeight: 'var(--weight-medium)' }}>{p.tag}</span>}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 3 }}>{p.unit_desc}</span>
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-bold)', color: active ? 'var(--accent-text)' : 'var(--text-1)' }}>
|
||||
¥{p.price_cents / 100}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ alignSelf: 'stretch', fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}>时长不过期 · 每天另有 3 分钟免费试用</div>
|
||||
<Button variant="primary" block disabled={!pack} onClick={async () => {
|
||||
const o = await invoke('create_order', { packId: sel });
|
||||
if (o) { setOrder(o); setStep('pay'); }
|
||||
}}>微信支付 ¥{pack ? pack.price_cents / 100 : '—'}</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (step === 'pay') {
|
||||
return (
|
||||
<Center>
|
||||
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>微信扫码支付</div>
|
||||
<div style={{ fontSize: 'var(--text-3xl)', fontWeight: 'var(--weight-bold)', color: 'var(--text-1)' }}>
|
||||
¥{pack.price_cents / 100}
|
||||
<span style={{ fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-regular)', color: 'var(--text-3)' }}> · {pack.minutes} 分钟时长包</span>
|
||||
</div>
|
||||
<QrBox value={order && order.code_url} />
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>支付完成后自动开通</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setStep('plans')}>返回</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Center>
|
||||
<span style={{
|
||||
width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28,
|
||||
}}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
</span>
|
||||
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>已到账 {pack.minutes} 分钟</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>
|
||||
时长余额 {me ? Math.floor(me.balance_seconds / 60) : '—'} 分钟 · 不过期 · 现在就按住 ⌘⇧Space 试试
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => invoke('close_login')}>开始使用</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<LoginApp />);
|
||||
@@ -0,0 +1,200 @@
|
||||
// 首次启动引导(11F):欢迎 → 权限 → 登录 → 试一试。
|
||||
// 规格见 doc/frontend-design.html 4.3;完成 / 关窗后置 onboarding_done。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@dudu/design/styles.css';
|
||||
import { Button } from '@dudu/design/components/core/Button';
|
||||
import { Badge } from '@dudu/design/components/core/Badge';
|
||||
import { HotkeyCombo } from '@dudu/design/components/core/Kbd';
|
||||
import { invoke } from '../shared/tauri';
|
||||
import { setThemePref } from '../shared/theme';
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().includes('MAC');
|
||||
const STEPS = ['welcome', 'perms', 'login', 'try'];
|
||||
|
||||
function Dots({ step }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 7, justifyContent: 'center' }}>
|
||||
{STEPS.map((s, i) => (
|
||||
<span key={s} style={{
|
||||
width: i === step ? 18 : 6, height: 6, borderRadius: 'var(--radius-full)',
|
||||
background: i === step ? 'var(--accent)' : 'var(--border-2)',
|
||||
transition: 'all var(--dur-base) var(--ease-out)',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Title({ children }) {
|
||||
return <div style={{ fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)', textAlign: 'center' }}>{children}</div>;
|
||||
}
|
||||
|
||||
function Desc({ children }) {
|
||||
return <div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center', lineHeight: 1.7 }}>{children}</div>;
|
||||
}
|
||||
|
||||
// 权限检测卡片:状态徽标 + 去开启按钮
|
||||
function PermCard({ icon, title, desc, granted, onGrant }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px',
|
||||
borderRadius: 'var(--radius-md)', border: '1px solid var(--border-1)', background: 'var(--surface-card)',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 36, height: 36, flex: 'none', borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--accent-soft)', color: 'var(--accent-text)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>{icon}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{title}</span>
|
||||
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-3)', marginTop: 2 }}>{desc}</span>
|
||||
</span>
|
||||
{granted ? <Badge tone="green">已开启</Badge> : <Button variant="secondary" size="sm" onClick={onGrant}>去开启</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MicIcon = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const KeyIcon = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15.5 7.5 3 3L22 7l-3-3"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function OnboardingApp() {
|
||||
const [step, setStep] = useState(0);
|
||||
const [micOk, setMicOk] = useState(false);
|
||||
const [axOk, setAxOk] = useState(!isMac);
|
||||
const [me, setMe] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('get_settings').then((s) => s && setThemePref(s.theme));
|
||||
}, []);
|
||||
|
||||
// 权限步:辅助功能状态轮询(用户去系统设置开启后自动变绿)
|
||||
useEffect(() => {
|
||||
if (STEPS[step] !== 'perms' || !isMac) return undefined;
|
||||
let live = true;
|
||||
const tick = () => invoke('check_accessibility').then((ok) => live && setAxOk(!!ok));
|
||||
tick();
|
||||
const t = setInterval(tick, 1000);
|
||||
return () => { live = false; clearInterval(t); };
|
||||
}, [step]);
|
||||
|
||||
// 登录步:轮询账号,扫码成功后自动亮起
|
||||
useEffect(() => {
|
||||
if (STEPS[step] !== 'login') return undefined;
|
||||
let live = true;
|
||||
const tick = () => invoke('fetch_me').then((m) => live && m && setMe(m));
|
||||
tick();
|
||||
const t = setInterval(tick, 1500);
|
||||
return () => { live = false; clearInterval(t); };
|
||||
}, [step]);
|
||||
|
||||
const next = () => setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
||||
const prev = () => setStep((s) => Math.max(s - 1, 0));
|
||||
const finish = () => invoke('finish_onboarding');
|
||||
|
||||
let body = null;
|
||||
let primary = null;
|
||||
const name = STEPS[step];
|
||||
|
||||
if (name === 'welcome') {
|
||||
body = (
|
||||
<>
|
||||
<span style={{
|
||||
width: 64, height: 64, borderRadius: 18, background: 'var(--accent)', color: 'var(--text-on-accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto',
|
||||
}}>
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>
|
||||
</svg>
|
||||
</span>
|
||||
<Title>dudu 语音输入</Title>
|
||||
<Desc>按住快捷键说话,松开文字就出现在光标处<br />全程比打字快三倍</Desc>
|
||||
</>
|
||||
);
|
||||
primary = <Button variant="primary" block onClick={next}>继续</Button>;
|
||||
} else if (name === 'perms') {
|
||||
body = (
|
||||
<>
|
||||
<Title>先开两个权限</Title>
|
||||
<Desc>{isMac ? '麦克风用来听你说话,辅助功能让文字上屏' : '麦克风用来听你说话'}</Desc>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 4 }}>
|
||||
<PermCard icon={MicIcon} title="麦克风" desc="录入你的声音"
|
||||
granted={micOk}
|
||||
onGrant={() => invoke('request_microphone').then((ok) => setMicOk(!!ok))} />
|
||||
{isMac && (
|
||||
<PermCard icon={KeyIcon} title="辅助功能" desc="把识别结果输入到任意应用"
|
||||
granted={axOk}
|
||||
onGrant={() => invoke('open_accessibility_settings')} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
primary = <Button variant="primary" block onClick={next}>{micOk && axOk ? '下一步' : '稍后开启,先继续'}</Button>;
|
||||
} else if (name === 'login') {
|
||||
body = (
|
||||
<>
|
||||
<Title>登录同步时长</Title>
|
||||
<Desc>微信扫码登录,购买的时长跟着账号走<br />也可以先试用,每天免费 3 分钟</Desc>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 4 }}>
|
||||
{me ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 'var(--text-sm)', color: 'var(--text-1)' }}>
|
||||
{me.nickname_masked} <Badge tone="green">已登录</Badge>
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="secondary" onClick={() => invoke('open_login')}>微信扫码登录</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
primary = <Button variant="primary" block onClick={next}>{me ? '下一步' : '先试用,跳过'}</Button>;
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
<Title>试一试</Title>
|
||||
<Desc>点一下下面的输入框,按住快捷键说句话,松开看看</Desc>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<HotkeyCombo keys={['⌘', '⇧', 'Space']} />
|
||||
</div>
|
||||
<textarea
|
||||
autoFocus
|
||||
placeholder="文字会出现在这里"
|
||||
style={{
|
||||
width: '100%', height: 88, resize: 'none', boxSizing: 'border-box',
|
||||
padding: '10px 12px', borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--border-1)', background: 'var(--surface-card)',
|
||||
color: 'var(--text-1)', fontSize: 'var(--text-base)', fontFamily: 'var(--font-sans)',
|
||||
lineHeight: 1.6, outline: 'none',
|
||||
}} />
|
||||
</>
|
||||
);
|
||||
primary = <Button variant="primary" block onClick={finish}>开始使用</Button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sans)', background: 'var(--bg-app)', minHeight: '100vh',
|
||||
boxSizing: 'border-box', padding: '34px 44px 24px',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 14 }}>
|
||||
{body}
|
||||
</div>
|
||||
<Dots step={step} />
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
{step > 0 && <Button variant="ghost" onClick={prev}>上一步</Button>}
|
||||
<span style={{ flex: 1 }}>{primary}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<OnboardingApp />);
|
||||
@@ -0,0 +1,53 @@
|
||||
// 识别浮层窗口:深色玻璃、不抢焦点;状态完全由 Rust 事件驱动。
|
||||
// listening / text 两态 + 错误态文案,规格见 doc/frontend-design.html 5.1/5.2
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@dudu/design/styles.css';
|
||||
import { RecognitionOverlay } from '@dudu/design/components/voice/RecognitionOverlay';
|
||||
import { listen } from '../shared/tauri';
|
||||
|
||||
const ERR_TEXT = {
|
||||
QUOTA_EXCEEDED: '今日试用已用完,去购买时长',
|
||||
RATE_LIMITED: '操作过于频繁,稍后再试',
|
||||
SESSION_LIMIT: '单次最长 3 分钟,松开后可继续',
|
||||
ASR_UNAVAILABLE: '识别服务暂不可用,稍后再试',
|
||||
NETWORK: '连接中断,松开重试',
|
||||
NO_ACCESSIBILITY: '需要辅助功能权限才能上屏,去系统设置开启 dudu',
|
||||
};
|
||||
|
||||
function OverlayApp() {
|
||||
const [finalText, setFinalText] = useState('');
|
||||
const [partialText, setPartialText] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const offs = [];
|
||||
listen('hotkey', ({ payload }) => {
|
||||
if (payload.state === 'down') { setFinalText(''); setPartialText(''); setError(''); }
|
||||
}).then((off) => offs.push(off));
|
||||
listen('asr', ({ payload }) => {
|
||||
if (payload.type === 'partial') setPartialText(payload.text);
|
||||
else if (payload.type === 'final') { setFinalText((f) => f + payload.text); setPartialText(''); }
|
||||
else if (payload.type === 'error') setError(ERR_TEXT[payload.code] || payload.message || '');
|
||||
}).then((off) => offs.push(off));
|
||||
return () => offs.forEach((off) => off());
|
||||
}, []);
|
||||
|
||||
const hasText = finalText || partialText;
|
||||
return (
|
||||
<div style={{ padding: 8 }}>
|
||||
{error ? (
|
||||
<RecognitionOverlay state="text" finalText="" partialText={error} hint="dudu" />
|
||||
) : (
|
||||
<RecognitionOverlay
|
||||
state={hasText ? 'text' : 'listening'}
|
||||
finalText={finalText}
|
||||
partialText={partialText}
|
||||
hint="松开 ⌘⇧Space 完成输入"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<OverlayApp />);
|
||||
@@ -0,0 +1,134 @@
|
||||
// dudu 设置窗口 — 四分组(输入 / 通用 / 账号与时长 / 帮助),规格见 doc/frontend-design.html 4.2
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@dudu/design/components/core/Button';
|
||||
import { Badge } from '@dudu/design/components/core/Badge';
|
||||
import { HotkeyCombo } from '@dudu/design/components/core/Kbd';
|
||||
import { ProgressBar } from '@dudu/design/components/core/ProgressBar';
|
||||
import { Switch } from '@dudu/design/components/forms/Switch';
|
||||
import { SettingRow } from '@dudu/design/components/forms/SettingRow';
|
||||
import { invoke } from '../shared/tauri';
|
||||
import { setThemePref } from '../shared/theme';
|
||||
|
||||
const THEME_OPTIONS = [['system', '跟随系统'], ['light', '浅色'], ['dark', '深色']];
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<div style={{ padding: '4px 18px 8px' }}>
|
||||
<div style={{
|
||||
fontSize: 'var(--text-xs)', color: 'var(--text-3)', fontWeight: 'var(--weight-medium)',
|
||||
margin: '10px 0 2px', letterSpacing: 'var(--tracking-wide)',
|
||||
}}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Sep = () => <div style={{ height: 1, background: 'var(--border-1)' }}></div>;
|
||||
|
||||
export function SettingsApp() {
|
||||
const [settings, setSettings] = useState({
|
||||
hotkey: 'CmdOrCtrl+Shift+Space', mic: '', sound: false, autostart: true, theme: 'system',
|
||||
});
|
||||
const [mics, setMics] = useState([]);
|
||||
const [me, setMe] = useState(null); // null = 未登录
|
||||
|
||||
useEffect(() => {
|
||||
invoke('get_settings').then((s) => { if (s) { setSettings(s); setThemePref(s.theme); } });
|
||||
invoke('list_mic_devices').then((d) => d && setMics(d));
|
||||
invoke('fetch_me').then((m) => m && setMe(m));
|
||||
}, []);
|
||||
|
||||
const update = (patch) => {
|
||||
const next = { ...settings, ...patch };
|
||||
setSettings(next);
|
||||
invoke('set_settings', { settings: next });
|
||||
if (patch.theme) setThemePref(patch.theme);
|
||||
};
|
||||
|
||||
const minutes = me ? Math.floor(me.balance_seconds / 60) : 0;
|
||||
const trialUsedMin = me ? Math.floor(me.trial_used_today / 60) : 0;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'var(--font-sans)', background: 'var(--bg-app)', minHeight: '100vh' }}>
|
||||
<Section title="输入">
|
||||
<SettingRow label="说话快捷键" description="按住说话,松开上屏">
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
<HotkeyCombo keys={['⌘', '⇧', 'Space']} />
|
||||
<Button variant="ghost" size="sm">修改</Button>
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="麦克风">
|
||||
<select
|
||||
value={settings.mic}
|
||||
onChange={(e) => update({ mic: e.target.value })}
|
||||
style={{
|
||||
height: 'var(--control-sm)', padding: '0 10px', borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-1)', background: 'var(--surface-card)',
|
||||
color: 'var(--text-1)', fontSize: 'var(--text-sm)', fontFamily: 'var(--font-sans)',
|
||||
}}>
|
||||
<option value="">系统默认</option>
|
||||
{mics.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="提示音" description="开始 / 完成时播放轻提示音">
|
||||
<Switch checked={settings.sound} onChange={(v) => update({ sound: v })} />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
<Sep />
|
||||
<Section title="通用">
|
||||
<SettingRow label="开机自启">
|
||||
<Switch checked={settings.autostart} onChange={(v) => update({ autostart: v })} />
|
||||
</SettingRow>
|
||||
<SettingRow label="外观">
|
||||
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
|
||||
{THEME_OPTIONS.map(([val, lab]) => (
|
||||
<button key={val} type="button" onClick={() => update({ theme: val })} style={{
|
||||
height: 24, padding: '0 10px', borderRadius: 4, border: 'none', cursor: 'pointer',
|
||||
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
|
||||
background: settings.theme === val ? 'var(--surface-card)' : 'transparent',
|
||||
color: settings.theme === val ? 'var(--text-1)' : 'var(--text-2)',
|
||||
boxShadow: settings.theme === val ? 'var(--shadow-card)' : 'none',
|
||||
}}>{lab}</button>
|
||||
))}
|
||||
</span>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
<Sep />
|
||||
<Section title="账号与时长">
|
||||
<SettingRow label="登录状态" description={me ? '微信已登录' : '登录后开始使用'}>
|
||||
{me ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 'var(--text-sm)', color: 'var(--text-1)' }}>
|
||||
{me.nickname_masked}
|
||||
<Badge tone={me.account_state === 'quota' ? 'orange' : 'green'}>
|
||||
{me.account_state === 'ok' ? '余额充足' : me.account_state === 'trial' ? '试用中' : '余额不足'}
|
||||
</Badge>
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" onClick={() => invoke('open_login')}>微信登录</Button>
|
||||
)}
|
||||
</SettingRow>
|
||||
<SettingRow label="时长余额" description="时长不过期 · 每天另有 3 分钟免费试用">
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 'var(--text-base)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>
|
||||
{minutes} 分钟
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" onClick={() => invoke('open_login')}>购买时长</Button>
|
||||
</span>
|
||||
</SettingRow>
|
||||
<div style={{ padding: '10px 0 12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginBottom: 7 }}>
|
||||
<span>今日免费试用</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{trialUsedMin} / 3 分钟</span>
|
||||
</div>
|
||||
<ProgressBar value={trialUsedMin} max={3} />
|
||||
</div>
|
||||
</Section>
|
||||
<Sep />
|
||||
<Section title="帮助">
|
||||
<SettingRow label="反馈问题" description="遇到问题或建议,告诉我们">
|
||||
<Button variant="ghost" size="sm" onClick={() => invoke('open_feedback')}>去反馈</Button>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@dudu/design/styles.css';
|
||||
import { SettingsApp } from './SettingsApp';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<SettingsApp />);
|
||||
@@ -0,0 +1,14 @@
|
||||
// Tauri 桥封装:在纯浏览器(vite dev 预览设计稿)环境下安全降级为 no-op。
|
||||
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
|
||||
export async function invoke(cmd, args) {
|
||||
if (!inTauri) return null;
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
return invoke(cmd, args);
|
||||
}
|
||||
|
||||
export async function listen(event, handler) {
|
||||
if (!inTauri) return () => {};
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
return listen(event, handler);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// 外观三态:system | light | dark;驱动根节点 data-theme(设计系统约定)。
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
let pref = 'system';
|
||||
|
||||
function apply() {
|
||||
const dark = pref === 'dark' || (pref === 'system' && media.matches);
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
export function setThemePref(p) {
|
||||
pref = p;
|
||||
apply();
|
||||
}
|
||||
|
||||
media.addEventListener('change', apply);
|
||||
apply();
|
||||
@@ -0,0 +1,66 @@
|
||||
// 自绘托盘菜单(248px、blur、hover accent 反白),规格见 doc/frontend-design.html 4.2
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@dudu/design/styles.css';
|
||||
import { Badge } from '@dudu/design/components/core/Badge';
|
||||
import { invoke, listen } from '../shared/tauri';
|
||||
|
||||
function Item({ children, right, onClick }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 18,
|
||||
padding: '7px 14px', cursor: 'default', fontSize: 'var(--text-sm)',
|
||||
background: hover ? 'var(--accent)' : 'transparent',
|
||||
color: hover ? 'var(--text-on-accent)' : 'var(--text-1)',
|
||||
transition: 'background var(--dur-fast) var(--ease-out)',
|
||||
}}>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{children}</span>
|
||||
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'rgba(255,255,255,.8)' : 'var(--text-3)' }}>{right}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Sep = () => <div style={{ height: 1, background: 'var(--border-1)', margin: '4px 0' }}></div>;
|
||||
|
||||
function TrayApp() {
|
||||
const [me, setMe] = useState(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
useEffect(() => {
|
||||
invoke('fetch_me').then((m) => m && setMe(m));
|
||||
invoke('is_paused').then((p) => setPaused(!!p));
|
||||
listen('account', ({ payload }) => setMe(payload));
|
||||
}, []);
|
||||
const minutes = me ? Math.floor(me.balance_seconds / 60) : 0;
|
||||
return (
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sans)', padding: '5px 0',
|
||||
background: 'color-mix(in srgb, var(--surface-card) 96%, transparent)',
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid var(--border-1)', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-menu)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 14px 6px' }}>
|
||||
<span style={{ fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>dudu 语音输入</span>
|
||||
<Badge tone={paused ? 'orange' : 'green'}>{paused ? '已暂停' : '已就绪'}</Badge>
|
||||
</div>
|
||||
<Sep />
|
||||
<Item right="⌘⇧Space">按住说话</Item>
|
||||
<Item onClick={() => invoke('toggle_pause').then((p) => setPaused(!!p))}>
|
||||
{paused ? '继续使用' : '暂停使用'}
|
||||
</Item>
|
||||
<Item onClick={() => invoke('open_settings')}>设置…</Item>
|
||||
<Item right={me ? `${minutes} 分钟` : '—'}>时长余额</Item>
|
||||
<Sep />
|
||||
<Item right={me ? '已登录' : '未登录'} onClick={() => invoke('open_login')}>
|
||||
{me ? `账号 ${me.nickname_masked}` : '微信登录'}
|
||||
</Item>
|
||||
<Item onClick={() => invoke('check_update')}>检查更新</Item>
|
||||
<Sep />
|
||||
<Item onClick={() => invoke('quit_app')}>退出 dudu</Item>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<TrayApp />);
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>dudu</title>
|
||||
<style>html, body { margin: 0; background: transparent; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/tray/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// 多窗口入口:settings(主/设置)、overlay(识别浮层)、tray(自绘托盘菜单)、login(登录购买)、
|
||||
// feedback(反馈问题)、onboarding(首次启动引导)
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// 设计系统直接以源码包形式复用(@dudu/design → ../design)
|
||||
'@dudu/design': resolve(__dirname, '../design'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
settings: resolve(__dirname, 'index.html'),
|
||||
overlay: resolve(__dirname, 'overlay.html'),
|
||||
tray: resolve(__dirname, 'tray.html'),
|
||||
login: resolve(__dirname, 'login.html'),
|
||||
feedback: resolve(__dirname, 'feedback.html'),
|
||||
onboarding: resolve(__dirname, 'onboarding.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
server: { port: 5181, strictPort: true },
|
||||
});
|
||||
Reference in New Issue
Block a user