dudu MVP:五端语音输入法初始提交
- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调 - desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导) - android:Compose 主 App + IME(键盘内录音直传) - ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写) - design/design-pipeline:设计系统 + token 导出 iOS/Android 主题 - doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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 />);
|
||||
Reference in New Issue
Block a user