Files
dudu/design/ui_kits/mobile/KeyboardScreen.jsx
T
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

141 lines
7.6 KiB
React
Raw 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 移动端 · 语音键盘(键盘扩展)
// 微信聊天上下文 + 以语音为主的键盘:工具条 / 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 });