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
+187
View File
@@ -0,0 +1,187 @@
// dudu 移动端 · 主 App 四屏:启用引导 / 登录 / 购买时长 / 我的
function MAppHeader({ title }) {
return (
<div style={{ textAlign: 'center', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)', padding: '4px 0 10px' }}>{title}</div>
);
}
/* ---------- ① 启用引导 ---------- */
function OnboardingScreen() {
const { Button } = window.DuduDesignSystem_2e0172;
const [step, setStep] = React.useState(0);
const steps = [
{ title: '添加键盘', desc: '设置 → 通用 → 键盘 → 添加新键盘,选择 dudu', icon: <DuMIcons.Keyboard size={26} /> },
{ title: '开启完全访问', desc: '语音识别需要联网,请允许完全访问', icon: <DuMIcons.Shield size={26} /> },
{ title: '试一试', desc: '在下面的输入框按住麦克风说一句话', icon: <DuMIcons.Mic size={26} /> },
];
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '4px 18px 18px' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, padding: '18px 0 6px' }}>
<span style={{ color: 'var(--accent)' }}><DuduMLogo size={44} /></span>
<div style={{ fontSize: 'var(--text-xl)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)', whiteSpace: 'nowrap' }}>三步开启 dudu</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>按住说话松开上屏</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 14 }}>
{steps.map((s, i) => {
const state = i < step ? 'done' : i === step ? 'active' : 'todo';
return (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '13px 14px',
borderRadius: 'var(--radius-lg)', background: 'var(--surface-card)',
border: state === 'active' ? '1.5px solid var(--accent)' : '1px solid var(--border-1)',
opacity: state === 'todo' ? 0.62 : 1,
transition: 'border-color var(--dur-fast) var(--ease-out), opacity var(--dur-fast) var(--ease-out)',
}}>
<span style={{
width: 40, height: 40, borderRadius: 'var(--radius-md)', flex: 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: state === 'done' ? 'var(--positive-soft)' : 'var(--accent-soft)',
color: state === 'done' ? 'var(--positive)' : 'var(--accent-text)',
}}>{state === 'done' ? <DuMIcons.Check size={20} /> : s.icon}</span>
<span style={{ minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{i + 1}. {s.title}</span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 2, lineHeight: 'var(--leading-normal)' }}>{s.desc}</span>
</span>
</div>
);
})}
</div>
<div style={{ marginTop: 'auto' }}>
<Button variant="primary" size="lg" block onClick={() => setStep((s) => (s + 1) % 4)}>
{step >= 3 ? '完成' : step === 2 ? '我说完了' : '去设置'}
</Button>
</div>
</div>
);
}
/* ---------- ② 登录 ---------- */
function LoginScreen() {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '4px 22px 22px' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<span style={{ color: 'var(--accent)' }}><DuduMLogo size={64} sw={3.2} /></span>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)', letterSpacing: 'var(--tracking-wide)' }}>dudu</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>大模型语音输入 · 说话即上屏</div>
</div>
<button type="button" style={{
height: 'var(--control-xl)', borderRadius: 'var(--radius-full)', border: 'none', cursor: 'pointer',
background: '#07C160', color: '#fff', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-semibold)',
fontFamily: 'var(--font-sans)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, width: '100%',
}}>
<DuMIcons.Wechat size={19} /> 微信一键登录
</button>
<div style={{ textAlign: 'center', fontSize: 'var(--text-xs)', color: 'var(--text-3)', marginTop: 12, lineHeight: 'var(--leading-normal)' }}>
登录即同意用户协议隐私政策
</div>
</div>
);
}
/* ---------- ③ 购买时长 ---------- */
function PaywallScreen() {
const { Button } = window.DuduDesignSystem_2e0172;
const [pack, setPack] = React.useState('m');
const PACKS = [
['s', '100 分钟', 9, '约 ¥0.09 / 分钟', ''],
['m', '500 分钟', 39, '约 ¥0.078 / 分钟', '省 13%'],
['l', '2000 分钟', 129, '约 ¥0.065 / 分钟', '省 28%'],
];
const price = PACKS.find((p) => p[0] === pack)[2];
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '4px 16px 16px', minHeight: 0, overflow: 'auto' }}>
<MAppHeader title="购买时长" />
{/* 余额卡 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)', flex: 'none' }}>
<span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)' }}>时长余额</span>
<span style={{ display: 'block', marginTop: 2, fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-bold)', color: 'var(--text-1)', fontFamily: 'var(--font-sans)' }}>472 <span style={{ fontSize: 'var(--text-xs)', fontWeight: 'var(--weight-regular)', color: 'var(--text-3)' }}>分钟</span></span>
</span>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)', textAlign: 'right', lineHeight: 'var(--leading-normal)' }}>时长不过期<br />每天另有 3 分钟免费试用</span>
</div>
{/* 时长包 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 12 }}>
{PACKS.map(([key, lab, p, desc, tag]) => {
const active = pack === key;
return (
<button key={key} type="button" onClick={() => setPack(key)} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', textAlign: 'left',
padding: '12px 14px', cursor: 'pointer', borderRadius: 'var(--radius-lg)', fontFamily: 'var(--font-sans)',
border: active ? '1.5px solid var(--accent)' : '1px solid var(--border-1)',
background: active ? 'var(--accent-soft)' : 'var(--surface-card)',
transition: 'border-color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out)',
}}>
<span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span style={{ fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{lab}</span>
{tag && <span style={{ fontSize: 10, padding: '1px 7px', borderRadius: 'var(--radius-full)', background: 'var(--warning-soft)', color: 'var(--warning)', fontWeight: 'var(--weight-medium)', whiteSpace: 'nowrap' }}>{tag}</span>}
</span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 2 }}>{desc}</span>
</span>
<span style={{ whiteSpace: 'nowrap', fontSize: 'var(--text-xl)', fontWeight: 'var(--weight-bold)', color: active ? 'var(--accent-text)' : 'var(--text-1)' }}>¥{p}</span>
</button>
);
})}
</div>
<div style={{ marginTop: 'auto', paddingTop: 14 }}>
<Button variant="primary" size="lg" block>微信支付 ¥{price}</Button>
<div style={{ textAlign: 'center', fontSize: 'var(--text-xs)', color: 'var(--text-3)', marginTop: 8 }}>支付后立即到账 · 时长不过期</div>
</div>
</div>
);
}
/* ---------- ④ 我的 ---------- */
function ProfileScreen() {
const { Badge, ProgressBar } = window.DuduDesignSystem_2e0172;
const perms = [
{ label: '麦克风权限', ok: true },
{ label: '键盘已添加', ok: true },
{ label: '完全访问', ok: false, hint: '去开启' },
];
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, padding: '4px 16px 16px', minHeight: 0, overflow: 'auto' }}>
<MAppHeader title="我的" />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 14px', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)' }}>
<span style={{ width: 46, height: 46, borderRadius: '50%', background: 'var(--accent-soft)', color: 'var(--accent-text)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
<DuMIcons.User size={22} />
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<span style={{ display: 'block', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>wang***</span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 2 }}>时长余额 472 分钟 · 不过期</span>
</span>
<Badge tone="green">余额充足</Badge>
</div>
<div style={{ padding: '14px 14px 16px', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginBottom: 8 }}>
<span>今日免费试用</span><span style={{ fontFamily: 'var(--font-mono)' }}>1 / 3 分钟</span>
</div>
<ProgressBar value={1} max={3} />
</div>
<div style={{ background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
{perms.map((p, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 14px', borderBottom: i < perms.length - 1 ? '1px solid var(--border-2)' : 'none', fontSize: 'var(--text-base)', color: 'var(--text-1)' }}>
<span>{p.label}</span>
{p.ok ? (
<span style={{ color: 'var(--positive)', display: 'inline-flex' }}><DuMIcons.Check size={16} /></span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 'var(--text-sm)', color: 'var(--danger)' }}>
{p.hint} <DuMIcons.ChevronRight size={14} />
</span>
)}
</div>
))}
</div>
</div>
);
}
Object.assign(window, { OnboardingScreen, LoginScreen, PaywallScreen, ProfileScreen });