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
+13
View File
@@ -0,0 +1,13 @@
/**
* 麦克风条 — 移动端键盘的主操作:按住说话的大胶囊按键。
* @startingPoint section="Components" subtitle="按住说话 · 四种状态" viewport="700x300"
*/
export interface MicBarProps {
/** idle 按住说话 / recording 录音中 / disabled 未登录 / quota 超额 @default 'idle' */
state?: 'idle' | 'recording' | 'disabled' | 'quota';
/** 覆盖默认文案 */
label?: string;
onPointerDown?: (e: any) => void;
onPointerUp?: (e: any) => void;
}
export declare function MicBar(props: MicBarProps): any;
+54
View File
@@ -0,0 +1,54 @@
import { Waveform } from './Waveform';
const ddMicBarCss = `
.dd-micbar {
display: flex; align-items: center; justify-content: center; gap: 8px;
width: 100%; height: var(--control-xl); border: none; cursor: pointer;
border-radius: var(--radius-full);
font-family: var(--font-sans); font-size: var(--text-md); font-weight: var(--weight-semibold);
user-select: none; -webkit-user-select: none; touch-action: none;
transition: background var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);
}
.dd-micbar--idle { background: var(--accent); color: var(--text-on-accent); }
.dd-micbar--idle:active { background: var(--accent-press); }
.dd-micbar--recording { background: var(--positive); color: #fff; }
.dd-micbar--disabled { background: var(--surface-3); color: var(--text-3); cursor: default; }
.dd-micbar--quota { background: var(--warning); color: #fff; }
`;
if (typeof document !== 'undefined' && !document.getElementById('dd-micbar-css')) {
const s = document.createElement('style');
s.id = 'dd-micbar-css';
s.textContent = ddMicBarCss;
document.head.appendChild(s);
}
function DdMicIcon({ size = 20 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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>
</svg>
);
}
const DD_MICBAR_LABELS = {
idle: '按住说话',
recording: '松开完成 · 上滑取消',
disabled: '打开 dudu App 登录',
quota: '今日试用已用完,去购买时长',
};
export function MicBar({ state = 'idle', label, ...rest }) {
const text = label || DD_MICBAR_LABELS[state] || DD_MICBAR_LABELS.idle;
return (
<button type="button" className={`dd-micbar dd-micbar--${state}`} {...rest}>
{state === 'recording'
? <Waveform active bars={9} height={18} color="rgba(255,255,255,0.95)" />
: <DdMicIcon />}
<span>{text}</span>
</button>
);
}
+11
View File
@@ -0,0 +1,11 @@
The push-to-talk capsule that anchors the mobile keyboard. 56px tall, full width.
```jsx
const { MicBar } = window.DuduDesignSystem_2e0172;
<MicBar state="idle" onPointerDown={start} onPointerUp={stop} />
<MicBar state="recording" /> // green + animated white waveform
<MicBar state="disabled" /> // 未登录
<MicBar state="quota" /> // 今日试用已用完 → 引导购买时长
```
Default copy is built in; override with `label`.
+19
View File
@@ -0,0 +1,19 @@
/**
* 识别浮层 — 桌面端核心界面:按住快捷键时出现在光标旁的深色小窗。
* 两种主题下都保持深色。
* @startingPoint section="Components" subtitle="桌面识别浮层 · 聆听/流式两态" viewport="700x220"
*/
export interface RecognitionOverlayProps {
/** listening 聆听中(未出字)/ streaming 流式识别中 @default 'listening' */
state?: 'listening' | 'streaming';
/** 已定稿文本(白色) */
finalText?: string;
/** 未定稿 partial 文本(灰蓝色,实时刷新) */
partialText?: string;
/** 右下角提示 @default '松开 ⌘⇧Space 完成输入' */
hint?: string;
/** @default 340 */
width?: number;
style?: any;
}
export declare function RecognitionOverlay(props: RecognitionOverlayProps): any;
@@ -0,0 +1,58 @@
import { Waveform } from './Waveform';
const ddOverlayCss = `
.dd-overlay {
box-sizing: border-box; width: 340px;
background: var(--overlay-bg); color: var(--overlay-text);
border-radius: var(--radius-lg); padding: 14px 18px;
box-shadow: var(--shadow-overlay);
font-family: var(--font-sans);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
}
.dd-overlay__status {
display: flex; align-items: center; gap: 8px;
color: var(--overlay-text-2); font-size: var(--text-base); margin-top: 8px;
}
.dd-overlay__text { font-size: var(--text-md); line-height: 1.6; margin-top: 8px; min-height: 24px; }
.dd-overlay__text .dd-partial { color: var(--overlay-text-2); }
.dd-overlay__hint { font-size: 11px; color: var(--overlay-text-3); margin-top: 8px; text-align: right; }
`;
if (typeof document !== 'undefined' && !document.getElementById('dd-overlay-css')) {
const s = document.createElement('style');
s.id = 'dd-overlay-css';
s.textContent = ddOverlayCss;
document.head.appendChild(s);
}
export function RecognitionOverlay({
state = 'listening',
finalText = '',
partialText = '',
hint = '松开 ⌘⇧Space 完成输入',
width = 340,
style,
}) {
return (
<div className="dd-overlay" style={{ width, ...style }} role="status">
<Waveform active bars={12} height={24} color="#7C9BFF" />
{state === 'listening' ? (
<div className="dd-overlay__status">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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>
</svg>
<span>聆听中</span>
</div>
) : (
<div className="dd-overlay__text">
{finalText}
{partialText ? <span className="dd-partial">{partialText}</span> : null}
</div>
)}
<div className="dd-overlay__hint">{hint}</div>
</div>
);
}
@@ -0,0 +1,11 @@
The desktop push-to-talk overlay: dark glass panel (in BOTH themes), waveform on top, recognized text below, hotkey hint bottom-right.
```jsx
const { RecognitionOverlay } = window.DuduDesignSystem_2e0172;
<RecognitionOverlay state="listening" />
<RecognitionOverlay state="streaming"
finalText="帮我把这份周报整理一下,"
partialText="重点突出本周的" />
```
finalText = settled (white); partialText = in-flight (gray-blue). Never restyle it light.
+13
View File
@@ -0,0 +1,13 @@
/** 声波 — 品牌母题:竖向圆头短线组成的音量波形;录音中起伏动画。 */
export interface WaveformProps {
/** 竖线条数 @default 12 */
bars?: number;
/** 录音中(起伏动画) @default false */
active?: boolean;
/** 最大波高 px @default 26 */
height?: number;
/** 波形颜色 @default 'var(--accent)' */
color?: string;
style?: any;
}
export declare function Waveform(props: WaveformProps): any;
+35
View File
@@ -0,0 +1,35 @@
const ddWaveCss = `
.dd-wave { display: inline-flex; align-items: center; gap: 3px; }
.dd-wave i { display: block; width: 3px; border-radius: 2px; background: currentColor; }
.dd-wave--active i { animation: dd-wave-bounce 0.9s var(--ease-in-out) infinite alternate; }
@keyframes dd-wave-bounce {
from { transform: scaleY(0.35); }
to { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.dd-wave--active i { animation: none; }
}
`;
if (typeof document !== 'undefined' && !document.getElementById('dd-wave-css')) {
const s = document.createElement('style');
s.id = 'dd-wave-css';
s.textContent = ddWaveCss;
document.head.appendChild(s);
}
const DD_WAVE_PATTERN = [0.3, 0.55, 0.85, 0.45, 0.7, 1, 0.4, 0.6, 0.25, 0.8, 0.5, 0.35];
export function Waveform({ bars = 12, active = false, height = 26, color = 'var(--accent)', style }) {
const items = [];
for (let i = 0; i < bars; i++) {
const h = Math.round(DD_WAVE_PATTERN[i % DD_WAVE_PATTERN.length] * height);
items.push(<i key={i} style={{ height: Math.max(4, h), animationDelay: `${(i % 6) * 0.07}s` }}></i>);
}
return (
<span className={`dd-wave${active ? ' dd-wave--active' : ''}`}
style={{ height, color, ...style }} aria-hidden="true">
{items}
</span>
);
}
@@ -0,0 +1,8 @@
The brand's voice motif — vertical rounded bars. Animates only while `active` (recording).
```jsx
const { Waveform } = window.DuduDesignSystem_2e0172;
<Waveform active height={26} /> // 录音中,品牌蓝
<Waveform color="#7C9BFF" active /> // 深色浮层内用亮蓝
<Waveform color="rgba(255,255,255,.9)" bars={8} /> // 静态装饰
```
+42
View File
@@ -0,0 +1,42 @@
<!-- @dsCard group="Components" viewport="700x400" name="Voice 语音" subtitle="RecognitionOverlay · MicBar · Waveform" -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<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>
body { margin: 0; font-family: var(--font-sans); display: grid; grid-template-columns: 1fr 1fr; }
.pane { padding: 20px; box-sizing: border-box; min-height: 400px; display: flex; flex-direction: column; gap: 14px; align-items: center; justify-content: center; }
.left { background: var(--gray-100); }
.right { background: var(--bg-app); }
.caption { font-size: var(--text-xs); color: var(--text-3); align-self: flex-start; margin: -6px 0 -8px 4px; }
</style>
</head>
<body>
<div class="pane left" id="overlays"></div>
<div class="pane right" id="micbars" data-theme="dark"></div>
<script type="text/babel">
const { RecognitionOverlay, MicBar, Waveform } = window.DuduDesignSystem_2e0172;
ReactDOM.createRoot(document.getElementById('overlays')).render(
<React.Fragment>
<RecognitionOverlay state="listening" width={300} />
<RecognitionOverlay state="streaming" width={300}
finalText="帮我把这份周报整理一下," partialText="重点突出本周的" />
<Waveform active height={22} />
</React.Fragment>
);
ReactDOM.createRoot(document.getElementById('micbars')).render(
<div style={{ display: 'grid', gap: 12, width: '100%', maxWidth: 300 }}>
<MicBar state="idle" />
<MicBar state="recording" />
<MicBar state="disabled" />
<MicBar state="quota" />
</div>
);
</script>
</body>
</html>