Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 161c7fa0e8 | |||
| f8d3e0d1e4 | |||
| 4b50ff2ce6 | |||
| ccce39a83a | |||
| 4ec294be40 | |||
| 3e292ec1e0 | |||
| cef58e658b | |||
| 8266c575aa | |||
| 7dccd694e0 | |||
| ab6351ebb3 | |||
| 19281d9c42 | |||
| 0ca1caa460 | |||
| 7d155fa2de | |||
| 559e224301 | |||
| b914aebb00 | |||
| adfa1ef1e4 | |||
| 911ceca079 | |||
| ddedef9605 | |||
| c9823ce454 |
@@ -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,无则 mock;mock 邮件固定验证码 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。**
|
||||
@@ -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(运行时用页面全局 React,unpkg 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' → 页面全局 React(unpkg 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
|
||||
运行时依赖:页面全局 React(ui_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(', ')})`);
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Vendored
+18
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"/>',
|
||||
};
|
||||
|
||||
/** 品牌 mark(48 网格、3.5 圆头——圆嘴 + 三条声波,与 assets/logo-mark.svg 同构) */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+306
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# 本地起桌面端(在 desktop/ 目录下运行):Tauri dev(Rust 系统层 + 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
|
||||
Generated
+1
@@ -954,6 +954,7 @@ dependencies = [
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
"parking_lot",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -2,6 +2,17 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "所有窗口的基础能力:核心 IPC(invoke/listen)+ 全局快捷键",
|
||||
"windows": ["settings", "overlay", "tray", "login", "feedback", "onboarding"],
|
||||
"permissions": ["core:default", "global-shortcut:default"]
|
||||
"windows": [
|
||||
"settings",
|
||||
"overlay",
|
||||
"tray",
|
||||
"login",
|
||||
"feedback",
|
||||
"onboarding"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"global-shortcut:default",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"所有窗口的基础能力:核心 IPC(invoke/listen)+ 全局快捷键","local":true,"windows":["settings","overlay","tray","login","feedback","onboarding"],"permissions":["core:default","global-shortcut:default"]}}
|
||||
{"default":{"identifier":"default","description":"所有窗口的基础能力:核心 IPC(invoke/listen)+ 全局快捷键","local":true,"windows":["settings","overlay","tray","login","feedback","onboarding"],"permissions":["core:default","global-shortcut:default","core:window:allow-start-dragging"]}}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 被 drop(stop 里)→ 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) {
|
||||
|
||||
@@ -57,10 +57,18 @@ pub fn inject_text(text: &str) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let modifier = Key::Control;
|
||||
|
||||
// 粘贴键 V:
|
||||
// macOS 用物理键码 9(kVK_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())?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"。
|
||||
// ⚠️⚠️ 上线前必须替换为真实 PayClient!MockPay 不验签,/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 |
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user