Files
dudu/desktop/src/onboarding/main.jsx
T
wangjia 40760aa884
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s
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>
2026-06-12 00:38:37 +08:00

201 lines
8.4 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 首次启动引导(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 />);