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
+124
View File
@@ -0,0 +1,124 @@
// dudu 桌面端 UI kit · 共享元素:Lucide 图标子集、mac 窗口框、伪二维码
// 通过 window 共享给其余 text/babel 脚本
function DuduIcon({ d, 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 DuIcons = {
Mic: (p) => (
<DuduIcon {...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>
</DuduIcon>
),
Settings: (p) => (
<DuduIcon {...p}>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</DuduIcon>
),
Check: (p) => <DuduIcon {...p}><path d="M20 6 9 17l-5-5"></path></DuduIcon>,
X: (p) => <DuduIcon {...p}><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></DuduIcon>,
ChevronDown: (p) => <DuduIcon {...p}><path d="m6 9 6 6 6-6"></path></DuduIcon>,
ChevronRight: (p) => <DuduIcon {...p}><path d="m9 18 6-6-6-6"></path></DuduIcon>,
Sun: (p) => (
<DuduIcon {...p}>
<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>
</DuduIcon>
),
Moon: (p) => <DuduIcon {...p}><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path></DuduIcon>,
AudioLines: (p) => (
<DuduIcon {...p}>
<path d="M2 10v3"></path><path d="M6 6v11"></path><path d="M10 3v18"></path>
<path d="M14 8v7"></path><path d="M18 5v13"></path><path d="M22 10v3"></path>
</DuduIcon>
),
User: (p) => (
<DuduIcon {...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>
</DuduIcon>
),
LogOut: (p) => (
<DuduIcon {...p}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" x2="9" y1="12" y2="12"></line>
</DuduIcon>
),
};
// dudu 圆嘴 logocurrentColor 线条版)
function DuduLogo({ 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>
);
}
// mac 风格窗口(红绿灯 + 标题)
function MacWindow({ title, width = 460, children, style }) {
return (
<div style={{
width, background: 'var(--surface-card)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-1)', boxShadow: 'var(--shadow-window)',
overflow: 'hidden', fontFamily: 'var(--font-sans)', ...style,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '10px 14px',
background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)',
}}>
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FF5F57' }}></span>
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FEBC2E' }}></span>
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#28C840' }}></span>
<span style={{ marginLeft: 10, fontSize: 'var(--text-xs)', color: 'var(--text-2)', whiteSpace: 'nowrap' }}>{title}</span>
</div>
{children}
</div>
);
}
// 伪二维码占位(确定性图案,仅作占位 — 接入后端 /v1/auth/qr 后替换)
function FakeQr({ size = 148, seed = 7 }) {
const n = 17;
const cells = [];
let s = seed;
const rnd = () => { s = (s * 16807) % 2147483647; return s / 2147483647; };
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const corner = (x < 5 && y < 5) || (x > n - 6 && y < 5) || (x < 5 && y > n - 6);
let on;
if (corner) {
const cx = x > n - 6 ? x - (n - 5) : x, cy = y > n - 6 ? y - (n - 5) : y;
on = cx === 0 || cx === 4 || cy === 0 || cy === 4 || (cx > 1 && cx < 3 + 1 && cy > 1 && cy < 3 + 1);
} else {
on = rnd() > 0.52;
}
if (on) cells.push(<span key={x + '-' + y} style={{ gridColumn: x + 1, gridRow: y + 1, background: '#1B1E26', borderRadius: 1 }}></span>);
}
}
return (
<div style={{
width: size, height: size, padding: 10, boxSizing: 'border-box', background: '#fff',
borderRadius: 'var(--radius-md)', border: '1px solid var(--border-1)',
display: 'grid', gridTemplateColumns: `repeat(${n}, 1fr)`, gridTemplateRows: `repeat(${n}, 1fr)`, gap: 1,
}} aria-label="二维码占位">{cells}</div>
);
}
Object.assign(window, { DuduIcon, DuIcons, DuduLogo, MacWindow, FakeQr });
+108
View File
@@ -0,0 +1,108 @@
// dudu 桌面端 · 登录 / 购买窗口(点击走完整流程:扫码登录 → 选时长包 → 支付 → 完成)
function LoginPurchaseWindow() {
const { Button, Badge } = window.DuduDesignSystem_2e0172;
const [step, setStep] = React.useState('login'); // login | plans | pay | done
const [plan, setPlan] = React.useState('m');
const PACKS = {
s: { label: '100 分钟', price: 9, desc: '约 ¥0.09 / 分钟', tag: '' },
m: { label: '500 分钟', price: 39, desc: '约 ¥0.078 / 分钟', tag: '省 13%' },
l: { label: '2000 分钟', price: 129, desc: '约 ¥0.065 / 分钟', tag: '省 28%' },
};
const Center = ({ children }) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14, padding: '26px 24px 30px', fontFamily: 'var(--font-sans)' }}>
{children}
</div>
);
return (
<MacWindow title={step === 'login' ? 'dudu — 登录' : 'dudu — 购买时长'} width={400}>
{step === 'login' && (
<Center>
<span style={{ color: 'var(--accent)' }}><DuduLogo size={40} /></span>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>微信扫码登录</div>
<FakeQr seed={7} />
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center', lineHeight: 'var(--leading-normal)' }}>
打开微信扫一扫确认后自动登录
</div>
<Button variant="ghost" size="sm" onClick={() => setStep('plans')}>模拟手机已确认 </Button>
</Center>
)}
{step === 'plans' && (
<Center>
<div style={{ alignSelf: 'stretch', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>购买时长</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>
wang*** <Badge tone="blue">试用中</Badge>
</span>
</div>
<div style={{ alignSelf: 'stretch', display: 'flex', flexDirection: 'column', gap: 10 }}>
{Object.entries(PACKS).map(([key, p]) => {
const active = plan === key;
return (
<button key={key} type="button" onClick={() => setPlan(key)} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', textAlign: 'left',
padding: '13px 16px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
border: active ? '1.5px solid var(--accent)' : '1px solid var(--border-1)',
background: active ? 'var(--accent-soft)' : 'var(--surface-card)',
fontFamily: 'var(--font-sans)', 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: 8 }}>
<span style={{ fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{p.label}</span>
{p.tag && <span style={{ fontSize: 10, padding: '1px 7px', borderRadius: 'var(--radius-full)', background: 'var(--warning-soft)', color: 'var(--warning)', fontWeight: 'var(--weight-medium)' }}>{p.tag}</span>}
</span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 3 }}>{p.desc}</span>
</span>
<span style={{ color: active ? 'var(--accent-text)' : 'var(--text-1)', whiteSpace: 'nowrap' }}>
<span style={{ fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-bold)' }}>¥{p.price}</span>
</span>
</button>
);
})}
</div>
<div style={{ alignSelf: 'stretch', fontSize: 'var(--text-xs)', color: 'var(--text-3)', lineHeight: 'var(--leading-normal)' }}>
时长不过期 · 每天另有 3 分钟免费试用
</div>
<Button variant="primary" block onClick={() => setStep('pay')}>微信支付 ¥{PACKS[plan].price}</Button>
</Center>
)}
{step === 'pay' && (
<Center>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>微信扫码支付</div>
<div style={{ fontSize: 'var(--text-3xl)', fontWeight: 'var(--weight-bold)', color: 'var(--text-1)', fontFamily: 'var(--font-sans)' }}>
¥{PACKS[plan].price}<span style={{ fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-regular)', color: 'var(--text-3)' }}> · {PACKS[plan].label}时长包</span>
</div>
<FakeQr seed={23} />
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>支付完成后自动开通</div>
<div style={{ display: 'flex', gap: 10 }}>
<Button variant="secondary" size="sm" onClick={() => setStep('plans')}>返回</Button>
<Button variant="ghost" size="sm" onClick={() => setStep('done')}>模拟支付成功 </Button>
</div>
</Center>
)}
{step === 'done' && (
<Center>
<span style={{
width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)',
color: 'var(--positive)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}><DuIcons.Check size={28} sw={2.5} /></span>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>已到账 {PACKS[plan].label}</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>
时长余额 472 分钟 · 不过期 · 现在就按住 Space 试试
</div>
<Button variant="primary" onClick={() => setStep('login')}>开始使用</Button>
</Center>
)}
</MacWindow>
);
}
Object.assign(window, { LoginPurchaseWindow });
+108
View File
@@ -0,0 +1,108 @@
// dudu 桌面端 · 识别浮层演示
// 在仿真备忘录窗口上方演示完整 push-to-talk 流程:
// 按住按钮(= 按住 ⌘⇧Space)→ 聆听中 → partial 流式 → 松开 → final 注入输入框
const DEMO_SENTENCES = [
'帮我把这份周报整理一下,重点突出本周的进展和下周的计划。',
'好的,我马上把文件发给你,大概十分钟之内。',
'明天上午十点的会改到下午三点,记得同步给项目组。',
];
function OverlayDemo() {
const { RecognitionOverlay } = window.DuduDesignSystem_2e0172;
const [phase, setPhase] = React.useState('idle'); // idle | listening | streaming
const [finalText, setFinalText] = React.useState('');
const [partialText, setPartialText] = React.useState('');
const [docText, setDocText] = React.useState('');
const [sentenceIdx, setSentenceIdx] = React.useState(0);
const timerRef = React.useRef(null);
const stateRef = React.useRef({});
stateRef.current = { finalText, partialText, sentenceIdx };
const stop = React.useCallback(() => {
clearInterval(timerRef.current);
const { finalText: f, partialText: p, sentenceIdx: idx } = stateRef.current;
const committed = f + p;
if (committed) {
setDocText((d) => d + committed);
setSentenceIdx((idx + 1) % DEMO_SENTENCES.length);
}
setFinalText(''); setPartialText('');
setPhase('idle');
}, []);
const start = React.useCallback(() => {
if (timerRef.current) clearInterval(timerRef.current);
setPhase('listening'); setFinalText(''); setPartialText('');
const sentence = DEMO_SENTENCES[stateRef.current.sentenceIdx];
let i = 0;
setTimeout(() => setPhase((ph) => (ph === 'listening' ? 'streaming' : ph)), 420);
timerRef.current = setInterval(() => {
i += 1;
if (i >= sentence.length) { clearInterval(timerRef.current); setFinalText(sentence); setPartialText(''); return; }
// 模拟流式:句读处定稿(final),其余为 partial
const lastPunct = Math.max(sentence.lastIndexOf('', i), sentence.lastIndexOf('。', i));
setFinalText(sentence.slice(0, lastPunct + 1));
setPartialText(sentence.slice(lastPunct + 1, i));
}, 90);
}, []);
React.useEffect(() => () => clearInterval(timerRef.current), []);
const overlayState = phase === 'streaming' && (finalText || partialText) ? 'text' : 'listening';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 28 }}>
<div style={{ position: 'relative' }}>
<MacWindow title="备忘录 — 周报草稿" width={560}>
<div style={{ padding: '18px 20px', minHeight: 150 }}>
<div style={{ fontSize: 'var(--text-md)', lineHeight: 'var(--leading-loose)', color: 'var(--text-1)', minHeight: 96 }}>
{docText || <span style={{ color: 'var(--text-3)' }}>光标在这里 在任意应用中按住快捷键即可语音输入</span>}
<span style={{ color: 'var(--accent)', animation: 'dd-caret 1.1s steps(1) infinite' }}></span>
</div>
</div>
</MacWindow>
{phase !== 'idle' && (
<div style={{ position: 'absolute', left: '50%', top: '100%', transform: 'translate(-50%, -22px)', animation: 'dd-rise var(--dur-base) var(--ease-out)' }}>
<RecognitionOverlay
state={overlayState}
finalText={finalText}
partialText={partialText}
hint="松开 ⌘⇧Space 完成输入"
/>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginTop: phase === 'idle' ? 0 : 40 }}>
<button
type="button"
onPointerDown={start}
onPointerUp={stop}
onPointerLeave={() => phase !== 'idle' && stop()}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8, height: 'var(--control-lg)',
padding: '0 22px', borderRadius: 'var(--radius-full)', border: 'none', cursor: 'pointer',
background: phase === 'idle' ? 'var(--accent)' : 'var(--positive)',
color: 'var(--text-on-accent)', fontSize: 'var(--text-base)', fontWeight: 'var(--weight-semibold)',
fontFamily: 'var(--font-sans)', userSelect: 'none', WebkitUserSelect: 'none',
transition: 'background var(--dur-fast) var(--ease-out)',
}}
>
<DuIcons.Mic size={17} />
{phase === 'idle' ? '按住试一试(模拟按住 ⌘⇧Space)' : '松开完成输入'}
</button>
{docText && (
<button type="button" onClick={() => setDocText('')} style={{
height: 'var(--control-lg)', padding: '0 16px', borderRadius: 'var(--radius-full)',
border: '1px solid var(--border-1)', background: 'var(--surface-card)', color: 'var(--text-2)',
fontSize: 'var(--text-sm)', cursor: 'pointer', fontFamily: 'var(--font-sans)',
}}>清空</button>
)}
</div>
</div>
);
}
Object.assign(window, { OverlayDemo });
+27
View File
@@ -0,0 +1,27 @@
# dudu 桌面端 UI Kit
桌面端(Tauri 2Mac / Windows)四个界面的高保真交互演示,对应 MVP 设计方案 §8.1:
| Tab | 界面 | 交互 |
|---|---|---|
| 识别浮层 | 仿真备忘录窗口 + RecognitionOverlay | 按住按钮模拟 ⌘⇧Space:聆听中 → partial 流式 → 松开 final 注入 |
| 托盘菜单 | 常驻菜单栏下拉 | hover 高亮(accent 反白)|
| 设置 | 单页设置窗口 | Switch / 麦克风下拉 / 外观切换(驱动整页 Light/Dark|
| 登录 / 购买 | 微信扫码 → 选时长包 → 支付 → 完成 | 点击走完整流程 |
右上角月亮/太阳切换 Light / Dark;设置中"外观"同样生效(含跟随系统)。
## 设计要点
- 浮层:`--overlay-bg` 深色玻璃(两种模式不变),淡入 + 上移 4px(`--dur-base`),final 亮 / partial 灰。
- 一屏一个蓝色主按钮;托盘 hover 用 accent 反白是系统菜单惯例。
- 设置窗口宽 440,行高紧凑(`--control-sm` 控件),分组标题 12px。
- 二维码为占位图案(`FakeQr`),接入后端 `/v1/auth/qr` 后替换。
## 文件
- `index.html` — 入口(tabs + 主题状态)
- `DesktopShared.jsx` — Lucide 图标子集 · DuduLogo · MacWindow · FakeQr
- `OverlayDemo.jsx` — push-to-talk 全流程模拟
- `SettingsTray.jsx` — TrayMenu + SettingsWindow
- `LoginPurchase.jsx` — 登录/时长包/支付/完成 四步
+140
View File
@@ -0,0 +1,140 @@
// dudu 桌面端 · 系统托盘菜单 + 设置窗口
function TrayMenu() {
const { Badge } = window.DuduDesignSystem_2e0172;
const Item = ({ children, right, accent, onClick }) => {
const [hover, setHover] = React.useState(false);
return (
<div
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onClick={onClick}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 18,
padding: '7px 14px', cursor: 'default', fontSize: 'var(--text-sm)',
background: hover ? 'var(--accent)' : 'transparent',
color: hover ? 'var(--text-on-accent)' : (accent ? 'var(--text-1)' : 'var(--text-1)'),
transition: 'background var(--dur-fast) var(--ease-out)',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap' }}>{children}</span>
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'rgba(255,255,255,0.8)' : 'var(--text-3)', whiteSpace: 'nowrap' }}>{right}</span>}
</div>
);
};
const Sep = () => <div style={{ height: 1, background: 'var(--border-1)', margin: '4px 0' }}></div>;
return (
<div style={{
width: 248, padding: '5px 0', fontFamily: 'var(--font-sans)',
background: 'color-mix(in srgb, var(--surface-card) 96%, transparent)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
border: '1px solid var(--border-1)', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-menu)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 14px 6px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)', whiteSpace: 'nowrap' }}>
<DuduLogo size={17} /> dudu 语音输入
</span>
<Badge tone="green">已就绪</Badge>
</div>
<Sep />
<Item right={<DuIcons.Check size={13} />}>启用快捷键</Item>
<Item right="⌘⇧Space">按住说话</Item>
<Item>设置</Item>
<Item right="472 分钟">时长余额</Item>
<Sep />
<Item right="已登录">账号 wang***</Item>
<Item>检查更新</Item>
<Sep />
<Item>退出 dudu</Item>
</div>
);
}
function SettingsWindow({ theme, onThemeChange }) {
const { Switch, SettingRow, HotkeyCombo, Button, Badge, ProgressBar } = window.DuduDesignSystem_2e0172;
const [autostart, setAutostart] = React.useState(true);
const [sound, setSound] = React.useState(false);
const [mic, setMic] = React.useState('MacBook Pro 麦克风');
const Section = ({ title, children }) => (
<div style={{ padding: '4px 18px 8px' }}>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)', fontWeight: 'var(--weight-medium)', margin: '10px 0 2px', letterSpacing: 'var(--tracking-wide)' }}>{title}</div>
{children}
</div>
);
const themeOptions = [['system', '跟随系统'], ['light', '浅色'], ['dark', '深色']];
return (
<MacWindow title="dudu 设置" width={440}>
<Section title="输入">
<SettingRow label="说话快捷键" description="按住说话,松开上屏">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<HotkeyCombo keys={['⌘', '⇧', 'Space']} />
<Button variant="ghost" size="sm">修改</Button>
</span>
</SettingRow>
<SettingRow label="麦克风">
<span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
<select value={mic} onChange={(e) => setMic(e.target.value)} style={{
appearance: 'none', WebkitAppearance: 'none', height: 'var(--control-sm)',
padding: '0 30px 0 10px', borderRadius: 'var(--radius-sm)', border: '1px solid var(--border-1)',
background: 'var(--surface-card)', color: 'var(--text-1)', fontSize: 'var(--text-sm)', fontFamily: 'var(--font-sans)', cursor: 'pointer',
}}>
<option>MacBook Pro 麦克风</option>
<option>AirPods Pro</option>
<option>外接 USB 麦克风</option>
</select>
<span style={{ position: 'absolute', right: 8, pointerEvents: 'none', color: 'var(--text-3)', display: 'inline-flex' }}><DuIcons.ChevronDown size={13} /></span>
</span>
</SettingRow>
<SettingRow label="提示音" description="开始 / 完成时播放轻提示音">
<Switch checked={sound} onChange={setSound} />
</SettingRow>
</Section>
<div style={{ height: 1, background: 'var(--border-1)' }}></div>
<Section title="通用">
<SettingRow label="开机自启">
<Switch checked={autostart} onChange={setAutostart} />
</SettingRow>
<SettingRow label="外观">
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
{themeOptions.map(([val, lab]) => (
<button key={val} type="button" onClick={() => onThemeChange(val)} style={{
height: 24, padding: '0 10px', borderRadius: 4, border: 'none', cursor: 'pointer',
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
background: theme === val ? 'var(--surface-card)' : 'transparent',
color: theme === val ? 'var(--text-1)' : 'var(--text-2)',
boxShadow: theme === val ? 'var(--shadow-card)' : 'none',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{lab}</button>
))}
</span>
</SettingRow>
</Section>
<div style={{ height: 1, background: 'var(--border-1)' }}></div>
<Section title="账号与时长">
<SettingRow label="登录状态" description="微信已登录">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 'var(--text-sm)', color: 'var(--text-1)' }}>
wang*** <Badge tone="green">余额充足</Badge>
</span>
</SettingRow>
<SettingRow label="时长余额" description="时长不过期 · 每天另有 3 分钟免费试用">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 'var(--text-base)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)', fontFamily: 'var(--font-sans)' }}>472 分钟</span>
<Button variant="secondary" size="sm">购买时长</Button>
</span>
</SettingRow>
<div style={{ padding: '10px 0 12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginBottom: 7 }}>
<span>今日免费试用</span><span style={{ fontFamily: 'var(--font-mono)' }}>1 / 3 分钟</span>
</div>
<ProgressBar value={1} max={3} />
</div>
</Section>
</MacWindow>
);
}
Object.assign(window, { TrayMenu, SettingsWindow });
+110
View File
@@ -0,0 +1,110 @@
<!-- @dsCard group="桌面端" viewport="1100x640" name="桌面端 UI Kit" subtitle="识别浮层 · 托盘菜单 · 设置 · 登录购买 — 可交互,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; } }
@keyframes dd-rise { from { opacity: 0; transform: translate(-50%, -14px); } to { opacity: 1; transform: translate(-50%, -22px); } }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="./DesktopShared.jsx"></script>
<script type="text/babel" src="./OverlayDemo.jsx"></script>
<script type="text/babel" src="./SettingsTray.jsx"></script>
<script type="text/babel" src="./LoginPurchase.jsx"></script>
<script type="text/babel">
const TABS = [
['overlay', '识别浮层'],
['tray', '托盘菜单'],
['settings', '设置'],
['account', '登录 / 购买'],
];
function DesktopKit() {
const [tab, setTab] = React.useState('overlay');
const [theme, setTheme] = React.useState('system'); // system | light | dark
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 = theme === 'dark' || (theme === 'system' && systemDark);
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: 16, 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)' }}>
<span style={{ color: 'var(--accent)', display: 'inline-flex' }}><DuduLogo 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)', whiteSpace: 'nowrap' }}>桌面端 · Mac / Windows</span>
</span>
<div style={{ display: 'flex', gap: 4, marginLeft: 18 }}>
{TABS.map(([key, label]) => (
<button key={key} type="button" onClick={() => setTab(key)} style={{
height: 30, padding: '0 14px', borderRadius: 'var(--radius-full)', border: 'none', cursor: 'pointer', whiteSpace: 'nowrap',
fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)',
fontWeight: tab === key ? 'var(--weight-medium)' : 'var(--weight-regular)',
background: tab === key ? 'var(--accent-soft)' : 'transparent',
color: tab === key ? 'var(--accent-text)' : 'var(--text-2)',
transition: 'background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out)',
}}>{label}</button>
))}
</div>
<button type="button" onClick={() => setTheme(dark ? 'light' : '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 ? <DuIcons.Sun size={15} /> : <DuIcons.Moon size={15} />}
</button>
</div>
{/* 舞台 */}
<div style={{ flex: 1, display: 'flex', padding: 28, overflow: 'auto' }}>
<div style={{ margin: 'auto' }}>
{tab === 'overlay' && <OverlayDemo />}
{tab === 'tray' && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--text-2)', fontSize: 'var(--text-sm)', paddingRight: 12, whiteSpace: 'nowrap' }}>
<DuduLogo size={15} /> 菜单栏图标
</span>
<TrayMenu />
</div>
)}
{tab === 'settings' && <SettingsWindow theme={theme} onThemeChange={setTheme} />}
{tab === 'account' && <LoginPurchaseWindow />}
</div>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<DesktopKit />);
</script>
</body>
</html>
+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>