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>
This commit is contained in:
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -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
|
||||
|
||||
注:微信图标为简化气泡占位,正式包请使用微信官方资源。
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user