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>