19 Commits

Author SHA1 Message Date
wangjia 161c7fa0e8 fix(desktop): 识别浮层浮在其他 App 全屏之上——改 Accessory 激活策略
ci / server (push) Failing after 13s
ci / design-system (push) Failing after 12s
标准 NSWindow 靠 collectionBehavior 无法叠加到别的 App 的全屏 Space(macOS 设计限制,Apple 论坛 26677 / tauri#11488;FullScreenAuxiliary 实为反效果)。确认可用解法是把 app 激活策略设为 Accessory(菜单栏辅助 App)——dudu 本就是托盘应用无主窗口,不占 Dock 正是其正确形态。配合 CanJoinAllSpaces,浮层即可显示在全屏 App 上。configure_overlay_spaces 改为每次 show 时在主线程重设 + 文件诊断(确认 behavior 0x111/level 已设上)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:05:34 +08:00
wangjia f8d3e0d1e4 fix(desktop): 修尾部音频截断——控制帧改由转发线程排空音频后补发
ci / server (push) Failing after 11s
ci / design-system (push) Failing after 12s
松开时 capture.take() 异步停采集,通道里可能还缓冲着最后几帧未发;原 stop() 主线程立即 ws.send(Stop) 会抢在这几帧前入队,网关收到 stop 即关会话、丢弃晚到音频,表现为最后约半秒偶发不识别。改为:stop() 只写 canceled 标志并停采集,由音频转发线程排空通道后再发 Stop/Cancel,保证控制帧排在所有音频帧之后(同一 WS 写入队列,FIFO)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:36:27 +08:00
wangjia 4b50ff2ce6 fix(desktop): 识别浮层浮在全屏 App 之上(FullScreenAuxiliary)
ci / server (push) Failing after 11s
ci / design-system (push) Failing after 11s
仅 alwaysOnTop 时,全屏终端/浏览器独占 Space,浮层不显示。启动时给浮层 NSWindow 追加 CanJoinAllSpaces|Stationary|FullScreenAuxiliary 集合行为(objc2 直设,主线程),使其作为辅助窗叠加到全屏 Space 上。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:57:00 +08:00
wangjia ccce39a83a fix(desktop): 文字注入崩溃——粘贴键用物理键码避开主线程限制的布局查询
ci / server (push) Failing after 11s
ci / design-system (push) Failing after 11s
enigo 的 Key::Unicode('v') 会调 TSMGetInputSourceProperty 查键盘布局,该 API 必须主线程执行;注入跑在 tokio 后台线程上,macOS 15/26 收紧主线程校验后触发断言崩溃(松开说话、识别成功后注入即崩)。改用 kVK_ANSI_V 物理键码 Key::Other(9),走 CGEvent 无需布局查询,与键盘布局无关。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:44:54 +08:00
wangjia 4ec294be40 docs+chore: 补 CLAUDE.md(联调启动 + 真相源约定)与 desktop/run-dev.sh
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 11s
(此前误报已完成实为未提交,现真实补上。)
- desktop/run-dev.sh:桌面端启动脚本,对齐 server/run-dev.sh,含麦克风权限提示
- CLAUDE.md:两条联调启动命令 + 麦克风/邮箱登录踩坑 + 真相源与闸约定 +
  透明窗口范式 + TODO 约定

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:02:53 +08:00
wangjia 3e292ec1e0 feat(web): 官网扩充改版——特性 6 项 + 场景/隐私/FAQ 三大板块
ci / server (push) Failing after 11s
ci / design-system (push) Failing after 11s
在既有克制视觉语言(单一蓝 · 无渐变 · 1px 边线分隔 · Outfit 数字/西文)
内做精并补足信息量,所有色值走 tokens,check-code 闸通过:

- Hero 加资质 eyebrow(五端一套账号·时长永不过期)
- 指标带 3→4 项(首字延迟 / 5 端 / 3 分钟每天 / 时长不过期)
- 特性 3→6(新增 上滑取消 / 浮层不抢焦点 / 中英混说)
- 新增「使用场景」4 卡(写作·消息·代码·会议,各带你说→上屏示例 + 3× 提速角标)
- 新增「隐私与信任」深色带 4 点(只按住时录音 / 音频用完即弃 / 只往光标写 / 透明计费)
- 平台区改信息卡片(桌面/手机/平板,各附一句能力说明)
- 定价卡加权益清单 + 热门档强调
- 新增「常见问题」8 条 details 手风琴(联网/隐私/准确率/跨端时长/兼容/额度/对比/方言)
- 页脚扩为四栏(产品·购买·支持 + 品牌介绍 + 运营主体)
- 双主题 + 移动窄屏截图验收通过;reduced-motion / focus-visible 已覆盖

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:01:58 +08:00
wangjia cef58e658b fix(desktop): 补 NSMicrophoneUsageDescription(macOS 打包版麦克风授权)
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 11s
缺该键时 macOS 不弹授权框、直接返回静音——真机按住说话有会话但识别不出内容(audio_seconds 有值而 provider_ms=0)。Tauri 2 打包自动合并本 Info.plist。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:48:41 +08:00
wangjia 8266c575aa fix(auth): 固定验证码改为显式 AUTH_EMAIL_DEV_CODE 环境开关(安全评审反馈)
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 10s
不再从 mailer 运行时类型推导认证材料:固定码仅当显式设置
AUTH_EMAIL_DEV_CODE 时生效(run-dev.sh 设 888888,生产部署不经
该脚本即为随机码),设置时启动打印醒目告警。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:42:11 +08:00
wangjia 7dccd694e0 feat(auth): mock 邮件模式验证码固定 888888(开发期便利,上线阻断项已标注)
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:40:19 +08:00
wangjia ab6351ebb3 fix(desktop): vite dev fs 白名单放行 ../design(Outfit 字体 404 降级问题)
ci / server (push) Failing after 11s
ci / design-system (push) Failing after 12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:32:47 +08:00
wangjia 19281d9c42 feat(auth+desktop): 邮箱验证码登录 + 登录窗改版(微信/邮箱双方式,无边框)
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 10s
后端(协议 → 存储 → 接口,E2E 测试全绿):
- protocol:AuthEmailCodeRequest / AuthEmailRequest DTO
- store:EmailIdentity 表(邮箱唯一索引)+ authmail:* Redis key
- auth/email.go:POST /v1/auth/email/code 发 6 位码(crypto/rand,
  60s 冷却 SetNX,10 分钟 TTL);POST /v1/auth/email 常量时间比对、
  码一次性、邮箱建号(昵称取前缀)→ JWT(与微信登录同响应形态)
- Mailer 接口 + MockMailer(验证码打日志供联调;SMTP 未配置自动降级,
  装配结果入启动日志,上线前须换真实实现)
- TestEmailLogin E2E:发码/冷却 429/错码拒绝/登录/token 可用/码防复用

桌面(Rust 命令 + UI 改版):
- api.rs:login_email_code / login_email(成功保存 token + ws 重连,
  与扫码登录同路径)
- 登录窗改版(原型 LoginPurchase 先行,dark 截图验收):logo + 分段
  切换(微信扫码 / 邮箱登录,与设置页同控件语言)+ 邮箱表单
  (60s 倒计时、错误文案、未注册自动建号提示)
- 二维码真渲染:qrcode 库画 canvas(前景/背景取主题 token),
  替换原 URL 文本占位;过期态半透明化
- 登录窗无边框化:transparent + Overlay 标题栏 + 原生贴形阴影;
  抽公共 WindowFrame 组件(设置窗同步重构复用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:40:16 +08:00
wangjia 0ca1caa460 feat(design+desktop): 设置屏改版——卡片分组布局(12A 实测反馈,原型先行)
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 12s
原布局「灰字组标题 + 通栏细线」平铺无体块感、行距不均、控件对齐参差。
改版(原型 SettingsTray 与实现 SettingsApp 同步):

- 分组卡片化:组标题在卡外(12px text-3 加字距),每组一张 radius-lg
  卡片(复用 Card 组件),卡内行以内嵌分隔线相隔,页面 bg-app 底上
  卡片体块清晰
- 行规格统一:SettingRow 复用,右侧控件统一右缘对齐
- 时长余额改大数字排版(text-xl bold + 单位小字基线对齐)
- 载入动效:四组 40ms 级差上浮淡入(对齐浮层动效语言,快入慢出)
- 顶条标题绝对居中(mac 原生习惯),与红绿灯永不重叠;原型 MacWindow
  同步改居中
- 原型补齐「帮助」组与实现对位

双主题截图验收通过;三道闸全绿;vite 构建通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:09:12 +08:00
wangjia 7d155fa2de fix(desktop): trafficLightPosition y 按按钮中心语义调为 18,与顶条标题垂直对齐
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 08:53:07 +08:00
wangjia 559e224301 fix(desktop): 设置窗口尺寸回归内容实际大小 + 原生贴形阴影 + 红绿灯对齐 + 显式拖拽权限(12A 实测反馈)
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 11s
- 去掉 16px CSS 阴影边距方案(窗口比观感大、外圈灰影、点击区域错位):
  transparent 窗口开 macOS 原生阴影,阴影贴合内容圆角,窗口 440x560
  即卡片本体
- trafficLightPosition {16,12}:红绿灯垂直居中于 36px 自绘顶条,与
  「dudu 设置」标题对齐
- capabilities 显式加 core:window:allow-start-dragging,保证
  data-tauri-drag-region 拖拽可用

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 07:52:23 +08:00
wangjia b914aebb00 fix(desktop): 设置窗口透明化 + 自绘 MacWindow 框,去掉原生白边(12A 实测反馈)
ci / server (push) Failing after 11s
ci / design-system (push) Failing after 12s
Overlay 标题栏模式下原生 NSWindow 底色在深色内容四周露出白边。改为
与托盘菜单一致的模式:窗口 transparent + shadow:false,由页面自绘
窗框(对齐原型 MacWindow 规格:radius-md + border-1 + shadow-window,
16px 阴影边距,窗口尺寸相应 472x592),红绿灯 trafficLightPosition
对齐自绘顶条,内容区独立滚动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 07:47:19 +08:00
wangjia adfa1ef1e4 fix(desktop): 开启 macOSPrivateApi,透明窗口不再画白底(12A 实测反馈)
ci / server (push) Failing after 11s
ci / design-system (push) Failing after 12s
识别浮层/托盘菜单窗口虽已 transparent:true 且页面背景透明,但 macOS
上 WKWebView 需要 macOSPrivateApi 才能真透明,否则渲染白底。Cargo
侧 macos-private-api feature 已就位,仅补 conf 开关。不走 App Store
分发,私有 API 可用。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 07:40:40 +08:00
wangjia 911ceca079 feat(design+desktop): Select 真相源组件 + 设置窗口无边框(12A 实测反馈)
ci / server (push) Failing after 11s
ci / design-system (push) Failing after 12s
Select 组件(用户拍板:下拉框全端共用一个真相源):
- design/components/forms/Select.jsx:自绘下拉——触发器同 Input
  (control-sm/radius-sm/border-1 + chevronDown),弹层同菜单
  (radius-md/shadow-menu、选中项前置对勾、hover surface-2),
  外点/Esc 收起;登记簿加卡;icons.js 新增 chevronDown 并登记 icon-map
- 原型 SettingsTray 与桌面 SettingsApp 同步换用(替换原生 select——
  其系统弹层样式/位置不可控,即 12A 截图问题)

原型组件运行时可再生(修复单源缺口):
- design-pipeline/bundle-components.mjs:用 desktop 现成 esbuild 重建
  _ds_bundle.js(此前为 Claude 导出产物、仓库内无法再生,组件一改即
  过期);import 'react' 经全局 shim,命名空间不变;已实测原型页正常
- _ds_bundle.js 重建(15 个组件,含 Select/Icon)

设置窗口无边框:
- tauri.conf settings 窗口 titleBarStyle=Overlay + hiddenTitle,
  系统红绿灯悬浮左上;SettingsApp 自绘顶条(surface-2 + 下边框 +
  data-tauri-drag-region 可拖拽),对齐原型 MacWindow 规格

合并 feat/design-truth-source(真相源与闸体系)进本分支

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:00:29 +08:00
wangjia ddedef9605 Merge branch 'feat/design-truth-source' into worktree-latency-probe-12b 2026-07-11 00:55:05 +08:00
wangjia c9823ce454 fix(desktop): 托盘菜单左缘对齐图标、去掉垂直间隙(12A 实测反馈)
ci / server (push) Failing after 9s
ci / design-tokens (push) Failing after 11s
此前菜单水平居中于托盘图标(观感偏左)且下方留 4px 间隙。改为
macOS 系统菜单习惯:左缘对齐图标左缘、紧贴菜单栏;菜单超出屏幕
右缘时整体左收 8px 边距。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:41:41 +08:00
46 changed files with 2257 additions and 2678 deletions
+37
View File
@@ -0,0 +1,37 @@
# dudu — Claude 项目约定
五端语音输入法:Go 后端 + 桌面(Tauri 2)/Android/iOS/官网。按住快捷键说话 → 流式识别 → 上屏;时长计费,微信/邮箱登录。
## 本地联调启动(两个终端)
前置:`cd server && docker compose up -d`(起 pg/redis);`rbw unlock`(取 DashScope key)。
```bash
# 终端 A — 后端 API(有 DashScope key 走真实 gummy,无则 mockmock 邮件固定验证码 888888
cd server && ./run-dev.sh
# 终端 B — 桌面端(必须从你自己的终端前台跑,麦克风权限才归属该终端)
cd desktop && ./run-dev.sh
```
- dudu 是**托盘应用无主窗口**:菜单栏找蓝底声纹图标,设置/登录从托盘菜单点出。
- **麦克风**dev 跑裸二进制 `target/debug/dudu-desktop`,权限归属父终端。首次没声音 → 系统设置 → 隐私与安全性 → 麦克风 勾选你的终端 → 完全退出 dudu 重启(TCC 变更需重启进程)。打包版由 `src-tauri/Info.plist``NSMicrophoneUsageDescription` 触发系统授权框。
- **邮箱登录**:登录窗切「邮箱登录」→ 任意邮箱 → 发送验证码 → 填 **888888**(仅 `AUTH_EMAIL_DEV_CODE` 设置时固定,`run-dev.sh` 已设;生产随机)。
## 真相源(改前必读)
- **协议**:只改 `server/pkg/protocol`,五端对照实现。
- **界面**`design/` 是唯一真相源,改 UI 前读登记簿 `design/index.html`。新颜色/组件/图标先登记 `design/tokens` | `components`+`index.html` | `icons.js`+`icon-map.json`,再写端代码。业务代码禁字面色(用 `var(--*)`/`DuduTheme`)、禁内联 `<path>`(走 `Icon`)、禁 emoji。
- **令牌 codegen**`design/tokens` 变更后 `node design-pipeline/export-tokens.mjs`(直写 ios/android/web 消费位置)并提交产物。
- **组件源码变更后**`node design-pipeline/bundle-components.mjs` 重建原型运行时 `design/_ds_bundle.js` 并提交。
- **防漂移闸**pre-commit + CI,启用:`git config core.hooksPath .githooks`):
`node design-pipeline/check-ds.mjs`(真相源自检)· `check-code.mjs`(代码侧单源)· `export-tokens.mjs --check`(产物零 diff)。
- 全景说明:`doc/frontend-overview.html`;改窗口先看 `design/ui_kits/desktop/`(原型先行)。
## 窗口范式(桌面)
透明无边框窗口共用 `desktop/src/shared/WindowFrame.jsx``transparent` + `titleBarStyle:Overlay` + `hiddenTitle` + `trafficLightPosition {18,18}` + macOS 原生阴影;页面自绘圆角边框。识别浮层/托盘/设置/登录均为此范式。**必须开 `macOSPrivateApi:true`**,否则透明窗口画白底。
## TODO
经全局 `/todo` skill 管理(看板 `todo/todo.html`),状态 open→doing→done→(用户)accepted。**禁用内置 TodoWrite。**
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* bundle-components.mjs — 重建 design/_ds_bundle.js(原型页组件运行时)
*
* ui_kits 预览页经 <script src="../../_ds_bundle.js"> 消费组件;该文件原为
* Claude 设计导出产物、仓库内无法再生,组件源码一改即过期。本脚本用
* desktop 现成的 esbuild(不新增依赖)从 design/components/ 重新打包:
* - JSX → React.createElement(运行时用页面全局 Reactunpkg UMD
* - import 'react' → 全局 React shim(组件源码保持 ESM 规范,vite 侧不受影响)
* - 导出统一挂到 window.DuduDesignSystem_2e0172(与原命名空间一致)
*
* 用法:node design-pipeline/bundle-components.mjs
* 组件源码(components/ icons.js)变更后须重跑并提交产物。
*/
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import { resolve, dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const __dir = dirname(fileURLToPath(import.meta.url));
const DESIGN = resolve(__dir, '../design');
const require = createRequire(resolve(__dir, '../desktop/package.json'));
const esbuild = require('esbuild');
// 收集全部组件导出(export function/const 大写开头)
function walk(dir, out = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, out);
else if (name.endsWith('.jsx')) out.push(p);
}
return out;
}
const imports = [];
const names = [];
for (const f of walk(join(DESIGN, 'components'))) {
const src = readFileSync(f, 'utf8');
const exported = [...src.matchAll(/export\s+(?:function|const)\s+([A-Z]\w+)/g)].map((m) => m[1]);
if (!exported.length) continue;
imports.push(`import { ${exported.join(', ')} } from './${relative(DESIGN, f)}';`);
names.push(...exported);
}
const entry = `${imports.join('\n')}
const ns = (globalThis.DuduDesignSystem_2e0172 = globalThis.DuduDesignSystem_2e0172 || {});
Object.assign(ns, { ${names.join(', ')} });
`;
// import 'react' → 页面全局 Reactunpkg UMD
const reactShim = {
name: 'react-global-shim',
setup(build) {
build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: 'react-shim' }));
build.onLoad({ filter: /.*/, namespace: 'react-shim' }, () => ({
contents: `const R = globalThis.React;
export default R;
export const useState = R.useState, useEffect = R.useEffect, useRef = R.useRef,
useMemo = R.useMemo, useCallback = R.useCallback, useContext = R.useContext,
createElement = R.createElement, Fragment = R.Fragment;`,
loader: 'js',
}));
},
};
const result = await esbuild.build({
stdin: { contents: entry, resolveDir: DESIGN, loader: 'js', sourcefile: '_ds_entry.js' },
bundle: true,
format: 'iife',
jsx: 'transform',
jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment',
plugins: [reactShim],
write: false,
banner: {
js: `/* 自动生成 — 请勿手改。来源 design/components/ + icons.js
重新生成:node design-pipeline/bundle-components.mjs
运行时依赖:页面全局 Reactui_kits 页先加载 unpkg UMD React)。
导出命名空间:window.DuduDesignSystem_2e0172(组件 ${names.length} 个)*/`,
},
});
writeFileSync(join(DESIGN, '_ds_bundle.js'), result.outputFiles[0].text, 'utf8');
console.log(`✅ design/_ds_bundle.js${names.length} 个组件:${names.join(', ')}`);
+1
View File
@@ -20,4 +20,5 @@ Hard rules: 一屏一个蓝色主按钮;无渐变;无 emoji;浮层永远
- **业务代码禁止**:字面色(hex/rgb/hsl,一律 var(--*) / DuduTheme)、内联 `<path>`(走 `Icon` 组件)、未登记的 SF Symbol。确属例外加行内 `ds-ignore: 理由`
- **闸**`node design-pipeline/check-ds.mjs`(真相源自检 7 道)· `check-code.mjs`(四端代码侧)· `export-tokens.mjs --check`(产物零 diff)。pre-commit`git config core.hooksPath .githooks` 启用)与 CI 双挂。
- **预览评审**`node design/serve.mjs` → http://localhost:5180(登记簿 index.html 与 ui_kits)。
- **组件源码(components/ 或 icons.js)变更后**`node design-pipeline/bundle-components.mjs` 重建原型运行时 `_ds_bundle.js` 并随改动提交(ui_kits 页消费的是该 bundle)。
- 全景说明:`doc/frontend-overview.html`
+347 -2371
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'onChange'> {
/** 当前值(受控) */
value: string;
onChange?: (value: string) => void;
/** 字符串数组或 { value, label } 数组 */
options?: Array<string | SelectOption>;
/** value 无匹配项时的显示文案 */
placeholder?: string;
}
export declare function Select(props: SelectProps): React.JSX.Element;
+110
View File
@@ -0,0 +1,110 @@
// Select:自绘下拉选择器(真相源组件,全端外观基准)。
// 原生 <select> 的弹层是系统样式且位置不可控——设计语言要求:触发器同 Input
// control-sm / radius-sm / border-1),弹层同菜单(radius-md / shadow-menu),
// 选中项前置对勾。iOS/Android 以本组件登记规格 + 各自 DuduTheme 镜像实现。
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../core/Icon';
const ddSelectCss = `
.dd-select { position: relative; display: inline-block; font-family: var(--font-sans); }
.dd-select__trigger {
display: inline-flex; align-items: center; gap: 6px;
height: var(--control-sm); padding: 0 26px 0 10px;
border-radius: var(--radius-sm); border: 1px solid var(--border-1);
background: var(--surface-card); color: var(--text-1);
font-size: var(--text-sm); font-family: inherit; cursor: pointer;
transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
white-space: nowrap;
}
.dd-select__trigger:hover { background: var(--surface-2); }
.dd-select__trigger:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.dd-select__chev {
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
pointer-events: none; color: var(--text-3); display: inline-flex;
}
.dd-select__menu {
position: absolute; top: calc(100% + 4px); right: 0; z-index: 60;
min-width: 100%; max-width: 300px; padding: 4px; margin: 0;
background: var(--surface-card); border: 1px solid var(--border-1);
border-radius: var(--radius-md); box-shadow: var(--shadow-menu);
list-style: none;
}
.dd-select__opt {
display: flex; align-items: center; gap: 7px; width: 100%;
height: 28px; padding: 0 10px 0 8px; border: none; border-radius: var(--radius-sm);
background: transparent; color: var(--text-1);
font-size: var(--text-sm); font-family: inherit; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: left;
}
.dd-select__opt:hover { background: var(--surface-2); }
.dd-select__opt .dd-select__tick { width: 14px; flex: none; display: inline-flex; color: var(--accent-text); visibility: hidden; }
.dd-select__opt[aria-selected="true"] .dd-select__tick { visibility: visible; }
`;
if (typeof document !== 'undefined' && !document.getElementById('dd-select-css')) {
const s = document.createElement('style');
s.id = 'dd-select-css';
s.textContent = ddSelectCss;
document.head.appendChild(s);
}
/**
* options: Array<string | { value, label }>value/onChange 受控。
* placeholder 在 value 无匹配项时显示。
*/
export function Select({ value, onChange, options = [], placeholder = '请选择', ...rest }) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
const items = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
const current = items.find((o) => o.value === value);
useEffect(() => {
if (!open) return;
const onDown = (e) => {
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
};
const onKey = (e) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<span className="dd-select" ref={rootRef} {...rest}>
<button
type="button"
className="dd-select__trigger"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{current ? current.label : placeholder}
</button>
<span className="dd-select__chev"><Icon name="chevronDown" size={13} /></span>
{open && (
<ul className="dd-select__menu" role="listbox">
{items.map((o) => (
<li key={o.value}>
<button
type="button"
className="dd-select__opt"
role="option"
aria-selected={o.value === value}
onClick={() => { onChange?.(o.value); setOpen(false); }}
>
<span className="dd-select__tick"><Icon name="check" size={12} sw={2.5} /></span>
{o.label}
</button>
</li>
))}
</ul>
)}
</span>
);
}
+1
View File
@@ -6,6 +6,7 @@
"check": { "web": "check", "ios": "checkmark", "android": null },
"close": { "web": "x", "ios": "xmark", "android": null },
"chevronRight": { "web": null, "ios": "chevron.right", "android": null },
"chevronDown": { "web": "chevronDown", "ios": null, "android": null },
"settings": { "web": null, "ios": "gearshape", "android": null },
"person": { "web": null, "ios": "person.fill", "android": null },
"globe": { "web": null, "ios": "globe", "android": null },
+2
View File
@@ -19,6 +19,8 @@ export const icons = {
'<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"/>',
/** 钥匙(权限引导) */
key: '<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"/>',
/** 下拉箭头(Select 等展开指示) */
chevronDown: '<path d="m6 9 6 6 6-6"/>',
};
/** 品牌 mark48 网格、3.5 圆头——圆嘴 + 三条声波,与 assets/logo-mark.svg 同构) */
+1
View File
@@ -142,6 +142,7 @@
<div class="entry"><b>Input</b><div class="sub">文本输入框</div><div class="meta">size: md/lg — components/forms/Input.jsx</div></div>
<div class="entry"><b>Switch</b><div class="sub">开关</div><div class="meta">checked · onChange — components/forms/Switch.jsx</div></div>
<div class="entry"><b>SettingRow</b><div class="sub">设置行(标签 + 描述 + 右侧控件)</div><div class="meta">label · description — components/forms/SettingRow.jsx</div></div>
<div class="entry"><b>Select</b><div class="sub">自绘下拉选择器:触发器同 Input,弹层同菜单(radius-md + shadow-menu),选中项前置对勾。全端下拉外观基准,禁用原生 select</div><div class="meta">value · onChange · options · placeholder — components/forms/Select.jsx</div></div>
</div>
<h3>voice</h3>
<div class="grid">
+7 -2
View File
@@ -80,13 +80,18 @@ function MacWindow({ title, width = 460, children, style }) {
overflow: 'hidden', fontFamily: 'var(--font-sans)', ...style,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '10px 14px',
position: 'relative', display: 'flex', alignItems: 'center', gap: 7, padding: '10px 14px',
background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)',
}}>
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FF5F57' /* ds-ignore: mac 红绿灯窗饰,系统固定色 */ }}></span>
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FEBC2E' /* ds-ignore: mac 红绿灯窗饰,系统固定色 */ }}></span>
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#28C840' /* ds-ignore: mac 红绿灯窗饰,系统固定色 */ }}></span>
<span style={{ marginLeft: 10, fontSize: 'var(--text-xs)', color: 'var(--text-2)', whiteSpace: 'nowrap' }}>{title}</span>
{/* 标题绝对居中(mac 原生窗口习惯),永不与红绿灯重叠 */}
<span style={{
position: 'absolute', left: '50%', transform: 'translateX(-50%)',
fontSize: 'var(--text-xs)', color: 'var(--text-2)', whiteSpace: 'nowrap',
fontWeight: 'var(--weight-medium)',
}}>{title}</span>
</div>
{children}
</div>
+59 -8
View File
@@ -1,9 +1,19 @@
// dudu 桌面端 · 登录 / 购买窗口(点击走完整流程:扫码登录 → 选时长包 → 支付 → 完成)
// dudu 桌面端 · 登录 / 购买窗口(点击走完整流程:登录(微信扫码 / 邮箱验证码)→ 选时长包 → 支付 → 完成)
function LoginPurchaseWindow() {
const { Button, Badge } = window.DuduDesignSystem_2e0172;
const { Button, Badge, Input } = window.DuduDesignSystem_2e0172;
const [step, setStep] = React.useState('login'); // login | plans | pay | done
const [plan, setPlan] = React.useState('m');
const [mode, setMode] = React.useState('wechat'); // wechat | email
const [email, setEmail] = React.useState('');
const [code, setCode] = React.useState('');
const [cd, setCd] = React.useState(0); // 验证码冷却秒数(演示 3s
React.useEffect(() => {
if (cd <= 0) return;
const t = setTimeout(() => setCd(cd - 1), 1000);
return () => clearTimeout(t);
}, [cd]);
const PACKS = {
s: { label: '100 分钟', price: 9, desc: '约 ¥0.09 / 分钟', tag: '' },
@@ -22,12 +32,53 @@ function LoginPurchaseWindow() {
{step === 'login' && (
<Center>
<span style={{ color: 'var(--accent)' }}><DuduLogo size={40} /></span>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>微信扫码登录</div>
<FakeQr seed={7} />
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center', lineHeight: 'var(--leading-normal)' }}>
打开微信扫一扫确认后自动登录
</div>
<Button variant="ghost" size="sm" onClick={() => setStep('plans')}>模拟手机已确认 </Button>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>登录 dudu</div>
{/* 登录方式分段切换(与设置页「外观」同控件语言) */}
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
{[['wechat', '微信扫码'], ['email', '邮箱登录']].map(([val, lab]) => (
<button key={val} type="button" onClick={() => setMode(val)} style={{
height: 26, padding: '0 14px', borderRadius: 4, border: 'none', cursor: 'pointer',
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
background: mode === val ? 'var(--surface-card)' : 'transparent',
color: mode === val ? 'var(--text-1)' : 'var(--text-2)',
boxShadow: mode === val ? 'var(--shadow-card)' : 'none',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{lab}</button>
))}
</span>
{mode === 'wechat' ? (
<>
<FakeQr seed={7} />
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center', lineHeight: 'var(--leading-normal)' }}>
打开微信扫一扫确认后自动登录
</div>
<Button variant="ghost" size="sm" onClick={() => setStep('plans')}>模拟手机已确认 </Button>
</>
) : (
<div style={{ alignSelf: 'stretch', display: 'flex', flexDirection: 'column', gap: 10, padding: '4px 8px 0' }}>
<Input
size="lg" type="email" placeholder="邮箱地址"
value={email} onChange={(e) => setEmail(e.target.value)}
style={{ width: '100%', boxSizing: 'border-box' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<Input
size="lg" inputMode="numeric" maxLength={6} placeholder="6 位验证码"
value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
style={{ flex: 1, minWidth: 0 }}
/>
<Button variant="secondary" disabled={cd > 0 || !email.includes('@')} onClick={() => setCd(3)}>
{cd > 0 ? `${cd}s` : '发送验证码'}
</Button>
</div>
<Button variant="primary" block disabled={code.length !== 6} onClick={() => setStep('plans')}>登录</Button>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)', textAlign: 'center' }}>
未注册的邮箱将自动创建账号
</div>
</div>
)}
</Center>
)}
+80 -68
View File
@@ -47,16 +47,33 @@ function TrayMenu() {
);
}
// 设置屏(2026-07-11 改版):分组从「灰字 + 通栏细线」改为卡片体块——
// 组标题在卡外、每组一张 radius-lg 卡片、卡内行以内嵌分隔线相隔;
// 载入时分组 4 段轻微上浮淡入(对齐浮层动效语言,120/40ms 级差)。
const ddSettingsCss = `
@keyframes dd-set-in { from { opacity: 0; transform: translateY(4px); } }
.dd-set-group { animation: dd-set-in var(--dur-base) var(--ease-out) both; }
`;
if (typeof document !== 'undefined' && !document.getElementById('dd-settings-css')) {
const s = document.createElement('style');
s.id = 'dd-settings-css';
s.textContent = ddSettingsCss;
document.head.appendChild(s);
}
function SettingsWindow({ theme, onThemeChange }) {
const { Switch, SettingRow, HotkeyCombo, Button, Badge, ProgressBar } = window.DuduDesignSystem_2e0172;
const { Switch, SettingRow, HotkeyCombo, Button, Badge, ProgressBar, Select, Card } = window.DuduDesignSystem_2e0172;
const [autostart, setAutostart] = React.useState(true);
const [sound, setSound] = React.useState(false);
const [mic, setMic] = React.useState('MacBook Pro 麦克风');
const Section = ({ title, children }) => (
<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}
const Group = ({ title, order = 0, children }) => (
<div className="dd-set-group" style={{ animationDelay: `${order * 40}ms` }}>
<div style={{
fontSize: 'var(--text-xs)', color: 'var(--text-3)', fontWeight: 'var(--weight-medium)',
letterSpacing: 'var(--tracking-wide)', margin: '0 0 6px 4px',
}}>{title}</div>
<Card padding="0 16px" style={{ borderRadius: 'var(--radius-lg)' }}>{children}</Card>
</div>
);
@@ -64,75 +81,70 @@ function SettingsWindow({ theme, onThemeChange }) {
return (
<MacWindow title="dudu 设置" width={440}>
<Section title="输入">
<SettingRow label="说话快捷键" description="按住说话,松开上屏">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '10px 16px 16px', background: 'var(--bg-app)' }}>
<Group title="输入" order={0}>
<SettingRow label="说话快捷键" description="按住说话,松开上屏">
<HotkeyCombo keys={['⌘', '⇧', 'Space']} />
<Button variant="ghost" size="sm">修改</Button>
</span>
</SettingRow>
<SettingRow label="麦克风">
<span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
<select value={mic} onChange={(e) => setMic(e.target.value)} style={{
appearance: 'none', WebkitAppearance: 'none', height: 'var(--control-sm)',
padding: '0 30px 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)', cursor: 'pointer',
}}>
<option>MacBook Pro 麦克风</option>
<option>AirPods Pro</option>
<option>外接 USB 麦克风</option>
</select>
<span style={{ position: 'absolute', right: 8, pointerEvents: 'none', color: 'var(--text-3)', display: 'inline-flex' }}><DuIcons.ChevronDown size={13} /></span>
</span>
</SettingRow>
<SettingRow label="提示音" description="开始 / 完成时播放轻提示音">
<Switch checked={sound} onChange={setSound} />
</SettingRow>
</Section>
</SettingRow>
<SettingRow label="麦克风">
<Select
value={mic}
onChange={setMic}
options={['MacBook Pro 麦克风', 'AirPods Pro', '外接 USB 麦克风']}
/>
</SettingRow>
<SettingRow label="提示音" description="开始 / 完成时播放轻提示音">
<Switch checked={sound} onChange={setSound} />
</SettingRow>
</Group>
<div style={{ height: 1, background: 'var(--border-1)' }}></div>
<Group title="通用" order={1}>
<SettingRow label="开机自启">
<Switch checked={autostart} onChange={setAutostart} />
</SettingRow>
<SettingRow label="外观">
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
{themeOptions.map(([val, lab]) => (
<button key={val} type="button" onClick={() => onThemeChange(val)} style={{
height: 24, padding: '0 10px', borderRadius: 4, border: 'none', cursor: 'pointer',
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
background: theme === val ? 'var(--surface-card)' : 'transparent',
color: theme === val ? 'var(--text-1)' : 'var(--text-2)',
boxShadow: theme === val ? 'var(--shadow-card)' : 'none',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{lab}</button>
))}
</span>
</SettingRow>
</Group>
<Section title="通用">
<SettingRow label="开机自启">
<Switch checked={autostart} onChange={setAutostart} />
</SettingRow>
<SettingRow label="外观">
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
{themeOptions.map(([val, lab]) => (
<button key={val} type="button" onClick={() => onThemeChange(val)} style={{
height: 24, padding: '0 10px', borderRadius: 4, border: 'none', cursor: 'pointer',
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
background: theme === val ? 'var(--surface-card)' : 'transparent',
color: theme === val ? 'var(--text-1)' : 'var(--text-2)',
boxShadow: theme === val ? 'var(--shadow-card)' : 'none',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{lab}</button>
))}
</span>
</SettingRow>
</Section>
<div style={{ height: 1, background: 'var(--border-1)' }}></div>
<Section title="账号与时长">
<SettingRow label="登录状态" description="微信已登录">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 'var(--text-sm)', color: 'var(--text-1)' }}>
wang*** <Badge tone="green">余额充足</Badge>
</span>
</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)', fontFamily: 'var(--font-sans)' }}>472 分钟</span>
<Group title="账号与时长" order={2}>
<SettingRow label="登录状态" description="微信已登录">
<span style={{ fontSize: 'var(--text-sm)', color: 'var(--text-1)' }}>wang***</span>
<Badge tone="green">余额充足</Badge>
</SettingRow>
<SettingRow label="时长余额" description="时长不过期 · 每天另有 3 分钟免费试用">
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 'var(--text-xl)', fontWeight: 'var(--weight-bold)', color: 'var(--text-1)' }}>472</span>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-2)' }}>分钟</span>
</span>
<Button variant="secondary" size="sm">购买时长</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)' }}>1 / 3 分钟</span>
</SettingRow>
<div style={{ padding: '12px 0 14px' }}>
<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)' }}>1 / 3 分钟</span>
</div>
<ProgressBar value={1} max={3} />
</div>
<ProgressBar value={1} max={3} />
</div>
</Section>
</Group>
<Group title="帮助" order={3}>
<SettingRow label="反馈问题" description="遇到问题或建议,告诉我们">
<Button variant="ghost" size="sm">去反馈</Button>
</SettingRow>
</Group>
</div>
</MacWindow>
);
}
+4
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dudu 设置</title>
<style>
/* 窗口 transparent,圆角/边框/阴影由页面自绘(对齐原型 MacWindow) */
html, body { margin: 0; background: transparent; }
</style>
</head>
<body>
<div id="root"></div>
+4
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<title>dudu — 登录</title>
<style>
/* 窗口 transparent,圆角/边框由页面自绘(WindowFrame),阴影走 macOS 原生 */
html, body { margin: 0; background: transparent; }
</style>
</head>
<body>
<div id="root"></div>
+306
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@tauri-apps/api": "^2.4.0",
"@tauri-apps/plugin-global-shortcut": "^2.2.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -1459,6 +1460,30 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.35",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
@@ -1506,6 +1531,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
@@ -1527,6 +1561,35 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1552,6 +1615,21 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.371",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
@@ -1559,6 +1637,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1629,6 +1713,19 @@
}
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1654,6 +1751,24 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1686,6 +1801,18 @@
"node": ">=6"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1744,6 +1871,51 @@
"node": ">=18"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1764,6 +1936,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -1793,6 +1974,23 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1828,6 +2026,21 @@
"node": ">=0.10.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rollup": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
@@ -1892,6 +2105,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1902,6 +2121,32 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2025,12 +2270,73 @@
}
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@tauri-apps/api": "^2.4.0",
"@tauri-apps/plugin-global-shortcut": "^2.2.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# 本地起桌面端(在 desktop/ 目录下运行):Tauri devRust 系统层 + Vite 前端热更新)。
# 前置:后端已起(../server/run-dev.sh);首次运行自动 npm install。
#
# ⚠️ 必须从你自己的终端前台运行:dev 跑的是裸二进制 target/debug/dudu-desktop
# 它是本终端的子进程,macOS 把麦克风权限算在终端头上。首次按住说话若没声音,
# 去 系统设置 → 隐私与安全性 → 麦克风 勾选本终端(iTerm/Terminal),
# 再完全退出 dudu 重启(TCC 权限变更需重启进程生效)。
set -e
if [ ! -d node_modules ]; then
echo "[run-dev] 首次运行,安装依赖…"
npm install
fi
exec npm run tauri dev
+1
View File
@@ -954,6 +954,7 @@ dependencies = [
"env_logger",
"futures-util",
"log",
"objc2 0.5.2",
"parking_lot",
"reqwest 0.12.28",
"serde",
+6 -1
View File
@@ -12,7 +12,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -28,3 +28,8 @@ reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"], de
base64 = "0.22"
log = "0.4"
env_logger = "0.11"
# macOS:直接设置识别浮层 NSWindow 的集合行为(浮在全屏 App 之上)。
# 版本对齐 tao 依赖的 objc2 0.5.x,避免重复版本。
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- macOS 打包版必须声明麦克风用途,否则系统不弹授权框、直接返回静音。
Tauri 2 打包时自动合并本文件到最终 .app 的 Info.plist。 -->
<key>NSMicrophoneUsageDescription</key>
<string>dudu 需要使用麦克风采集你的语音,实时识别为文字上屏。</string>
</dict>
</plist>
+13 -2
View File
@@ -2,6 +2,17 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "所有窗口的基础能力:核心 IPCinvoke/listen+ 全局快捷键",
"windows": ["settings", "overlay", "tray", "login", "feedback", "onboarding"],
"permissions": ["core:default", "global-shortcut:default"]
"windows": [
"settings",
"overlay",
"tray",
"login",
"feedback",
"onboarding"
],
"permissions": [
"core:default",
"global-shortcut:default",
"core:window:allow-start-dragging"
]
}
@@ -1 +1 @@
{"default":{"identifier":"default","description":"所有窗口的基础能力:核心 IPCinvoke/listen+ 全局快捷键","local":true,"windows":["settings","overlay","tray","login","feedback","onboarding"],"permissions":["core:default","global-shortcut:default"]}}
{"default":{"identifier":"default","description":"所有窗口的基础能力:核心 IPCinvoke/listen+ 全局快捷键","local":true,"windows":["settings","overlay","tray","login","feedback","onboarding"],"permissions":["core:default","global-shortcut:default","core:window:allow-start-dragging"]}}
+37
View File
@@ -109,6 +109,43 @@ pub async fn login_qr_poll(app: AppHandle, state: String) -> Option<Value> {
Some(v)
}
/// 发送邮箱登录验证码。返回 true=已发送,false=网络失败,
/// Some(json) 形式的错误码(如 RATE_LIMITED 冷却中)由前端据 status 判断。
#[tauri::command]
pub async fn login_email_code(app: AppHandle, email: String) -> Option<u16> {
let (url, _) = base(&app);
let resp = http()
.post(format!("{url}/v1/auth/email/code"))
.json(&serde_json::json!({ "email": email }))
.send()
.await
.ok()?;
Some(resp.status().as_u16())
}
/// 邮箱验证码登录:成功保存 token 并重连 ws(与扫码登录同路径)。
#[tauri::command]
pub async fn login_email(app: AppHandle, email: String, code: String) -> Option<Value> {
let (url, _) = base(&app);
let v: Value = http()
.post(format!("{url}/v1/auth/email"))
.json(&serde_json::json!({ "email": email, "code": code }))
.send()
.await
.ok()?
.json()
.await
.ok()?;
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);
let _ = app.state::<crate::ws::WsHandle>().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);
+91 -15
View File
@@ -4,8 +4,9 @@
use parking_lot::Mutex;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, EventTarget, Manager};
use tokio::sync::Notify;
@@ -24,12 +25,14 @@ pub struct DictationState {
}
struct Session {
id: String,
/// 会话代际(18A/18B):每次 start 自增,on_server_msg / 收尾任务校验后才生效。
epoch: u64,
capture: Option<crate::audio::Capture>,
started: Instant,
got_first_partial: bool,
/// 收尾语义(true=取消):stop() 在停采集前写入,转发线程排空音频后据此
/// 发 Stop/Cancel 帧,保证控制帧在全部音频帧之后到网关(尾部不丢,见 stop)。
canceled: Arc<AtomicBool>,
}
pub fn start(app: &AppHandle) {
@@ -71,23 +74,42 @@ pub fn start(app: &AppHandle) {
None
}
};
// 收尾语义标志:stop() 停采集前写入;转发线程排空音频后读取并发控制帧。
let canceled = Arc::new(AtomicBool::new(false));
{
let ws = ws.clone();
let sid = session_id.clone();
let canceled = canceled.clone();
std::thread::spawn(move || {
while let Ok(frame) = rx.recv() {
if ws.send(crate::ws::WsCmd::Audio(frame)).is_err() {
break;
// 排空音频通道:capture 被 dropstop 里)→ tx 关闭 → 这里 recv 收完
// 剩余缓冲帧后返回 Err。ws 写入失败则直接退出,不再补发控制帧。
loop {
match rx.recv() {
Ok(frame) => {
if ws.send(crate::ws::WsCmd::Audio(frame)).is_err() {
return;
}
}
Err(_) => break,
}
}
// 音频已全部入队,最后才发 Stop/Cancel——保证控制帧排在所有音频帧之后,
// 网关先收完音频再收 stop,尾部半秒不再被截断(竞态修复)。
let cmd = if canceled.load(Ordering::SeqCst) {
crate::ws::WsCmd::Cancel { session_id: sid }
} else {
crate::ws::WsCmd::Stop { session_id: sid }
};
let _ = ws.send(cmd);
});
}
*state.inner.lock() = Some(Session {
id: session_id,
epoch,
capture,
started: Instant::now(),
got_first_partial: false,
canceled,
});
}
@@ -104,7 +126,11 @@ pub fn stop(app: &AppHandle, canceled: bool) {
let epoch = sess.epoch;
emit_overlay(app, "hotkey", json!({"state": "up"}));
set_tray_tooltip(app, "dudu — 就绪");
sess.capture.take(); // 停止采集
// 先写收尾语义,再停采集:转发线程排空音频后据此发 Stop/Cancel
// 保证控制帧排在所有音频帧之后(尾部不被截断)。不在此处直接发控制帧。
sess.canceled.store(canceled, Ordering::SeqCst);
sess.capture.take(); // 停止采集 → 关音频通道 → 转发线程排空后补发控制帧
let buf = app.state::<CommitBuffer>();
if canceled {
@@ -112,14 +138,6 @@ pub fn stop(app: &AppHandle, canceled: bool) {
buf.discard(epoch);
}
let ws = (*app.state::<crate::ws::WsHandle>()).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);
// 取尾部 final 完成信号(事件驱动注入,18C)。
let finalized = buf.finalize_signal();
@@ -312,6 +330,62 @@ fn set_tray_tooltip(app: &AppHandle, text: &str) {
}
}
/// 让识别浮层能浮在全屏 App 之上(macOS):给 NSWindow 追加
/// CanJoinAllSpaces | Stationary | FullScreenAuxiliary 集合行为,并抬高窗口层级。
/// 仅 alwaysOnTop 不够——全屏 App 独占 Space,普通置顶窗口不会出现在其中,
/// FullScreenAuxiliary 才让浮层作为辅助窗叠加到全屏 Space 上。
///
/// 强制派发到主线程执行(设 NSWindow 属性必须主线程);每次显示浮层都重设一遍,
/// 规避「setup 时隐藏窗口的原生窗口尚未就绪 / 属性被 show 重置」。
#[cfg(target_os = "macos")]
pub fn configure_overlay_spaces(app: &AppHandle) {
let app2 = app.clone();
let _ = app.run_on_main_thread(move || {
use objc2::{msg_send, runtime::AnyObject};
const CAN_JOIN_ALL_SPACES: usize = 1 << 0;
const STATIONARY: usize = 1 << 4;
const FULL_SCREEN_AUXILIARY: usize = 1 << 8;
// NSStatusWindowLevel(25):浮在普通/全屏窗口之上、菜单栏之下。
const STATUS_LEVEL: isize = 25;
let Some(w) = app2.get_webview_window(OVERLAY) else {
overlay_diag("configure: overlay window 不存在");
return;
};
match w.ns_window() {
Ok(ptr) => {
let ns = ptr as *mut AnyObject;
unsafe {
let before: usize = msg_send![ns, collectionBehavior];
let next = before | CAN_JOIN_ALL_SPACES | STATIONARY | FULL_SCREEN_AUXILIARY;
let _: () = msg_send![ns, setCollectionBehavior: next];
let _: () = msg_send![ns, setLevel: STATUS_LEVEL];
let after: usize = msg_send![ns, collectionBehavior];
overlay_diag(&format!(
"configure: ns_window OK behavior {before:#x}{after:#x} level={STATUS_LEVEL}"
));
}
}
Err(e) => overlay_diag(&format!("configure: ns_window 失败: {e}")),
}
});
}
#[cfg(not(target_os = "macos"))]
pub fn configure_overlay_spaces(_app: &AppHandle) {}
// TODO(诊断): 全屏浮层排查——写文件便于 open 启动时读取,定位后删除。
#[cfg(target_os = "macos")]
fn overlay_diag(msg: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/dudu-overlay-diag.log")
{
let _ = writeln!(f, "{msg}");
}
}
fn show_overlay(app: &AppHandle) {
if let Some(w) = app.get_webview_window(OVERLAY) {
// 跟随光标:浮层出现在指针下方 24px,水平居中
@@ -320,6 +394,8 @@ fn show_overlay(app: &AppHandle) {
}
let _ = w.show();
}
// 每次显示都重设全屏叠加行为(主线程派发;见 configure_overlay_spaces)。
configure_overlay_spaces(app);
}
fn hide_overlay(app: &AppHandle) {
+11 -3
View File
@@ -57,10 +57,18 @@ pub fn inject_text(text: &str) -> Result<(), String> {
#[cfg(not(target_os = "macos"))]
let modifier = Key::Control;
// 粘贴键 V
// macOS 用物理键码 9kVK_ANSI_V),直接走 CGEvent,避开 enigo 对
// Key::Unicode 的键盘布局查询——那条路径调 TSMGetInputSourceProperty
// 该 API 必须在主线程执行,在 tokio 后台线程上调用会触发断言崩溃
// macOS 15/26 收紧了主线程校验)。物理键码与布局无关,粘贴恒为此键位。
#[cfg(target_os = "macos")]
let paste_key = Key::Other(9);
#[cfg(not(target_os = "macos"))]
let paste_key = Key::Unicode('v');
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(paste_key, Direction::Click).map_err(|e| e.to_string())?;
enigo
.key(modifier, Direction::Release)
.map_err(|e| e.to_string())?;
+31 -5
View File
@@ -12,17 +12,30 @@ 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) {
/// 托盘图标 rect → 菜单窗口左上角物理坐标。
/// macOS 系统菜单习惯:左缘对齐图标左缘、紧贴菜单栏下方;
/// 菜单超出屏幕右缘时整体左收(12A 实测反馈:居中定位偏左、间隙偏大)。
fn tray_menu_position(
rect: &tauri::Rect,
scale: f64,
win_width: f64,
screen_right: Option<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 {
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)
let mut mx = x;
if let Some(right) = screen_right {
if mx + win_width > right {
mx = right - win_width - 8.0 * scale;
}
}
(mx, y + h)
}
/// 左键点击托盘图标 → 在图标位置弹出 / 收起自绘菜单窗(11B)。
@@ -39,7 +52,10 @@ fn toggle_tray_menu(app: &tauri::AppHandle, rect: &tauri::Rect) {
.outer_size()
.map(|s| s.width as f64)
.unwrap_or(248.0 * scale);
let (x, y) = tray_menu_position(rect, scale, win_width);
let screen_right = w.current_monitor().ok().flatten().map(|m| {
m.position().x as f64 + m.size().width as f64
});
let (x, y) = tray_menu_position(rect, scale, win_width, screen_right);
let _ = w.set_position(tauri::PhysicalPosition::new(x, y));
let _ = w.show();
let _ = w.set_focus(); // 获焦后才能在失焦时自动收起
@@ -82,6 +98,12 @@ pub fn run() {
})
.setup(|app| {
let handle = app.handle().clone();
// dudu 是托盘应用(无主窗口):设为 Accessory 激活策略——菜单栏辅助 App,
// 不占 Dock。这也是识别浮层能浮在其他 App 全屏之上的前提:标准 NSWindow
// 靠 collectionBehavior 无法叠加到别的 App 全屏 Space(macOS 设计限制,
// 见 Apple 论坛 26677 / tauri#11488),Accessory 策略 + CanJoinAllSpaces 才生效。
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
// 设置 → 管理状态
app.manage(settings::SettingsStore::load(&handle));
app.manage(dictation::DictationState::default());
@@ -116,6 +138,8 @@ pub fn run() {
if let Err(e) = app.global_shortcut().register(s.hotkey.as_str()) {
log::error!("register hotkey failed: {e}");
}
// 识别浮层可浮在全屏 App 之上(终端/浏览器全屏时也能看到浮层)
dictation::configure_overlay_spaces(&handle);
// 首次启动 → 引导窗(11F);之后均静默启动(仅托盘常驻)
if !s.onboarding_done {
if let Some(w) = app.get_webview_window("onboarding") {
@@ -136,6 +160,8 @@ pub fn run() {
api::fetch_packs,
api::login_qr_create,
api::login_qr_poll,
api::login_email_code,
api::login_email,
api::create_order,
api::order_status,
api::submit_feedback,
+20 -3
View File
@@ -18,7 +18,15 @@
"width": 440,
"height": 560,
"resizable": false,
"visible": false
"visible": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"transparent": true,
"shadow": true,
"trafficLightPosition": {
"x": 18,
"y": 18
}
},
{
"label": "overlay",
@@ -41,7 +49,15 @@
"width": 400,
"height": 520,
"resizable": false,
"visible": false
"visible": false,
"transparent": true,
"shadow": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": {
"x": 18,
"y": 18
}
},
{
"label": "tray",
@@ -80,7 +96,8 @@
],
"security": {
"csp": null
}
},
"macOSPrivateApi": true
},
"bundle": {
"active": true,
+164 -40
View File
@@ -1,39 +1,89 @@
// 登录 / 购买窗口四步流:扫码登录 → 时长包 → 支付二维码 → 完成
// 规格见 doc/frontend-design.html 4.2;二维码内容来自后端,轮询自动推进。
import { useEffect, useState } from 'react';
// 登录 / 购买窗口四步流:登录(微信扫码 / 邮箱验证码)→ 时长包 → 支付二维码 → 完成
// 规格见 doc/frontend-design.html 4.2 与 ui_kits/desktop/LoginPurchase.jsx 原型;
// 二维码内容来自后端(qrcode 库本地渲染),轮询自动推进。
import { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import QRCode from 'qrcode';
import '@dudu/design/styles.css';
import { Button } from '@dudu/design/components/core/Button';
import { Badge } from '@dudu/design/components/core/Badge';
import { Icon } from '@dudu/design/components/core/Icon';
import { Input } from '@dudu/design/components/forms/Input';
import { logoMark } from '@dudu/design/icons.js';
import { invoke } from '../shared/tauri';
import { setThemePref } from '../shared/theme';
import { usePoll } from '../shared/usePoll';
import { WindowFrame } from '../shared/WindowFrame';
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',
padding: '22px 24px 28px', boxSizing: 'border-box',
}}>{children}</div>
);
}
// 简易二维码占位:真实渲染由 qr 内容字符串绘制(接入 qrcode 库或后端返图)。
function QrBox({ value }) {
function Logo({ size = 40 }) {
// 安全前提同 Icon 组件:内容为 icons.js 静态常量,无用户输入。
return (
<svg
width={size} height={size} viewBox="0 0 48 48" fill="none" stroke="currentColor"
strokeWidth="3.5" strokeLinecap="round" aria-hidden="true"
dangerouslySetInnerHTML={{ __html: logoMark }}
/>
);
}
// 二维码:qrcode 库画到 canvas(前景/背景取当前主题 token 计算值)。
function QrBox({ value, dimmed }) {
const ref = useRef(null);
useEffect(() => {
if (!value || !ref.current) return;
const css = getComputedStyle(document.documentElement);
QRCode.toCanvas(ref.current, value, {
width: 168,
margin: 1,
color: {
dark: css.getPropertyValue('--text-1').trim() || undefined,
light: css.getPropertyValue('--surface-card').trim() || undefined,
},
}).catch(() => {});
}, [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>
opacity: dimmed ? 0.25 : 1, transition: 'opacity var(--dur-base) var(--ease-out)',
}}>
{value
? <canvas ref={ref} style={{ borderRadius: 4 }} />
: <span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}>加载中</span>}
</div>
);
}
// 登录方式分段切换(与设置页「外观」同控件语言)
function ModeSwitch({ mode, onChange }) {
return (
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
{[['wechat', '微信扫码'], ['email', '邮箱登录']].map(([val, lab]) => (
<button key={val} type="button" onClick={() => onChange(val)} style={{
height: 26, padding: '0 14px', borderRadius: 4, border: 'none', cursor: 'pointer',
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
background: mode === val ? 'var(--surface-card)' : 'transparent',
color: mode === val ? 'var(--text-1)' : 'var(--text-2)',
boxShadow: mode === val ? 'var(--shadow-card)' : 'none',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{lab}</button>
))}
</span>
);
}
function LoginApp() {
const [step, setStep] = useState('login'); // login | plans | pay | done
const [mode, setMode] = useState('wechat'); // wechat | email
const [qr, setQr] = useState(null);
const [qrExpired, setQrExpired] = useState(false);
const [packs, setPacks] = useState([]);
@@ -42,41 +92,56 @@ function LoginApp() {
const [me, setMe] = useState(null);
const [qrNonce, setQrNonce] = useState(0); // 递增以触发二维码重建(刷新)
// 邮箱登录态
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [cooldown, setCooldown] = useState(0);
const [emailErr, setEmailErr] = useState('');
const [busy, setBusy] = useState(false);
// 启动应用主题:随系统/已存偏好(深色模式下不再停在浅色)。
useEffect(() => {
invoke('get_settings').then((s) => s && setThemePref(s.theme));
}, []);
// 扫码登录:创建二维码(进入 login 步或点击刷新时重建)。
useEffect(() => {
if (step !== 'login') return undefined;
if (cooldown <= 0) return undefined;
const t = setTimeout(() => setCooldown(cooldown - 1), 1000);
return () => clearTimeout(t);
}, [cooldown]);
// 扫码登录:创建二维码(进入 login 步或点击刷新时重建;邮箱模式下不建)。
useEffect(() => {
if (step !== 'login' || mode !== 'wechat') return undefined;
let live = true;
setQrExpired(false);
setQr(null);
invoke('login_qr_create').then((r) => { if (live && r) setQr(r); });
return () => { live = false; };
}, [step, qrNonce]);
}, [step, mode, qrNonce]);
const enterPlans = async () => {
const m = await invoke('fetch_me');
const ps = await invoke('fetch_packs');
setMe(m);
setPacks(ps || []);
setStep('plans');
};
// 扫码登录轮询:拿到 confirmed/expired 即停(不再永久 1Hz 打后端)。
usePoll(async (alive) => {
const st = await invoke('login_qr_poll', { state: qr.state });
if (!alive()) return 'stop';
if (st && st.status === 'confirmed') {
const m = await invoke('fetch_me');
const ps = await invoke('fetch_packs');
if (!alive()) return 'stop';
setMe(m);
setPacks(ps || []);
setStep('plans');
await enterPlans();
return 'stop';
}
// 后端 2 分钟 qrTTL 过期后返回 expired:停轮询并提示点击刷新。
if (st && st.status === 'expired') {
setQrExpired(true);
return 'stop';
}
return undefined;
}, 1000, [qr && qr.state], step === 'login' && !!qr && !qrExpired);
}, 1000, [qr && qr.state], step === 'login' && mode === 'wechat' && !!qr && !qrExpired);
// 支付轮询:付款成功即停。
usePoll(async (alive) => {
@@ -94,27 +159,81 @@ function LoginApp() {
const refreshQr = () => setQrNonce((n) => n + 1);
const sendCode = async () => {
setEmailErr('');
const status = await invoke('login_email_code', { email: email.trim() });
if (status === 204) setCooldown(60);
else if (status === 429) setEmailErr('发送太频繁,稍后再试');
else setEmailErr('发送失败,检查邮箱地址');
};
const emailLogin = async () => {
setBusy(true);
setEmailErr('');
const r = await invoke('login_email', { email: email.trim(), code });
setBusy(false);
if (r && r.token) {
await enterPlans();
} else {
setEmailErr('验证码错误或已过期');
}
};
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={qrExpired ? '二维码已过期' : (qr && qr.qr_url)} />
{qrExpired ? (
<>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>二维码已过期点击刷新重新扫码</div>
<Button variant="primary" size="sm" onClick={refreshQr}>刷新二维码</Button>
</>
) : (
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>打开微信扫一扫确认后自动登录</div>
)}
</Center>
<WindowFrame title="dudu — 登录">
<Center>
<span style={{ color: 'var(--accent)', marginTop: 4 }}><Logo size={40} /></span>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>登录 dudu</div>
<ModeSwitch mode={mode} onChange={setMode} />
{mode === 'wechat' ? (
<>
<QrBox value={qr && qr.qr_url} dimmed={qrExpired} />
{qrExpired ? (
<>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>二维码已过期点击刷新重新扫码</div>
<Button variant="primary" size="sm" onClick={refreshQr}>刷新二维码</Button>
</>
) : (
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>打开微信扫一扫确认后自动登录</div>
)}
</>
) : (
<div style={{ alignSelf: 'stretch', display: 'flex', flexDirection: 'column', gap: 10, padding: '4px 8px 0' }}>
<Input
size="lg" type="email" placeholder="邮箱地址" autoFocus
value={email} onChange={(e) => setEmail(e.target.value)}
style={{ width: '100%', boxSizing: 'border-box' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<Input
size="lg" inputMode="numeric" maxLength={6} placeholder="6 位验证码"
value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
style={{ flex: 1, minWidth: 0 }}
/>
<Button variant="secondary" disabled={cooldown > 0 || !email.includes('@')} onClick={sendCode}>
{cooldown > 0 ? `${cooldown}s` : '发送验证码'}
</Button>
</div>
<Button variant="primary" block disabled={code.length !== 6 || busy} onClick={emailLogin}>
{busy ? '登录中…' : '登录'}
</Button>
<div style={{ fontSize: 'var(--text-xs)', color: emailErr ? 'var(--danger)' : 'var(--text-3)', textAlign: 'center' }}>
{emailErr || '未注册的邮箱将自动创建账号'}
</div>
</div>
)}
</Center>
</WindowFrame>
);
}
if (step === 'plans') {
return (
<Center>
<WindowFrame title="dudu — 购买时长">
<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)' }}>
@@ -150,12 +269,14 @@ function LoginApp() {
const o = await invoke('create_order', { packId: sel });
if (o) { setOrder(o); setStep('pay'); }
}}>微信支付 ¥{pack ? pack.price_cents / 100 : '—'}</Button>
</Center>
</Center>
</WindowFrame>
);
}
if (step === 'pay') {
return (
<Center>
<WindowFrame title="dudu — 购买时长">
<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}
@@ -164,14 +285,16 @@ function LoginApp() {
<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>
</Center>
</WindowFrame>
);
}
return (
<Center>
<WindowFrame title="dudu — 购买时长">
<Center>
<span style={{
width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28,
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, marginTop: 24,
}}>
<Icon name="check" size={28} sw={2.5} />
</span>
@@ -180,7 +303,8 @@ function LoginApp() {
时长余额 {me ? Math.floor(me.balance_seconds / 60) : '—'} 分钟 · 不过期 · 现在就按住 Space 试试
</div>
<Button variant="primary" onClick={() => invoke('close_login')}>开始使用</Button>
</Center>
</Center>
</WindowFrame>
);
}
+84 -81
View File
@@ -2,29 +2,37 @@
import { useEffect, useState } from 'react';
import { Button } from '@dudu/design/components/core/Button';
import { Badge } from '@dudu/design/components/core/Badge';
import { Card } from '@dudu/design/components/core/Card';
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 { Select } from '@dudu/design/components/forms/Select';
import { invoke } from '../shared/tauri';
import { setThemePref } from '../shared/theme';
import { WindowFrame } from '../shared/WindowFrame';
const THEME_OPTIONS = [['system', '跟随系统'], ['light', '浅色'], ['dark', '深色']];
function Section({ title, children }) {
// 分组卡片(对齐原型 SettingsTray 2026-07-11 改版):组标题在卡外、
// radius-lg 卡片体块、载入分组轻微上浮淡入(40ms 级差)。
const groupCss = `
@keyframes dd-set-in { from { opacity: 0; transform: translateY(4px); } }
.dd-set-group { animation: dd-set-in var(--dur-base) var(--ease-out) both; }
`;
function Group({ title, order = 0, children }) {
return (
<div style={{ padding: '4px 18px 8px' }}>
<div className="dd-set-group" style={{ animationDelay: `${order * 40}ms` }}>
<div style={{
fontSize: 'var(--text-xs)', color: 'var(--text-3)', fontWeight: 'var(--weight-medium)',
margin: '10px 0 2px', letterSpacing: 'var(--tracking-wide)',
letterSpacing: 'var(--tracking-wide)', margin: '0 0 6px 4px',
}}>{title}</div>
{children}
<Card padding="0 16px" style={{ borderRadius: 'var(--radius-lg)' }}>{children}</Card>
</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',
@@ -60,86 +68,81 @@ export function SettingsApp() {
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 }}>
<WindowFrame title="dudu 设置">
<style>{groupCss}</style>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '10px 16px 16px' }}>
<Group title="输入" order={0}>
<SettingRow label="说话快捷键" description="按住说话,松开上屏">
<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>
</SettingRow>
<SettingRow label="麦克风">
<Select
value={settings.mic}
onChange={(mic) => update({ mic })}
options={[{ value: '', label: '系统默认' }, ...mics.map((m) => ({ value: m, label: m }))]}
/>
</SettingRow>
<SettingRow label="提示音" description="开始 / 完成时播放轻提示音">
<Switch checked={settings.sound} onChange={(v) => update({ sound: v })} />
</SettingRow>
</Group>
<Group title="通用" order={1}>
<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',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{lab}</button>
))}
</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} 分钟
</SettingRow>
</Group>
<Group title="账号与时长" order={2}>
<SettingRow label="登录状态" description={me ? '微信已登录' : '登录后开始使用'}>
{me ? (
<>
<span style={{ fontSize: 'var(--text-sm)', color: 'var(--text-1)' }}>{me.nickname_masked}</span>
<Badge tone={me.account_state === 'quota' ? 'orange' : 'green'}>
{me.account_state === 'ok' ? '余额充足' : me.account_state === 'trial' ? '试用中' : '余额不足'}
</Badge>
</>
) : (
<Button variant="primary" size="sm" onClick={() => invoke('open_login')}>微信登录</Button>
)}
</SettingRow>
<SettingRow label="时长余额" description="时长不过期 · 每天另有 3 分钟免费试用">
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 'var(--text-xl)', fontWeight: 'var(--weight-bold)', color: 'var(--text-1)' }}>{minutes}</span>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-2)' }}>分钟</span>
</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>
</SettingRow>
<div style={{ padding: '12px 0 14px' }}>
<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>
<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>
</Group>
<Group title="帮助" order={3}>
<SettingRow label="反馈问题" description="遇到问题或建议,告诉我们">
<Button variant="ghost" size="sm" onClick={() => invoke('open_feedback')}>去反馈</Button>
</SettingRow>
</Group>
</div>
</WindowFrame>
);
}
+31
View File
@@ -0,0 +1,31 @@
// WindowFrame:透明窗口的自绘 MacWindow 框(对齐原型 DesktopShared.MacWindow 规格)。
// 窗口须配 transparent + titleBarStyle Overlay + hiddenTitle + trafficLightPosition {18,18}
// 阴影用 macOS 原生(贴合内容圆角),窗口即卡片实际尺寸。设置 / 登录等窗口共用。
export function WindowFrame({ title, children }) {
return (
<div style={{
fontFamily: 'var(--font-sans)', position: 'fixed', inset: 0,
background: 'var(--bg-app)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-1)',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* 自绘顶条:系统红绿灯悬浮左上,标题绝对居中(mac 原生习惯);整条可拖拽 */}
<div
data-tauri-drag-region
style={{
position: 'relative', height: 36, flex: 'none', display: 'flex', alignItems: 'center',
background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)',
userSelect: 'none', WebkitUserSelect: 'none', cursor: 'default',
}}
>
<span style={{
position: 'absolute', left: '50%', transform: 'translateX(-50%)', pointerEvents: 'none',
fontSize: 'var(--text-xs)', fontWeight: 'var(--weight-medium)', color: 'var(--text-2)',
}}>{title}</span>
</div>
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column' }}>
{children}
</div>
</div>
);
}
+6 -1
View File
@@ -25,5 +25,10 @@ export default defineConfig({
},
},
clearScreen: false,
server: { port: 5181, strictPort: true },
server: {
port: 5181,
strictPort: true,
// 设计系统在 desktop/ 之外(../design),其静态资产(Outfit 字体等)需入 fs 白名单
fs: { allow: [resolve(__dirname), resolve(__dirname, '../design')] },
},
});
+157
View File
@@ -0,0 +1,157 @@
// 邮箱验证码登录(桌面端第二登录方式):
// POST /v1/auth/email/code 发送 6 位验证码(60s 冷却,码 10 分钟有效)
// POST /v1/auth/email 校验验证码 → 建号/登录 → JWT
// 邮件发送经 Mailer 接口;SMTP 未配置时装配 MockMailer(验证码打日志,供联调)。
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"dudu/server/internal/store"
"dudu/server/pkg/protocol"
)
const (
emailCodeTTL = 10 * time.Minute
emailCodeCooldown = 60 * time.Second
)
// Mailer 验证码邮件发送。
type Mailer interface {
SendCode(ctx context.Context, email, code string) error
}
// MockMailer 开发期 mock:验证码打日志不真发(⚠️ 上线前必须替换 SMTP 实现)。
type MockMailer struct{}
func (MockMailer) SendCode(_ context.Context, email, code string) error {
slog.Info("mock mailer: email login code", "email", email, "code", code)
return nil
}
// randCode 6 位数字验证码(crypto/rand)。
func randCode() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
digits := make([]byte, 6)
for i, v := range b {
digits[i] = '0' + v%10
}
return string(digits)
}
// EmailCode POST /v1/auth/email/code 🔓
func (h *Handlers) EmailCode(c *gin.Context) {
var req protocol.AuthEmailCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
// 60s 冷却(SET NX),防轰炸
ok, err := h.RDB.SetNX(c, store.KeyAuthEmailCd(email), 1, emailCodeCooldown).Result()
if err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
if !ok {
c.JSON(http.StatusTooManyRequests, protocol.NewAPIError(protocol.ErrRateLimited))
return
}
code := randCode()
// ⚠️ 开发期便利:AUTH_EMAIL_DEV_CODE 显式设置时验证码固定(run-dev.sh 设 888888),
// 免翻日志。仅开发脚本会设置该变量;生产部署不设即为随机码,启动时若设有醒目告警。
if h.DevCode != "" {
code = h.DevCode
}
if err := h.RDB.Set(c, store.KeyAuthEmail(email), code, emailCodeTTL).Err(); err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
if err := h.Mailer.SendCode(c, email, code); err != nil {
slog.Error("send email code failed", "email", email, "err", err)
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
c.Status(http.StatusNoContent)
}
// EmailLogin POST /v1/auth/email 🔓
func (h *Handlers) EmailLogin(c *gin.Context) {
var req protocol.AuthEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
want, err := h.RDB.Get(c, store.KeyAuthEmail(email)).Result()
if err == redis.Nil || subtle.ConstantTimeCompare([]byte(want), []byte(req.Code)) != 1 {
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
return
}
if err != nil && err != redis.Nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
// 一次性:校验通过即删,防复用
h.RDB.Del(c, store.KeyAuthEmail(email))
user, err := findOrCreateUserByEmail(h.DB, email)
if err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
token, err := h.JWT.Sign(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
c.JSON(http.StatusOK, protocol.AuthTokenResponse{
Token: token,
User: protocol.UserInfo{UserID: user.ID, NicknameMasked: MaskNickname(user.Nickname)},
})
}
// findOrCreateUserByEmail 邮箱身份建号/登录(昵称取邮箱前缀)。
func findOrCreateUserByEmail(db *gorm.DB, email string) (*store.User, error) {
var ident store.EmailIdentity
err := db.Where("email = ?", email).First(&ident).Error
if err == nil {
var u store.User
return &u, db.First(&u, "id = ?", ident.UserID).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
nick := email
if i := strings.IndexByte(email, '@'); i > 0 {
nick = email[:i]
}
user := store.User{ID: strings.ReplaceAll(uuid.NewString(), "-", "")[:24], Nickname: nick}
err = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err
}
return tx.Create(&store.EmailIdentity{UserID: user.ID, Email: email}).Error
})
if err != nil {
return nil, err
}
return &user, nil
}
+4 -1
View File
@@ -14,12 +14,15 @@ import (
"dudu/server/pkg/protocol"
)
// Handlers 认证路由:扫码(5B)、微信 OAuth(5C)、logout。
// Handlers 认证路由:扫码(5B)、微信 OAuth(5C)、邮箱验证码、logout。
type Handlers struct {
DB *gorm.DB
RDB *redis.Client
JWT *JWT
Wechat WechatClient
Mailer Mailer
// DevCode 非空时邮箱验证码固定为该值(AUTH_EMAIL_DEV_CODE,仅开发脚本设置)。
DevCode string
// QrAuthURL 二维码内容模板(真实环境为微信开放平台授权页,%s 为 state)
QrAuthURL string
}
+7
View File
@@ -40,6 +40,11 @@ type Config struct {
OSSKeyID string
OSSKeySecret string
// SMTP(邮箱验证码登录;未配置降级 mock mailer
SMTPHost string
// EmailDevCode 非空时邮箱验证码固定为该值(仅开发脚本设置,生产禁用)
EmailDevCode string
// AppLatest 启动时解析 APP_LATEST_JSON 一次(17G):平台→版本信息。
// 解析失败或未配置则为 nil/空,handler 据此返回 204,不 fail-fast。
AppLatest map[string]protocol.AppLatestResponse
@@ -70,6 +75,8 @@ func Load() Config {
OSSEndpoint: os.Getenv("OSS_ENDPOINT"),
OSSBucket: os.Getenv("OSS_BUCKET"),
SMTPHost: os.Getenv("SMTP_HOST"),
EmailDevCode: os.Getenv("AUTH_EMAIL_DEV_CODE"),
OSSKeyID: os.Getenv("OSS_KEY_ID"),
OSSKeySecret: os.Getenv("OSS_KEY_SECRET"),
+59 -7
View File
@@ -24,7 +24,7 @@ import (
"dudu/server/pkg/protocol"
)
func newAPI(t *testing.T) (*httptest.Server, *gorm.DB) {
func newAPI(t *testing.T) (*httptest.Server, *gorm.DB, *redis.Client) {
t.Helper()
gin.SetMode(gin.TestMode)
mr := miniredis.RunT(t)
@@ -45,7 +45,7 @@ func newAPI(t *testing.T) (*httptest.Server, *gorm.DB) {
Register(r, &Deps{Cfg: cfg, DB: db, RDB: rdb, JWT: jwt, Quota: quota.New(rdb, db)})
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
return srv, db
return srv, db, rdb
}
func postJSON(t *testing.T, url, token string, body any) *http.Response {
@@ -93,7 +93,7 @@ func login(t *testing.T, srv *httptest.Server, code string) string {
}
func TestMobileLoginAndMe(t *testing.T) {
srv, _ := newAPI(t)
srv, _, _ := newAPI(t)
token := login(t, srv, "wangjia99")
var me protocol.MeResponse
@@ -120,7 +120,7 @@ func TestMobileLoginAndMe(t *testing.T) {
}
func TestQrLoginFlow(t *testing.T) {
srv, _ := newAPI(t)
srv, _, _ := newAPI(t)
resp := postJSON(t, srv.URL+"/v1/auth/qr", "", nil)
var qr protocol.AuthQrResponse
_ = json.NewDecoder(resp.Body).Decode(&qr)
@@ -154,7 +154,7 @@ func TestQrLoginFlow(t *testing.T) {
}
func TestOrderAndPayNotifyIdempotent(t *testing.T) {
srv, db := newAPI(t)
srv, db, _ := newAPI(t)
token := login(t, srv, "buyer1")
// packs
@@ -235,7 +235,7 @@ func postFeedback(t *testing.T, srv *httptest.Server, token, content string, ima
}
func TestFeedback(t *testing.T) {
srv, db := newAPI(t)
srv, db, _ := newAPI(t)
token := login(t, srv, "fbuser")
t.Cleanup(func() { _ = removeUploads() })
@@ -287,7 +287,7 @@ func TestFeedback(t *testing.T) {
func removeUploads() error { return nil } // LocalStorage 写入 var/uploads,测试容忍残留
func TestMetricsBatchAndAggregate(t *testing.T) {
srv, db := newAPI(t)
srv, db, _ := newAPI(t)
body := protocol.MetricsBatchRequest{
DeviceID: "dev-m1", Platform: "mac", AppVersion: "0.1.0",
Events: []protocol.MetricEvent{
@@ -314,3 +314,55 @@ func TestMetricsBatchAndAggregate(t *testing.T) {
t.Fatalf("want 2 events stored (whitelist), got %d", n)
}
}
// 邮箱验证码登录 E2E:发码(60s 冷却)→ 取码登录 → token 可用 → 码一次性。
func TestEmailLogin(t *testing.T) {
srv, _, rdb := newAPI(t)
// 发码
resp := postJSON(t, srv.URL+"/v1/auth/email/code", "", map[string]string{"email": "Dev@Example.com"})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("send code: want 204, got %d", resp.StatusCode)
}
// 冷却期内再发 → 429
resp = postJSON(t, srv.URL+"/v1/auth/email/code", "", map[string]string{"email": "dev@example.com"})
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("cooldown: want 429, got %d", resp.StatusCode)
}
code, err := rdb.Get(t.Context(), store.KeyAuthEmail("dev@example.com")).Result()
if err != nil {
t.Fatal(err)
}
// 错码拒绝
resp = postJSON(t, srv.URL+"/v1/auth/email", "", map[string]string{"email": "dev@example.com", "code": "000000"})
if code != "000000" && resp.StatusCode != http.StatusBadRequest {
t.Fatalf("wrong code: want 400, got %d", resp.StatusCode)
}
// 正确码登录
resp = postJSON(t, srv.URL+"/v1/auth/email", "", map[string]string{"email": "DEV@example.com", "code": code})
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: want 200, got %d", resp.StatusCode)
}
var tok protocol.AuthTokenResponse
_ = json.NewDecoder(resp.Body).Decode(&tok)
if tok.Token == "" || tok.User.UserID == "" {
t.Fatalf("empty token/user: %+v", tok)
}
// token 可访问 /v1/me
req, _ := http.NewRequest("GET", srv.URL+"/v1/me", nil)
req.Header.Set("Authorization", "Bearer "+tok.Token)
meResp, err := http.DefaultClient.Do(req)
if err != nil || meResp.StatusCode != http.StatusOK {
t.Fatalf("me with email token: %v %d", err, meResp.StatusCode)
}
// 码一次性:复用被拒
resp = postJSON(t, srv.URL+"/v1/auth/email", "", map[string]string{"email": "dev@example.com", "code": code})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("code reuse: want 400, got %d", resp.StatusCode)
}
}
+18 -2
View File
@@ -53,6 +53,15 @@ func pickWechat(cfg config.Config) (auth.WechatClient, string) {
return auth.MockWechat{}, "mock" // TODO(5C-真实): 开放平台实现,凭证就绪后替换
}
// pickMailer SMTP 未配置时使用 mock(验证码打日志,供联调)。返回 kind。
// ⚠️ 上线前必须配置真实 SMTP,否则邮箱登录形同虚设。
func pickMailer(cfg config.Config) (auth.Mailer, string) {
if cfg.SMTPHost == "" {
return auth.MockMailer{}, "mock"
}
return auth.MockMailer{}, "mock" // TODO(邮箱登录-真实): SMTP 实现,凭证就绪后替换
}
// pickPay 商户号未配置(#2B 申请中)时使用 mock。返回 kind="mock"。
// ⚠️⚠️ 上线前必须替换为真实 PayClientMockPay 不验签,/v1/pay/notify 等于公开充值接口。
func pickPay(cfg config.Config) (billing.PayClient, string) {
@@ -78,12 +87,17 @@ func Register(r *gin.Engine, d *Deps) {
wechat, wechatKind := pickWechat(d.Cfg)
pay, payKind := pickPay(d.Cfg)
storage, storageKind := pickStorage(d.Cfg)
mailer, mailerKind := pickMailer(d.Cfg)
// 启动时打印各外部依赖的装配结果(real/mock),便于部署核对(17E)。
slog.Info("dependency assembly",
"asr_provider", providerKind, "wechat", wechatKind, "pay", payKind, "storage", storageKind)
"asr_provider", providerKind, "wechat", wechatKind, "pay", payKind,
"storage", storageKind, "mailer", mailerKind)
authH := &auth.Handlers{DB: d.DB, RDB: d.RDB, JWT: d.JWT, Wechat: wechat}
if d.Cfg.EmailDevCode != "" {
slog.Warn("⚠️ AUTH_EMAIL_DEV_CODE 已设置:邮箱验证码为固定值,任何人可登录任意邮箱账号——生产环境严禁")
}
authH := &auth.Handlers{DB: d.DB, RDB: d.RDB, JWT: d.JWT, Wechat: wechat, Mailer: mailer, DevCode: d.Cfg.EmailDevCode}
billH := &billing.Handlers{DB: d.DB, Pay: pay, Quota: d.Quota}
userH := &user.Handlers{DB: d.DB, Quota: d.Quota, AppVersions: d.Cfg.AppLatest}
fbH := &feedback.Handlers{DB: d.DB, RDB: d.RDB, Storage: storage}
@@ -103,6 +117,8 @@ func Register(r *gin.Engine, d *Deps) {
v1.GET("/auth/qr/:state", authH.PollQr)
v1.GET("/auth/wechat/callback", authH.QrCallback)
v1.POST("/auth/wechat", authH.MobileLogin)
v1.POST("/auth/email/code", authH.EmailCode)
v1.POST("/auth/email", authH.EmailLogin)
v1.GET("/packs", billH.Packs)
// ⚠️ 部署前必须切换到真实 PayClient(验签)!当前 MockPay 不验签,
// /v1/pay/notify 等于一个无鉴权的公开充值接口(任意人可伪造支付成功回调充值)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B

+10 -2
View File
@@ -24,6 +24,14 @@ type WechatIdentity struct {
CreatedAt time.Time
}
// EmailIdentity 邮箱验证码登录身份(桌面端第二登录方式)。
type EmailIdentity struct {
ID uint `gorm:"primaryKey"`
UserID string `gorm:"size:32;index;not null"`
Email string `gorm:"size:255;uniqueIndex;not null"`
CreatedAt time.Time
}
type DurationPack struct {
ID string `gorm:"primaryKey;size:32"`
Minutes int `gorm:"not null"`
@@ -141,10 +149,10 @@ type MetricDaily struct {
P95Ms float64 `gorm:"not null;default:0"`
}
// AllModels 迁移清单(11 张表)。
// AllModels 迁移清单(12 张表)。
func AllModels() []any {
return []any{
&User{}, &WechatIdentity{}, &DurationPack{}, &Order{}, &BalanceLedger{},
&User{}, &WechatIdentity{}, &EmailIdentity{}, &DurationPack{}, &Order{}, &BalanceLedger{},
&TrialUsage{}, &ASRSession{}, &Device{}, &Feedback{}, &MetricEvent{}, &MetricDaily{},
}
}
+2
View File
@@ -13,6 +13,8 @@ func KeyQuotaTrial(uid string, day string) string {
return "quota:" + uid + ":trial:" + day
}
func KeyAuthQr(state string) string { return "authqr:" + state }
func KeyAuthEmail(email string) string { return "authmail:" + email }
func KeyAuthEmailCd(email string) string { return "authmail:cd:" + email }
func KeyJwtBlock(jti string) string { return "jwt:block:" + jti }
func KeyRateCnt(did string) string { return "rate:" + did + ":asr:cnt" }
func KeyRateSecs(did string) string { return "rate:" + did + ":asr:secs" }
+10
View File
@@ -17,6 +17,16 @@ type AuthWechatRequest struct {
Code string `json:"code" binding:"required"`
}
// 邮箱验证码登录(桌面端第二登录方式)
type AuthEmailCodeRequest struct {
Email string `json:"email" binding:"required,email"`
}
type AuthEmailRequest struct {
Email string `json:"email" binding:"required,email"`
Code string `json:"code" binding:"required,len=6"`
}
type AuthTokenResponse struct {
Token string `json:"token"`
User UserInfo `json:"user"`
+3
View File
@@ -3,6 +3,9 @@
# DashScope Key 从 Bitwarden 取(条目名 dashscope-api-key),有则真实识别,无则自动落 mock provider。
# 前置:rbw unlock 已解锁;docker compose up -d 已起 pg/redis。
set -e
# 开发期固定邮箱验证码(仅本脚本设置;生产部署不经此脚本,不会带上)
export AUTH_EMAIL_DEV_CODE=888888
if rbw get dashscope-api-key >/dev/null 2>&1; then
echo "[run-dev] 使用真实 gummy provider"
rbw get dashscope-api-key | { read -r K; ASR_PROVIDER=gummy DASHSCOPE_API_KEY="$K" exec go run ./cmd/server; }
+402 -65
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dudu — 按住说话,文字上屏</title>
<meta name="description" content="dudu 语音输入法:大模型识别,流式实时上屏。Mac / Windows / iPhone / iPad / Android。每天免费 3 分钟。">
<meta name="description" content="dudu 语音输入法:大模型识别,流式实时上屏。Mac / Windows / iPhone / iPad / Android 五端一套账号。每天免费 3 分钟,时长永不过期。">
<link rel="stylesheet" href="tokens.css">
<style>
/* ────────────────────────────────────────────────────────────────
@@ -52,8 +52,10 @@ nav {
.brand { display: inline-flex; align-items: center; gap: 10px; color: var(--text-1); }
.brand svg { color: var(--accent); }
.brand .name { font-family: var(--latin); font-weight: 600; font-size: 19px; letter-spacing: .02em; }
.nav-links { display: flex; align-items: center; gap: 28px; font-size: 14px; color: var(--text-2); }
.nav-links { display: flex; align-items: center; gap: 26px; font-size: 14px; color: var(--text-2); }
.nav-links a:hover { color: var(--text-1); }
.nav-links .only-wide { display: inline; }
@media (max-width: 640px) { .nav-links .only-wide { display: none; } }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
border: 1px solid transparent; border-radius: 6px; cursor: pointer;
@@ -69,16 +71,24 @@ nav {
.btn-secondary:hover { background: var(--surface-2); }
.btn-ghost { background: transparent; color: var(--accent-text); }
.btn-ghost:hover { background: var(--accent-soft); }
:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: 6px; }
/* ── Hero ── */
.hero { border-bottom: 1px solid var(--border-1); overflow: hidden; }
.hero-grid {
display: grid; grid-template-columns: 6fr 5fr; gap: 48px;
padding: 96px 0 110px; align-items: center;
padding: 92px 0 104px; align-items: center;
}
@media (max-width: 920px) {
.hero-grid { grid-template-columns: 1fr; padding: 64px 0 72px; gap: 56px; }
.hero-grid { grid-template-columns: 1fr; padding: 60px 0 68px; gap: 52px; }
}
.hero .eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font-size: 13px; color: var(--text-2); font-weight: 500;
padding: 5px 13px 5px 10px; border: 1px solid var(--border-1); border-radius: 999px;
background: var(--card); margin-bottom: 22px;
}
.hero .eyebrow .pip { width: 7px; height: 7px; border-radius: 50%; background: var(--positive); flex: none; }
.hero h1 {
font-size: clamp(48px, 7.2vw, 84px);
line-height: 1.12; letter-spacing: -0.015em;
@@ -90,7 +100,7 @@ nav {
display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
}
.hero .sub .dot { color: var(--text-3); }
.hero-cta { margin-top: 38px; display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.hero-cta { margin-top: 36px; display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.hero-note { font-size: 13px; color: var(--text-3); }
.hero-note .num { font-family: var(--latin); }
@@ -157,19 +167,25 @@ kbd {
/* ── 指标带(墨色) ── */
.metrics { background: var(--gray-950); color: var(--text-1); border-bottom: 1px solid var(--border-1); }
.metrics-grid { display: grid; grid-template-columns: repeat(3, 1fr); }
.metrics-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
.metric {
padding: 44px 32px; text-align: center;
padding: 44px 24px; text-align: center;
border-left: 1px solid var(--border-1);
}
.metric:first-child { border-left: none; }
.metric .num {
font-family: var(--latin); font-weight: 700;
font-size: clamp(34px, 4.4vw, 52px); line-height: 1.1;
font-size: clamp(30px, 3.8vw, 46px); line-height: 1.1;
}
.metric .num small { font-size: 0.45em; font-weight: 600; color: var(--text-2); margin-left: 2px; }
.metric .label { margin-top: 8px; font-size: 14px; color: var(--text-2); }
@media (max-width: 720px) {
.metric .label { margin-top: 8px; font-size: 13.5px; color: var(--text-2); }
@media (max-width: 820px) {
.metrics-grid { grid-template-columns: repeat(2, 1fr); }
.metric:nth-child(-n+2) { border-top: none; }
.metric:nth-child(2n+1) { border-left: none; }
.metric:nth-child(n+3) { border-top: 1px solid var(--border-1); }
}
@media (max-width: 480px) {
.metrics-grid { grid-template-columns: 1fr; }
.metric { border-left: none; border-top: 1px solid var(--border-1); padding: 28px 20px; }
.metric:first-child { border-top: none; }
@@ -177,7 +193,9 @@ kbd {
/* ── 章节通用 ── */
section { border-bottom: 1px solid var(--border-1); }
.section-head { padding: 88px 0 0; }
.section-head { padding: 84px 0 0; }
.section-head.center { text-align: center; }
.section-head.center h2 { margin-left: auto; margin-right: auto; }
.kicker {
font-size: 13px; font-weight: 600; color: var(--accent-text);
letter-spacing: .12em;
@@ -186,23 +204,32 @@ section { border-bottom: 1px solid var(--border-1); }
margin-top: 12px; font-size: clamp(30px, 4vw, 44px);
line-height: 1.25; letter-spacing: -0.01em; font-weight: 700; max-width: 18em;
}
.section-head .lead {
margin-top: 16px; font-size: 16px; color: var(--text-2); max-width: 34em;
}
.section-head.center .lead { margin-left: auto; margin-right: auto; }
.reveal { opacity: 0; translate: 0 14px; transition: opacity 320ms var(--ease-out), translate 320ms var(--ease-out); }
.reveal.in { opacity: 1; translate: 0 0; }
@media (prefers-reduced-motion: reduce) {
.reveal { opacity: 1; translate: 0 0; transition: none; }
html { scroll-behavior: auto; }
}
/* ── 特性 ── */
/* ── 特性6 格,两行内嵌分隔线) ── */
.features {
display: grid; grid-template-columns: repeat(3, 1fr);
margin: 56px 0 96px; border: 1px solid var(--border-1); border-radius: 14px;
margin: 52px 0 92px; border: 1px solid var(--border-1); border-radius: 14px;
background: var(--card); overflow: hidden;
}
.feature { padding: 36px 32px 40px; border-left: 1px solid var(--border-1); }
.feature:first-child { border-left: none; }
.feature { padding: 32px 28px 34px; border-left: 1px solid var(--border-1); border-top: 1px solid var(--border-1); }
.feature:nth-child(-n+3) { border-top: none; }
.feature:nth-child(3n+1) { border-left: none; }
.feature .icon {
width: 44px; height: 44px; border-radius: 10px;
background: var(--accent-soft); color: var(--accent-text);
display: inline-flex; align-items: center; justify-content: center;
}
.feature h3 { margin-top: 18px; font-size: 19px; font-weight: 600; }
.feature h3 { margin-top: 18px; font-size: 18px; font-weight: 600; }
.feature p { margin-top: 8px; font-size: 14.5px; color: var(--text-2); }
@media (max-width: 820px) {
.features { grid-template-columns: 1fr; }
@@ -210,67 +237,155 @@ section { border-bottom: 1px solid var(--border-1); }
.feature:first-child { border-top: none; }
}
/* ── 场景 ── */
.scene-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
.speed-badge {
flex: none; margin-bottom: 4px; padding: 12px 20px;
border: 1px solid var(--border-1); border-radius: 14px; background: var(--card);
box-shadow: var(--shadow-card); text-align: center;
}
.speed-badge .big { font-family: var(--latin); font-weight: 700; font-size: 30px; color: var(--accent-text); line-height: 1; }
.speed-badge .cap { margin-top: 4px; font-size: 12px; color: var(--text-2); }
.scenes { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; margin: 48px 0 92px; }
@media (max-width: 720px) { .scenes { grid-template-columns: 1fr; } }
.scene {
border: 1px solid var(--border-1); border-radius: 14px; background: var(--card);
padding: 26px 26px 24px; box-shadow: var(--shadow-card);
display: flex; flex-direction: column;
}
.scene .top { display: flex; align-items: center; gap: 12px; }
.scene .sicon {
width: 38px; height: 38px; border-radius: 9px; flex: none;
background: var(--accent-soft); color: var(--accent-text);
display: inline-flex; align-items: center; justify-content: center;
}
.scene h3 { font-size: 17px; font-weight: 600; }
.scene p { margin-top: 12px; font-size: 14px; color: var(--text-2); }
.scene .eg {
margin-top: 18px; border-top: 1px dashed var(--border-2); padding-top: 16px;
font-size: 13.5px;
}
.scene .eg .say {
display: inline-flex; align-items: center; gap: 6px;
color: var(--text-3); font-size: 12px; margin-bottom: 6px;
}
.scene .eg .quote { color: var(--text-1); }
.scene .eg .quote b { color: var(--accent-text); font-weight: 600; }
/* ── 隐私与信任(墨色带) ── */
.trust { background: var(--gray-950); color: var(--text-1); }
.trust .section-head { padding-top: 84px; }
.trust .kicker { color: var(--accent-text); }
.priv-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin: 48px 0 88px; }
@media (max-width: 900px) { .priv-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 520px) { .priv-grid { grid-template-columns: 1fr; } }
.priv {
border: 1px solid var(--border-1); border-radius: 14px; background: var(--surface-card);
padding: 24px 22px 26px;
}
.priv .picon { color: var(--accent-text); }
.priv h3 { margin-top: 16px; font-size: 16px; font-weight: 600; }
.priv p { margin-top: 8px; font-size: 13.5px; color: var(--text-2); line-height: 1.6; }
/* ── 三步 ── */
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; margin: 64px 0 96px; }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; margin: 60px 0 92px; }
.step .no {
font-family: var(--latin); font-weight: 700; font-size: 72px; line-height: 1;
font-family: var(--latin); font-weight: 700; font-size: 66px; line-height: 1;
color: var(--surface-3);
}
.step h3 { margin-top: 14px; font-size: 19px; font-weight: 600; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.step h3 { margin-top: 12px; font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.step p { margin-top: 6px; font-size: 14.5px; color: var(--text-2); max-width: 26em; }
@media (max-width: 820px) { .steps { grid-template-columns: 1fr; gap: 40px; } }
/* ── 平台 ── */
.platforms { display: flex; flex-wrap: wrap; gap: 12px; margin: 48px 0 96px; }
.platform {
display: inline-flex; align-items: center; gap: 9px;
padding: 12px 22px; border: 1px solid var(--border-1); border-radius: 999px;
background: var(--card); font-size: 15px; font-weight: 500; box-shadow: var(--shadow-card);
/* ── 平台(卡片) ── */
.plat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin: 48px 0 92px; }
@media (max-width: 820px) { .plat-grid { grid-template-columns: 1fr; } }
.plat {
border: 1px solid var(--border-1); border-radius: 14px; background: var(--card);
padding: 26px 26px 24px; box-shadow: var(--shadow-card);
}
.plat .os { display: inline-flex; align-items: center; gap: 12px; color: var(--text-2); }
.plat .os svg { color: var(--text-2); }
.plat h3 { margin-top: 16px; font-size: 17px; font-weight: 600; color: var(--text-1); display: flex; align-items: center; gap: 8px; }
.plat h3 .form { font-size: 13px; font-weight: 500; color: var(--text-3); }
.plat p { margin-top: 8px; font-size: 14px; color: var(--text-2); }
.plat .soon {
display: inline-block; margin-top: 14px; font-size: 12px; font-weight: 600;
color: var(--accent-text); background: var(--accent-soft);
padding: 2px 10px; border-radius: 999px;
}
.platform svg { color: var(--text-2); }
.platform .soon { font-size: 12px; color: var(--text-3); }
/* ── 定价 ── */
.pricing { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin: 56px 0 28px; align-items: stretch; }
.pricing { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin: 52px 0 24px; align-items: stretch; }
.price-card {
background: var(--card); border: 1px solid var(--border-1); border-radius: 14px;
padding: 30px 28px 28px; display: flex; flex-direction: column; box-shadow: var(--shadow-card);
position: relative;
}
.price-card.hot { border: 1.5px solid var(--accent); }
.price-card.hot { border: 1.5px solid var(--accent); box-shadow: var(--shadow-window); }
.price-card .tag {
position: absolute; top: -11px; left: 24px;
font-size: 12px; font-weight: 600; padding: 2px 12px; border-radius: 999px;
background: var(--warning-soft); color: var(--warning);
}
.price-card.hot .tag { background: var(--accent); color: var(--text-on-accent); }
.price-card .minutes { font-size: 17px; font-weight: 600; }
.price-card .minutes .num { font-family: var(--latin); font-size: 22px; }
.price-card .price { margin-top: 14px; font-family: var(--latin); font-weight: 700; font-size: 56px; line-height: 1; }
.price-card .price { margin-top: 12px; font-family: var(--latin); font-weight: 700; font-size: 54px; line-height: 1; }
.price-card .price .cur { font-size: 24px; font-weight: 600; vertical-align: 14px; margin-right: 2px; }
.price-card .unit { margin-top: 8px; font-size: 13px; color: var(--text-3); }
.price-card .btn { margin-top: 26px; width: 100%; }
.pricing-note { text-align: center; font-size: 14px; color: var(--text-2); padding-bottom: 96px; }
.price-card .perks { margin-top: 20px; display: flex; flex-direction: column; gap: 9px; }
.price-card .perks li { list-style: none; display: flex; align-items: flex-start; gap: 8px; font-size: 13.5px; color: var(--text-2); }
.price-card .perks svg { color: var(--positive); flex: none; margin-top: 2px; }
.price-card .btn { margin-top: 24px; width: 100%; }
.pricing-note { text-align: center; font-size: 14px; color: var(--text-2); padding-bottom: 92px; }
.pricing-note .num { font-family: var(--latin); }
@media (max-width: 820px) { .pricing { grid-template-columns: 1fr; } }
/* ── 常见问题 ── */
.faq { max-width: 820px; margin: 44px auto 92px; border: 1px solid var(--border-1); border-radius: 14px; background: var(--card); overflow: hidden; }
.faq details { border-top: 1px solid var(--border-1); }
.faq details:first-child { border-top: none; }
.faq summary {
list-style: none; cursor: pointer; padding: 20px 24px;
display: flex; align-items: center; justify-content: space-between; gap: 16px;
font-size: 16px; font-weight: 500; color: var(--text-1);
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary .chev { color: var(--text-3); transition: transform 200ms var(--ease-out); flex: none; }
.faq details[open] summary .chev { transform: rotate(180deg); }
.faq details[open] summary { color: var(--accent-text); }
.faq .answer { padding: 0 24px 22px; font-size: 14.5px; color: var(--text-2); line-height: 1.7; max-width: 60ch; }
/* ── 尾部 CTA(墨色) ── */
.cta { background: var(--gray-950); color: var(--text-1); border-bottom: 1px solid var(--border-1); }
.cta-inner { padding: 110px 0; text-align: center; }
.cta-inner { padding: 104px 0; text-align: center; }
.cta .logo-big { color: var(--accent); }
.cta h2 { margin-top: 26px; font-size: clamp(34px, 5vw, 56px); font-weight: 700; letter-spacing: -0.01em; }
.cta p { margin-top: 14px; color: var(--text-2); font-size: 16px; }
.cta .btn { margin-top: 36px; }
.cta .cta-btns { margin-top: 36px; display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
.cta .microcopy { margin-top: 20px; font-size: 13px; color: var(--text-3); }
.cta .microcopy .num { font-family: var(--latin); }
/* ── 页脚 ── */
footer { background: var(--gray-950); color: var(--text-2); }
.footer-inner {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;
padding: 28px 0; font-size: 13px;
.footer-top {
display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px;
padding: 56px 0 40px; border-bottom: 1px solid var(--border-1);
}
.footer-inner .brand { color: var(--text-1); }
.footer-inner .brand svg { color: var(--accent); }
.footer-links { display: flex; gap: 22px; }
.footer-links a:hover { color: var(--text-1); }
@media (max-width: 720px) { .footer-top { grid-template-columns: 1fr 1fr; gap: 32px 20px; } }
.footer-brand .brand { color: var(--text-1); }
.footer-brand .brand svg { color: var(--accent); }
.footer-brand p { margin-top: 14px; font-size: 13.5px; color: var(--text-2); max-width: 24em; line-height: 1.6; }
.footer-col h4 { font-size: 13px; font-weight: 600; color: var(--text-1); letter-spacing: .04em; }
.footer-col a { display: block; margin-top: 12px; font-size: 13.5px; color: var(--text-2); }
.footer-col a:hover { color: var(--text-1); }
.footer-bottom {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;
padding: 22px 0; font-size: 12.5px; color: var(--text-3);
}
.footer-bottom .legal { display: flex; gap: 18px; flex-wrap: wrap; }
.footer-bottom a:hover { color: var(--text-1); }
</style>
</head>
<body>
@@ -278,13 +393,15 @@ footer { background: var(--gray-950); color: var(--text-2); }
<!-- ════════ 导航 ════════ -->
<nav>
<div class="frame nav-inner">
<a class="brand" href="#">
<a class="brand" href="#top">
<svg width="26" height="26" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"><circle cx="24" cy="24" r="18"/><path d="M17 20.5v7"/><path d="M24 16.5v15"/><path d="M31 20.5v7"/></svg>
<span class="name">dudu</span>
</a>
<div class="nav-links">
<a href="#features">特性</a>
<a class="only-wide" href="#scenes">场景</a>
<a href="#how">怎么用</a>
<a class="only-wide" href="#faq">常见问题</a>
<a href="#pricing">价格</a>
<a class="btn btn-sm btn-secondary" href="#download">下载</a>
</div>
@@ -292,9 +409,10 @@ footer { background: var(--gray-950); color: var(--text-2); }
</nav>
<!-- ════════ Hero ════════ -->
<header class="hero">
<header class="hero" id="top">
<div class="frame hero-grid">
<div>
<span class="eyebrow"><span class="pip"></span>五端一套账号 · 时长永不过期</span>
<h1>按住说话<br><span class="accent-word">文字上屏</span></h1>
<div class="sub">
<span>大模型识别</span><span class="dot">·</span>
@@ -306,7 +424,7 @@ footer { background: var(--gray-950); color: var(--text-2); }
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
免费下载
</a>
<span class="hero-note">每天免费 <span class="num">3</span> 分钟 · 微信登录即用</span>
<span class="hero-note">每天免费 <span class="num">3</span> 分钟 · 微信或邮箱登录即用</span>
</div>
</div>
@@ -351,6 +469,10 @@ footer { background: var(--gray-950); color: var(--text-2); }
<div class="num">3<small>分钟/天</small></div>
<div class="label">免费试用 · 不需要先付费</div>
</div>
<div class="metric reveal" style="transition-delay:180ms">
<div class="num">0<small>过期</small></div>
<div class="label">买的时长永久有效</div>
</div>
</div>
</div>
@@ -375,7 +497,86 @@ footer { background: var(--gray-950); color: var(--text-2); }
<div class="feature">
<span class="icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg></span>
<h3>哪里都能用</h3>
<p>桌面端快捷键全局可用,手机上是真正的系统键盘——微信、文档、代码编辑器,焦点在哪写到哪</p>
<p>桌面端快捷键全局可用,手机上是真正的系统键盘——微信、文档、代码,焦点在哪写到哪</p>
</div>
<div class="feature">
<span class="icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 9-6-6-6 6"/><path d="M12 3v14"/><path d="M5 21h14"/></svg></span>
<h3>上滑随时取消</h3>
<p>话说错、想重来,按住时上滑就丢弃这一句,不会打断你的思路,也不会写错内容</p>
</div>
<div class="feature">
<span class="icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.57 3.9a2 2 0 0 0 1.66 0l8.57-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg></span>
<h3>浮层不抢焦点</h3>
<p>识别浮层叠在当前应用之上,不夺走光标、不切走窗口——你正在做的事不受一点打断</p>
</div>
<div class="feature">
<span class="icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg></span>
<h3>中英混说不卡壳</h3>
<p>“把这个 pull request 合进 main”——中英文混着说、技术词穿插着讲,照样认得准、断得对</p>
</div>
</div>
</div>
</section>
<!-- ════════ 场景 ════════ -->
<section id="scenes">
<div class="frame">
<div class="section-head reveal">
<div class="scene-head">
<div>
<div class="kicker">用得上的时候</div>
<h2>需要动嘴的地方<br>就少动一次手</h2>
</div>
<div class="speed-badge">
<div class="big">3×</div>
<div class="cap">口述比打字约快</div>
</div>
</div>
</div>
<div class="scenes reveal">
<div class="scene">
<div class="top">
<span class="sicon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></svg></span>
<h3>写周报、长文档、邮件</h3>
</div>
<p>先把想法一口气说成初稿,再动手改。想到哪说到哪,标点和分段它替你排好。</p>
<div class="eg">
<span class="say"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>你说</span>
<div class="quote">“这周主要推进了识别延迟优化,下周开始做支付联调。”</div>
</div>
</div>
<div class="scene">
<div class="top">
<span class="sicon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
<h3>微信、消息、评论</h3>
</div>
<p>走路、做饭、通勤,腾不出两只手打字的时候,按住说完就发,长消息也不怕。</p>
<div class="eg">
<span class="say"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>你说</span>
<div class="quote">“我大概晚十分钟到,你们先点,给我留个 <b>靠窗</b> 的位置。”</div>
</div>
</div>
<div class="scene">
<div class="top">
<span class="sicon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg></span>
<h3>代码注释、提交信息</h3>
</div>
<p>中英混说不卡壳,专有名词认得准,写注释和 commit message 时不用来回切输入法。</p>
<div class="eg">
<span class="say"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>你说</span>
<div class="quote">“修复 <b>WebSocket</b> 重连后丢帧的问题。”</div>
</div>
</div>
<div class="scene">
<div class="top">
<span class="sicon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 14c.2-1 .7-1.7 1.5-2.5C17.7 10.3 18 9 18 7.5a6 6 0 0 0-12 0c0 1.5.5 2.8 1.5 4 .8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/></svg></span>
<h3>会议纪要、灵感速记</h3>
</div>
<p>开会随手记要点,脑子里冒出来的想法,说出来就落进备忘录,不怕它跑掉。</p>
<div class="eg">
<span class="say"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>你说</span>
<div class="quote">“下周三之前把三个方案的对比整理出来,周四评审。”</div>
</div>
</div>
</div>
</div>
@@ -408,19 +609,69 @@ footer { background: var(--gray-950); color: var(--text-2); }
</div>
</section>
<!-- ════════ 隐私与信任 ════════ -->
<section class="trust" id="trust" data-theme="dark">
<div class="frame">
<div class="section-head reveal">
<div class="kicker">隐私与信任</div>
<h2>它一直在偷听?<br>不,只在你按住的那一刻</h2>
<p class="lead">语音输入法听得见你说的每句话,所以我们把边界划得很清楚——录音、数据、计费,都不含糊。</p>
</div>
<div class="priv-grid reveal">
<div class="priv">
<span class="picon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg></span>
<h3>只在按住时录音</h3>
<p>松开即停,没有后台常驻监听。不按快捷键,麦克风就是关着的。</p>
</div>
<div class="priv">
<span class="picon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg></span>
<h3>音频用完即弃</h3>
<p>识别完成后音频即丢弃,不留存、不用于训练模型。我们要的是文字,不是你的声音。</p>
</div>
<div class="priv">
<span class="picon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/></svg></span>
<h3>文字只往光标处写</h3>
<p>dudu 把识别结果注入到你的输入框,不读取、不上传你在别处输入的任何内容。</p>
</div>
<div class="priv">
<span class="picon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 17.5v-11"/></svg></span>
<h3>按时长透明计费</h3>
<p>用多少识别时长扣多少,不订阅、不自动续费。买的时长永久有效,换设备也带得走。</p>
</div>
</div>
</div>
</section>
<!-- ════════ 平台 ════════ -->
<section id="download">
<div class="frame">
<div class="section-head reveal">
<div class="kicker">全平台</div>
<h2>你的每一台设备</h2>
<h2>你的每一台设备<br>共用一个账号和时长</h2>
</div>
<div class="platforms reveal">
<span class="platform"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>Mac</span>
<span class="platform"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>Windows</span>
<span class="platform"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2"/><path d="M12 18h.01"/></svg>iPhone<span class="soon">键盘扩展</span></span>
<span class="platform"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="22" x="3" y="1" rx="2"/><path d="M12 19h.01"/></svg>iPad</span>
<span class="platform"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2"/><path d="M12 18h.01"/></svg>Android<span class="soon">系统输入法</span></span>
<div class="plat-grid reveal">
<div class="plat">
<div class="os">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>
</div>
<h3>桌面 <span class="form">Mac · Windows</span></h3>
<p>全局快捷键 push-to-talk,任意输入框旁按住说话;识别浮层不抢焦点,不打断当前窗口。</p>
</div>
<div class="plat">
<div class="os">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2"/><path d="M12 18h.01"/></svg>
</div>
<h3>手机 <span class="form">iPhone · Android</span></h3>
<p>作为系统键盘接入,凡是能调出键盘的应用都能用。按住大麦克风说话,上滑取消。</p>
<span class="soon">陆续上架中</span>
</div>
<div class="plat">
<div class="os">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="22" x="3" y="1" rx="2"/><path d="M12 19h.01"/></svg>
</div>
<h3>平板 <span class="form">iPad</span></h3>
<p>键盘布局随屏幕自适应,横竖屏都顺手。和手机、桌面同一套账号,时长互通。</p>
</div>
</div>
</div>
</section>
@@ -437,6 +688,10 @@ footer { background: var(--gray-950); color: var(--text-2); }
<div class="minutes"><span class="num">100</span> 分钟</div>
<div class="price"><span class="cur">¥</span>9</div>
<div class="unit">约 ¥0.09 / 分钟</div>
<ul class="perks">
<li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>先试后买,随时够用</li>
<li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>时长永久有效</li>
</ul>
<a class="btn btn-lg btn-secondary" href="#download">选这档</a>
</div>
<div class="price-card hot">
@@ -444,6 +699,10 @@ footer { background: var(--gray-950); color: var(--text-2); }
<div class="minutes"><span class="num">500</span> 分钟</div>
<div class="price"><span class="cur">¥</span>39</div>
<div class="unit">约 ¥0.078 / 分钟</div>
<ul class="perks">
<li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>日常口述一两个月的量</li>
<li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>五端通用,一处购买处处能用</li>
</ul>
<a class="btn btn-lg btn-primary" href="#download">选这档</a>
</div>
<div class="price-card">
@@ -451,10 +710,58 @@ footer { background: var(--gray-950); color: var(--text-2); }
<div class="minutes"><span class="num">2000</span> 分钟</div>
<div class="price"><span class="cur">¥</span>129</div>
<div class="unit">约 ¥0.065 / 分钟</div>
<ul class="perks">
<li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>重度使用最划算</li>
<li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>时长永久有效</li>
</ul>
<a class="btn btn-lg btn-secondary" href="#download">选这档</a>
</div>
</div>
<div class="pricing-note">每天另有 <span class="num">3</span> 分钟免费试用 · 微信支付,到账即用 · 时长永不过期</div>
<div class="pricing-note">每天另有 <span class="num">3</span> 分钟免费试用 · 微信支付,到账即用 · 时长永不过期,换设备照样能用</div>
</div>
</section>
<!-- ════════ 常见问题 ════════ -->
<section id="faq">
<div class="frame">
<div class="section-head center reveal">
<div class="kicker">常见问题</div>
<h2>你可能想先问的</h2>
</div>
<div class="faq reveal">
<details open>
<summary>需要联网吗?<span class="chev"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span></summary>
<div class="answer">需要。识别由云端大模型完成,所以要联网使用。离线时会明确提示,不会静默失败或写错内容。</div>
</details>
<details>
<summary>我的语音会被保存吗?<span class="chev"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span></summary>
<div class="answer">不会。音频在识别完成后即丢弃,既不留存、也不用于训练模型。dudu 只把结果文字写进你的输入框。</div>
</details>
<details>
<summary>识别不准怎么办?<span class="chev"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span></summary>
<div class="answer">大模型对中英混说、专有名词、口语表达都做了优化,标点自动排好。万一说错了,按住时上滑就能取消这一句,重说即可,不会打断你正在做的事。</div>
</details>
<details>
<summary>换电脑或手机,买的时长还在吗?<span class="chev"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span></summary>
<div class="answer">在。时长绑定你的账号,用微信或邮箱登录后,Mac、Windows、iPhone、iPad、Android 五端通用,余额实时同步。</div>
</details>
<details>
<summary>支持哪些应用和输入框?<span class="chev"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span></summary>
<div class="answer">桌面端几乎所有可输入的地方都能用——浏览器、文档、聊天工具、代码编辑器。手机上作为系统键盘接入,凡是能调出键盘的应用都支持。</div>
</details>
<details>
<summary>免费额度用完会怎样?<span class="chev"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span></summary>
<div class="answer">每天都有 3 分钟免费额度,次日自动恢复。想用得更多,可以购买时长包,按识别时长扣费,时长永不过期。</div>
</details>
<details>
<summary>和系统自带的语音输入有什么不同?<span class="chev"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span></summary>
<div class="answer">dudu 用云端大模型,长句、标点、中英混说更稳,桌面端还有不抢焦点的浮层;而且五端一套账号、时长互通,跨设备体验一致。</div>
</details>
<details>
<summary>支持方言吗?<span class="chev"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span></summary>
<div class="answer">目前以普通话为主,也支持标准粤语。更多方言的识别在逐步扩展中。</div>
</details>
</div>
</div>
</section>
@@ -463,24 +770,54 @@ footer { background: var(--gray-950); color: var(--text-2); }
<div class="frame cta-inner reveal">
<svg class="logo-big" width="64" height="64" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"><circle cx="24" cy="24" r="18"/><path d="M17 20.5v7"/><path d="M24 16.5v15"/><path d="M31 20.5v7"/></svg>
<h2>从下一句话开始<br>用说的</h2>
<p>下载 dudu,微信扫码登录,每天免费 3 分钟</p>
<a class="btn btn-lg btn-primary" href="#download">免费下载 dudu</a>
<p>下载 dudu,微信或邮箱登录,每天免费 3 分钟</p>
<div class="cta-btns">
<a class="btn btn-lg btn-primary" href="#download">免费下载 dudu</a>
<a class="btn btn-lg btn-secondary" href="#pricing">看看价格</a>
</div>
<div class="microcopy">无需先付费 · 时长永不过期 · 支持 <span class="num">5</span></div>
</div>
</div>
<!-- ════════ 页脚 ════════ -->
<footer data-theme="dark">
<div class="frame footer-inner">
<a class="brand" href="#">
<svg width="20" height="20" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"><circle cx="24" cy="24" r="18"/><path d="M17 20.5v7"/><path d="M24 16.5v15"/><path d="M31 20.5v7"/></svg>
<span class="name latin" style="font-size:15px;font-weight:600">dudu</span>
</a>
<div class="footer-links">
<a href="#">用户协议</a>
<a href="#">隐私政策</a>
<a href="#">联系我们</a>
<div class="frame">
<div class="footer-top">
<div class="footer-brand">
<a class="brand" href="#top">
<svg width="22" height="22" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"><circle cx="24" cy="24" r="18"/><path d="M17 20.5v7"/><path d="M24 16.5v15"/><path d="M31 20.5v7"/></svg>
<span class="name latin" style="font-size:17px;font-weight:600">dudu</span>
</a>
<p>按住说话,文字上屏。大模型识别的全平台语音输入法,五端一套账号,时长永不过期。</p>
</div>
<div class="footer-col">
<h4>产品</h4>
<a href="#features">特性</a>
<a href="#scenes">使用场景</a>
<a href="#how">怎么用</a>
<a href="#download">下载</a>
</div>
<div class="footer-col">
<h4>购买</h4>
<a href="#pricing">时长包价格</a>
<a href="#faq">常见问题</a>
<a href="#trust">隐私与信任</a>
</div>
<div class="footer-col">
<h4>支持</h4>
<a href="#">帮助中心</a>
<a href="#">联系我们</a>
<a href="#">更新日志</a>
</div>
</div>
<div class="footer-bottom">
<div>© 2026 dudu · 岩美北京技术有限公司</div>
<div class="legal">
<a href="#">用户协议</a>
<a href="#">隐私政策</a>
<a href="#">沪ICP备XXXXXXXX号</a>
</div>
</div>
<div>© 2026 dudu · 沪ICP备XXXXXXXX号</div>
</div>
</footer>