40760aa884
- 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>
201 lines
8.4 KiB
React
201 lines
8.4 KiB
React
// 首次启动引导(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 />);
|