dudu MVP:五端语音输入法初始提交
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s

- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调
- desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导)
- android:Compose 主 App + IME(键盘内录音直传)
- ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写)
- design/design-pipeline:设计系统 + token 导出 iOS/Android 主题
- doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-12 00:38:37 +08:00
commit 40760aa884
252 changed files with 40789 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
// 首次启动引导(11F):欢迎 → 权限 → 登录 → 试一试。
// 规格见 doc/frontend-design.html 4.3;完成 / 关窗后置 onboarding_done。
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import '@dudu/design/styles.css';
import { Button } from '@dudu/design/components/core/Button';
import { Badge } from '@dudu/design/components/core/Badge';
import { HotkeyCombo } from '@dudu/design/components/core/Kbd';
import { invoke } from '../shared/tauri';
import { setThemePref } from '../shared/theme';
const isMac = navigator.platform.toUpperCase().includes('MAC');
const STEPS = ['welcome', 'perms', 'login', 'try'];
function Dots({ step }) {
return (
<div style={{ display: 'flex', gap: 7, justifyContent: 'center' }}>
{STEPS.map((s, i) => (
<span key={s} style={{
width: i === step ? 18 : 6, height: 6, borderRadius: 'var(--radius-full)',
background: i === step ? 'var(--accent)' : 'var(--border-2)',
transition: 'all var(--dur-base) var(--ease-out)',
}} />
))}
</div>
);
}
function Title({ children }) {
return <div style={{ fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)', textAlign: 'center' }}>{children}</div>;
}
function Desc({ children }) {
return <div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center', lineHeight: 1.7 }}>{children}</div>;
}
// 权限检测卡片:状态徽标 + 去开启按钮
function PermCard({ icon, title, desc, granted, onGrant }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px',
borderRadius: 'var(--radius-md)', border: '1px solid var(--border-1)', background: 'var(--surface-card)',
}}>
<span style={{
width: 36, height: 36, flex: 'none', borderRadius: 'var(--radius-sm)',
background: 'var(--accent-soft)', color: 'var(--accent-text)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{icon}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{title}</span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-3)', marginTop: 2 }}>{desc}</span>
</span>
{granted ? <Badge tone="green">已开启</Badge> : <Button variant="secondary" size="sm" onClick={onGrant}>去开启</Button>}
</div>
);
}
const MicIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>
</svg>
);
const KeyIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15.5 7.5 3 3L22 7l-3-3"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>
</svg>
);
function OnboardingApp() {
const [step, setStep] = useState(0);
const [micOk, setMicOk] = useState(false);
const [axOk, setAxOk] = useState(!isMac);
const [me, setMe] = useState(null);
useEffect(() => {
invoke('get_settings').then((s) => s && setThemePref(s.theme));
}, []);
// 权限步:辅助功能状态轮询(用户去系统设置开启后自动变绿)
useEffect(() => {
if (STEPS[step] !== 'perms' || !isMac) return undefined;
let live = true;
const tick = () => invoke('check_accessibility').then((ok) => live && setAxOk(!!ok));
tick();
const t = setInterval(tick, 1000);
return () => { live = false; clearInterval(t); };
}, [step]);
// 登录步:轮询账号,扫码成功后自动亮起
useEffect(() => {
if (STEPS[step] !== 'login') return undefined;
let live = true;
const tick = () => invoke('fetch_me').then((m) => live && m && setMe(m));
tick();
const t = setInterval(tick, 1500);
return () => { live = false; clearInterval(t); };
}, [step]);
const next = () => setStep((s) => Math.min(s + 1, STEPS.length - 1));
const prev = () => setStep((s) => Math.max(s - 1, 0));
const finish = () => invoke('finish_onboarding');
let body = null;
let primary = null;
const name = STEPS[step];
if (name === 'welcome') {
body = (
<>
<span style={{
width: 64, height: 64, borderRadius: 18, background: 'var(--accent)', color: 'var(--text-on-accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto',
}}>
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>
</svg>
</span>
<Title>dudu 语音输入</Title>
<Desc>按住快捷键说话松开文字就出现在光标处<br />全程比打字快三倍</Desc>
</>
);
primary = <Button variant="primary" block onClick={next}>继续</Button>;
} else if (name === 'perms') {
body = (
<>
<Title>先开两个权限</Title>
<Desc>{isMac ? '麦克风用来听你说话,辅助功能让文字上屏' : '麦克风用来听你说话'}</Desc>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 4 }}>
<PermCard icon={MicIcon} title="麦克风" desc="录入你的声音"
granted={micOk}
onGrant={() => invoke('request_microphone').then((ok) => setMicOk(!!ok))} />
{isMac && (
<PermCard icon={KeyIcon} title="辅助功能" desc="把识别结果输入到任意应用"
granted={axOk}
onGrant={() => invoke('open_accessibility_settings')} />
)}
</div>
</>
);
primary = <Button variant="primary" block onClick={next}>{micOk && axOk ? '下一步' : '稍后开启,先继续'}</Button>;
} else if (name === 'login') {
body = (
<>
<Title>登录同步时长</Title>
<Desc>微信扫码登录购买的时长跟着账号走<br />也可以先试用每天免费 3 分钟</Desc>
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 4 }}>
{me ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 'var(--text-sm)', color: 'var(--text-1)' }}>
{me.nickname_masked} <Badge tone="green">已登录</Badge>
</span>
) : (
<Button variant="secondary" onClick={() => invoke('open_login')}>微信扫码登录</Button>
)}
</div>
</>
);
primary = <Button variant="primary" block onClick={next}>{me ? '下一步' : '先试用,跳过'}</Button>;
} else {
body = (
<>
<Title>试一试</Title>
<Desc>点一下下面的输入框按住快捷键说句话松开看看</Desc>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<HotkeyCombo keys={['⌘', '⇧', 'Space']} />
</div>
<textarea
autoFocus
placeholder="文字会出现在这里"
style={{
width: '100%', height: 88, resize: 'none', boxSizing: 'border-box',
padding: '10px 12px', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-1)', background: 'var(--surface-card)',
color: 'var(--text-1)', fontSize: 'var(--text-base)', fontFamily: 'var(--font-sans)',
lineHeight: 1.6, outline: 'none',
}} />
</>
);
primary = <Button variant="primary" block onClick={finish}>开始使用</Button>;
}
return (
<div style={{
fontFamily: 'var(--font-sans)', background: 'var(--bg-app)', minHeight: '100vh',
boxSizing: 'border-box', padding: '34px 44px 24px',
display: 'flex', flexDirection: 'column', gap: 14,
}}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 14 }}>
{body}
</div>
<Dots step={step} />
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
{step > 0 && <Button variant="ghost" onClick={prev}>上一步</Button>}
<span style={{ flex: 1 }}>{primary}</span>
</div>
</div>
);
}
createRoot(document.getElementById('root')).render(<OnboardingApp />);