40760aa884
- 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>
141 lines
7.6 KiB
React
141 lines
7.6 KiB
React
// 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 });
|