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
+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 });