Files
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

188 lines
11 KiB
React
Raw Permalink 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.
// 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 });