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 });
+140
View File
@@ -0,0 +1,140 @@
// dudu 移动端 · 语音键盘(键盘扩展)
// 微信聊天上下文 + 以语音为主的键盘:工具条 / partial 文本条 / 功能键行 / 大麦克风条
// 按住麦克风条:partial 流式显示 → 松开 final 上屏到宿主输入框
const KB_SENTENCES = [
'好的,我马上把文件发给你,大概十分钟之内。',
'今晚的聚餐改到八点,地址我发你定位。',
];
function KeyboardScreen({ account = 'ok' }) {
// account: ok(有余额) | trial(试用中) | guest(未登录) | quota(今日试用已用完)
const { MicBar } = window.DuduDesignSystem_2e0172;
const [recording, setRecording] = React.useState(false);
const [finalText, setFinalText] = React.useState('');
const [partialText, setPartialText] = React.useState('');
const [inputText, setInputText] = React.useState('');
const [seconds, setSeconds] = React.useState(0);
const [idx, setIdx] = React.useState(0);
const timers = React.useRef({});
const ref = React.useRef({});
ref.current = { finalText, partialText, idx };
const stop = () => {
clearInterval(timers.current.t); clearInterval(timers.current.s);
const { finalText: f, partialText: p, idx: i } = ref.current;
const committed = f + p;
if (committed) { setInputText((v) => v + committed); setIdx((i + 1) % KB_SENTENCES.length); }
setFinalText(''); setPartialText(''); setSeconds(0);
setRecording(false);
};
const start = () => {
if (account === 'guest' || account === 'quota') return;
setRecording(true); setFinalText(''); setPartialText(''); setSeconds(0);
const sentence = KB_SENTENCES[ref.current.idx];
let i = 0;
timers.current.t = setInterval(() => {
i += 1;
if (i >= sentence.length) { clearInterval(timers.current.t); setFinalText(sentence); setPartialText(''); return; }
const lastPunct = Math.max(sentence.lastIndexOf('', i), sentence.lastIndexOf('。', i));
setFinalText(sentence.slice(0, lastPunct + 1));
setPartialText(sentence.slice(lastPunct + 1, i));
}, 95);
timers.current.s = setInterval(() => setSeconds((s) => s + 1), 1000);
};
React.useEffect(() => () => { clearInterval(timers.current.t); clearInterval(timers.current.s); }, []);
const micState = account === 'guest' ? 'disabled' : account === 'quota' ? 'quota' : recording ? 'recording' : 'idle';
const fmt = (s) => `00:${String(s).padStart(2, '0')}`;
const FnKey = ({ children, label, flex = 1, onClick }) => (
<button type="button" onClick={onClick} aria-label={label} style={{
flex, height: 40, border: 'none', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
background: 'var(--surface-card)', color: 'var(--text-1)', boxShadow: 'var(--shadow-key)',
fontFamily: 'var(--font-sans)', fontSize: 'var(--text-md)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4,
}}>{children}</button>
);
return (
<React.Fragment>
{/* 宿主 App:微信聊天 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ textAlign: 'center', fontSize: 'var(--text-sm)', color: 'var(--text-2)', padding: '4px 0 8px', borderBottom: '1px solid var(--border-1)' }}>张三</div>
<div style={{ flex: 1, padding: '12px 12px 8px', display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden', justifyContent: 'flex-end' }}>
<div style={{ alignSelf: 'flex-start', maxWidth: '78%', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: '4px 12px 12px 12px', padding: '8px 11px', fontSize: 'var(--text-base)', color: 'var(--text-1)', lineHeight: 'var(--leading-normal)' }}>
上次说的那份材料方便发我一下吗
</div>
<div style={{ alignSelf: 'flex-end', maxWidth: '78%', background: 'var(--accent)', color: 'var(--text-on-accent)', borderRadius: '12px 4px 12px 12px', padding: '8px 11px', fontSize: 'var(--text-base)', lineHeight: 'var(--leading-normal)' }}>
稍等我看一下
</div>
</div>
{/* 宿主输入框 */}
<div style={{ padding: '0 10px 10px' }}>
<div style={{
minHeight: 38, boxSizing: 'border-box', padding: '8px 12px', borderRadius: 'var(--radius-md)',
background: 'var(--surface-card)', border: recording ? '1.5px solid var(--accent)' : '1px solid var(--border-1)',
fontSize: 'var(--text-base)', color: inputText ? 'var(--text-1)' : 'var(--text-3)', lineHeight: 'var(--leading-normal)',
}}>
{inputText || '发消息…'}
<span style={{ color: 'var(--accent)', animation: 'dd-caret 1.1s steps(1) infinite' }}></span>
</div>
</div>
</div>
{/* ===== 键盘扩展 ===== */}
<div style={{ flex: 'none', background: 'var(--surface-2)', borderTop: '1px solid var(--border-1)', padding: '6px 8px 10px' }}>
{/* 工具条 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '2px 6px 8px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 'var(--text-xs)', color: 'var(--text-2)' }}>
<DuduMLogo size={13} /> dudu 语音输入
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 'var(--text-xs)', fontFamily: recording ? 'var(--font-mono)' : 'var(--font-sans)', color: recording ? 'var(--positive)' : account === 'quota' ? 'var(--warning)' : 'var(--text-3)' }}>
{account === 'guest' ? '未登录'
: account === 'quota' ? '今日试用已用完'
: recording ? fmt(seconds)
: account === 'trial' ? '试用 · 今日剩 2 分钟'
: <React.Fragment>余额 472 分钟 <DuMIcons.Check size={11} /></React.Fragment>}
</span>
</div>
{/* partial 文本条(录音中替代功能键行) */}
{recording ? (
<div style={{
margin: '0 2px 8px', minHeight: 40, boxSizing: 'border-box', padding: '9px 12px',
background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-base)', lineHeight: 'var(--leading-normal)', color: 'var(--text-1)',
}}>
{finalText}<span style={{ color: 'var(--text-3)' }}>{partialText}</span>
</div>
) : (
<div style={{ display: 'flex', gap: 6, margin: '0 2px 8px' }}>
<FnKey label="切换键盘" flex={1.3}><DuMIcons.Globe size={17} /></FnKey>
<FnKey label="逗号" onClick={() => setInputText((v) => v + '')}></FnKey>
<FnKey label="句号" onClick={() => setInputText((v) => v + '。')}></FnKey>
<FnKey label="退格" flex={1.3} onClick={() => setInputText((v) => v.slice(0, -1))}><DuMIcons.Backspace size={17} /></FnKey>
<FnKey label="发送" flex={1.3} onClick={() => setInputText('')}><DuMIcons.Enter size={16} /></FnKey>
</div>
)}
{/* 大麦克风条 */}
<div style={{ padding: '0 2px' }}>
<MicBar
state={micState}
onPointerDown={start}
onPointerUp={stop}
onPointerLeave={() => recording && stop()}
/>
</div>
{recording && (
<div style={{ textAlign: 'center', fontSize: 'var(--text-xs)', color: 'var(--text-3)', paddingTop: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4 }}>
<DuMIcons.ArrowUp size={11} /> 上滑取消
</div>
)}
</div>
</React.Fragment>
);
}
Object.assign(window, { KeyboardScreen });
+103
View File
@@ -0,0 +1,103 @@
// dudu 移动端 UI kit · 共享:Lucide 图标子集、DuduLogo、手机框
function DuMIcon({ size = 16, sw = 2, children, ...rest }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...rest}>
{children}
</svg>
);
}
const DuMIcons = {
Mic: (p) => (
<DuMIcon {...p}>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" x2="12" y1="19" y2="22"></line>
</DuMIcon>
),
Globe: (p) => (
<DuMIcon {...p}>
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path>
<path d="M2 12h20"></path>
</DuMIcon>
),
Backspace: (p) => (
<DuMIcon {...p}>
<path d="M20 5H9l-7 7 7 7h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Z"></path>
<line x1="18" x2="12" y1="9" y2="15"></line>
<line x1="12" x2="18" y1="9" y2="15"></line>
</DuMIcon>
),
Enter: (p) => (
<DuMIcon {...p}>
<polyline points="9 10 4 15 9 20"></polyline>
<path d="M20 4v7a4 4 0 0 1-4 4H4"></path>
</DuMIcon>
),
Check: (p) => <DuMIcon {...p}><path d="M20 6 9 17l-5-5"></path></DuMIcon>,
ChevronRight: (p) => <DuMIcon {...p}><path d="m9 18 6-6-6-6"></path></DuMIcon>,
User: (p) => (
<DuMIcon {...p}>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</DuMIcon>
),
Keyboard: (p) => (
<DuMIcon {...p}>
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
<path d="M6 8h.01"></path><path d="M10 8h.01"></path><path d="M14 8h.01"></path><path d="M18 8h.01"></path>
<path d="M6 12h.01"></path><path d="M10 12h.01"></path><path d="M14 12h.01"></path><path d="M18 12h.01"></path>
<path d="M7 16h10"></path>
</DuMIcon>
),
Shield: (p) => (
<DuMIcon {...p}>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path>
</DuMIcon>
),
ArrowUp: (p) => <DuMIcon {...p}><path d="m5 12 7-7 7 7"></path><path d="M12 19V5"></path></DuMIcon>,
Wechat: (p) => (
/* 简化对话气泡(代微信图标占位,正式包内用官方资源)*/
<DuMIcon {...p}>
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path>
</DuMIcon>
),
};
function DuduMLogo({ size = 20, sw = 3.5 }) {
return (
<svg width={size} height={size} viewBox="0 0 48 48" fill="none" stroke="currentColor"
strokeWidth={sw} strokeLinecap="round" aria-hidden="true">
<circle cx="24" cy="24" r="18"></circle>
<path d="M17 20.5v7"></path>
<path d="M24 16.5v15"></path>
<path d="M31 20.5v7"></path>
</svg>
);
}
// 中性手机框:深色边框 + 打孔/刘海,内容区由调用方填充
function PhoneFrame({ width = 312, height = 596, children }) {
return (
<div style={{
width, height, boxSizing: 'border-box', background: '#0B0C10', borderRadius: 38,
padding: 9, boxShadow: 'var(--shadow-window)', flex: 'none',
}}>
<div style={{
width: '100%', height: '100%', boxSizing: 'border-box', background: 'var(--bg-app)',
borderRadius: 30, overflow: 'hidden', display: 'flex', flexDirection: 'column', position: 'relative',
}}>
{/* 状态栏 */}
<div style={{ height: 30, flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 84, height: 18, background: '#0B0C10', borderRadius: 10 }}></div>
</div>
{children}
</div>
</div>
);
}
Object.assign(window, { DuMIcon, DuMIcons, DuduMLogo, PhoneFrame });
+30
View File
@@ -0,0 +1,30 @@
# dudu 移动端 UI Kit
移动端(iOS / Android 原生键盘扩展 + 主 App)高保真交互演示,对应 MVP 设计方案 §8.2–8.3。
## 左:键盘扩展
微信聊天宿主上下文 + 以语音为主的键盘:
- 工具条(dudu 标识 + 余额/试用/计时状态)
- 功能键行:切换键盘 / ,/ 。/ 退格 / 发送(语音为主,不做 26 键)
- 大麦克风条(`MicBar`,56px 胶囊):**按住说话** → partial 文本条流式刷新 → **松开** final 上屏到宿主输入框;录音中显示"上滑取消"
- 顶部状态 chips 切换四态:有余额 / 试用中(每天免费 3 分钟)/ 未登录(按键置灰)/ 试用已用完(橙色引导购买时长)
## 右:主 App 四屏
- **启用引导**:三步卡片(添加键盘 → 完全访问 → 试一试),点主按钮推进
- **登录**:logo + 微信一键登录(微信品牌绿 #07C160
- **购买时长**:时长余额卡 + 时长包(100/500/2000 分钟,不过期)+ 微信支付
- **我的**:账号卡(时长余额)+ 今日免费试用 ProgressBar + 权限自检(异常红色"去开启")
右上角切换 Light / Dark。键盘面板与 App 均随主题;键帽用 `--surface-card` + `--shadow-key` 底边。
## 文件
- `index.html` — 入口(两台手机并排 + 状态 chips + 主题)
- `MobileShared.jsx` — Lucide 图标子集 · DuduMLogo · PhoneFrame
- `KeyboardScreen.jsx` — 键盘扩展全态
- `AppScreens.jsx` — Onboarding / Login / Paywall / Profile
注:微信图标为简化气泡占位,正式包请使用微信官方资源。
+131
View File
@@ -0,0 +1,131 @@
<!-- @dsCard group="移动端" viewport="1100x720" name="移动端 UI Kit" subtitle="语音键盘(按住说话)+ 主 App 四屏 — 可交互,Light/Dark" -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dudu 移动端 UI Kit</title>
<link rel="stylesheet" href="../../styles.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="../../_ds_bundle.js"></script>
<style>
html, body { margin: 0; height: 100%; }
body { font-family: var(--font-sans); }
#root { height: 100%; }
@keyframes dd-caret { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="./MobileShared.jsx"></script>
<script type="text/babel" src="./KeyboardScreen.jsx"></script>
<script type="text/babel" src="./AppScreens.jsx"></script>
<script type="text/babel">
function Chip({ active, onClick, children }) {
return (
<button type="button" onClick={onClick} style={{
height: 28, padding: '0 13px', borderRadius: 'var(--radius-full)', cursor: 'pointer', whiteSpace: 'nowrap',
border: active ? '1px solid transparent' : '1px solid var(--border-1)',
background: active ? 'var(--accent-soft)' : 'var(--surface-card)',
color: active ? 'var(--accent-text)' : 'var(--text-2)',
fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)',
fontWeight: active ? 'var(--weight-medium)' : 'var(--weight-regular)',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{children}</button>
);
}
function MobileKit() {
const [override, setOverride] = React.useState(null); // null = 跟随系统
const [systemDark, setSystemDark] = React.useState(
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
);
React.useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const fn = (e) => setSystemDark(e.matches);
mq.addEventListener('change', fn);
return () => mq.removeEventListener('change', fn);
}, []);
const dark = override === null ? systemDark : override;
const [account, setAccount] = React.useState('ok'); // 键盘状态
const [appScreen, setAppScreen] = React.useState('onboarding');
const APP_SCREENS = [
['onboarding', '启用引导'],
['login', '登录'],
['paywall', '购买时长'],
['profile', '我的'],
];
return (
<div data-theme={dark ? 'dark' : undefined} style={{
height: '100%', display: 'flex', flexDirection: 'column',
background: 'var(--bg-app)', transition: 'background var(--dur-base) var(--ease-out)',
}}>
{/* 顶栏 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '12px 20px',
borderBottom: '1px solid var(--border-1)', background: 'var(--surface-card)',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 9, color: 'var(--text-1)', whiteSpace: 'nowrap' }}>
<span style={{ color: 'var(--accent)', display: 'inline-flex' }}><DuduMLogo size={22} /></span>
<span style={{ fontWeight: 'var(--weight-semibold)', fontSize: 'var(--text-base)', letterSpacing: 'var(--tracking-wide)' }}>dudu</span>
<span style={{ fontSize: 'var(--text-sm)', color: 'var(--text-3)' }}>移动端 · iOS / Android</span>
</span>
<button type="button" onClick={() => setOverride(!dark)} aria-label="切换深浅模式" style={{
marginLeft: 'auto', width: 32, height: 32, borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-1)', background: 'var(--surface-card)', color: 'var(--text-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
}}>
{dark ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2"></path><path d="M12 20v2"></path><path d="m4.93 4.93 1.41 1.41"></path><path d="m17.66 17.66 1.41 1.41"></path><path d="M2 12h2"></path><path d="M20 12h2"></path><path d="m6.34 17.66-1.41 1.41"></path><path d="m19.07 4.93-1.41 1.41"></path></svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path></svg>
)}
</button>
</div>
{/* 舞台:键盘 + 主 App 并排 */}
<div style={{ flex: 1, display: 'flex', overflow: 'auto' }}>
<div style={{ margin: 'auto', display: 'flex', gap: 56, alignItems: 'flex-start', padding: 28 }}>
{/* 键盘扩展 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 6 }}>
<Chip active={account === 'ok'} onClick={() => setAccount('ok')}>有余额</Chip>
<Chip active={account === 'trial'} onClick={() => setAccount('trial')}>试用中</Chip>
<Chip active={account === 'guest'} onClick={() => setAccount('guest')}>未登录</Chip>
<Chip active={account === 'quota'} onClick={() => setAccount('quota')}>试用已用完</Chip>
</div>
<PhoneFrame>
<KeyboardScreen account={account} key={account} />
</PhoneFrame>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}>键盘扩展 按住麦克风条说话</span>
</div>
{/* 主 App */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 6 }}>
{APP_SCREENS.map(([key, label]) => (
<Chip key={key} active={appScreen === key} onClick={() => setAppScreen(key)}>{label}</Chip>
))}
</div>
<PhoneFrame>
{appScreen === 'onboarding' && <OnboardingScreen />}
{appScreen === 'login' && <LoginScreen />}
{appScreen === 'paywall' && <PaywallScreen />}
{appScreen === 'profile' && <ProfileScreen />}
</PhoneFrame>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}> App 登录 / 购买 / 用量</span>
</div>
</div>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<MobileKit />);
</script>
</body>
</html>