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
+237
View File
@@ -0,0 +1,237 @@
// 反馈窗口(11E):文字 + 图片(粘贴 / 拖拽 / 选择,≤3 张 ≤5MB)+ 诊断信息开关。
// 规格见 doc/frontend-design.html 第八章;提交走 multipart POST /v1/feedback。
import { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import '@dudu/design/styles.css';
import { Button } from '@dudu/design/components/core/Button';
import { Switch } from '@dudu/design/components/forms/Switch';
import { SettingRow } from '@dudu/design/components/forms/SettingRow';
import { invoke } from '../shared/tauri';
import { setThemePref } from '../shared/theme';
const MAX_IMAGES = 3;
const MAX_BYTES = 5 * 1024 * 1024;
const MAX_LEN = 1000;
const OK_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ERR_TEXT = {
NOT_LOGGED_IN: '先登录再反馈',
FEEDBACK_RATE_LIMITED: '今天反馈次数已达上限',
};
// File → { name, dataUrl, base64 }
function readImage(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result;
resolve({ name: file.name || 'screenshot.png', dataUrl, base64: dataUrl.split(',')[1] });
};
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
}
function Thumb({ img, onRemove }) {
return (
<div style={{ position: 'relative', width: 64, height: 64, flex: 'none' }}>
<img src={img.dataUrl} alt={img.name} style={{
width: 64, height: 64, objectFit: 'cover', borderRadius: 6,
border: '1px solid var(--border-1)', display: 'block',
}} />
<button type="button" onClick={onRemove} aria-label="删除图片" style={{
position: 'absolute', top: -7, right: -7, width: 20, height: 20,
borderRadius: '50%', border: 'none', cursor: 'pointer',
background: 'var(--text-1)', color: 'var(--bg-app)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
}}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
);
}
function AddBox({ onClick, dragging }) {
const [hover, setHover] = useState(false);
const on = hover || dragging;
return (
<button type="button" onClick={onClick}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
width: 64, height: 64, flex: 'none', borderRadius: 6, cursor: 'pointer',
border: `2px dashed ${on ? 'var(--accent)' : 'var(--border-2)'}`,
background: on ? 'var(--accent-soft)' : 'transparent',
color: on ? 'var(--accent-text)' : 'var(--text-3)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color var(--dur-fast) var(--ease-out)',
}}>
{/* Lucide image-plus */}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 5h6"/><path d="M19 2v6"/><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/>
<circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</svg>
</button>
);
}
function SuccessView({ onAgain }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14, paddingTop: 110 }}>
<span style={{
width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</span>
<div style={{ fontSize: 17, fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>已收到 · 谢谢你</div>
<Button variant="ghost" size="sm" onClick={onAgain}>继续反馈</Button>
</div>
);
}
function FeedbackApp() {
const [content, setContent] = useState('');
const [images, setImages] = useState([]);
const [diag, setDiag] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState('');
const [dragging, setDragging] = useState(false);
const [loggedIn, setLoggedIn] = useState(true);
const fileRef = useRef(null);
const closeTimer = useRef(null);
const refreshLogin = () => {
invoke('get_settings').then((s) => {
if (s) { setThemePref(s.theme); setLoggedIn(!!s.token); }
});
};
useEffect(() => {
refreshLogin();
window.addEventListener('focus', refreshLogin);
return () => window.removeEventListener('focus', refreshLogin);
}, []);
const addFiles = async (files) => {
setErr('');
const list = Array.from(files).filter((f) => OK_TYPES.includes(f.type));
if (!list.length) return;
const room = MAX_IMAGES - images.length;
if (room <= 0) { setErr(`最多 ${MAX_IMAGES} 张图片`); return; }
const next = [];
for (const f of list.slice(0, room)) {
if (f.size > MAX_BYTES) { setErr('单张图片不超过 5MB'); continue; }
const img = await readImage(f);
if (img) next.push(img);
}
if (next.length) setImages((cur) => [...cur, ...next].slice(0, MAX_IMAGES));
};
// 粘贴截图(Cmd+V
useEffect(() => {
const onPaste = (e) => {
const files = Array.from(e.clipboardData?.items || [])
.filter((it) => it.kind === 'file').map((it) => it.getAsFile()).filter(Boolean);
if (files.length) addFiles(files);
};
document.addEventListener('paste', onPaste);
return () => document.removeEventListener('paste', onPaste);
});
const submit = async () => {
setSubmitting(true);
setErr('');
try {
const r = await invoke('submit_feedback', {
content: content.trim(),
images: images.map((i) => ({ name: i.name, data: i.base64 })),
includeDiagnostics: diag,
});
if (r && r.feedback_id) {
setDone(true);
setContent(''); setImages([]);
closeTimer.current = setTimeout(() => invoke('close_feedback'), 1800);
} else {
setErr('提交失败,请稍后再试');
}
} catch (e) {
setErr(ERR_TEXT[String(e)] || '提交失败,请稍后再试');
} finally {
setSubmitting(false);
}
};
const frame = (children) => (
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}
style={{
fontFamily: 'var(--font-sans)', background: 'var(--bg-app)', minHeight: '100vh',
boxSizing: 'border-box', padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 12,
}}>
{children}
</div>
);
if (done) {
return frame(<SuccessView onAgain={() => { clearTimeout(closeTimer.current); setDone(false); }} />);
}
if (!loggedIn) {
return frame(
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14, paddingTop: 130 }}>
<div style={{ fontSize: 17, fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>先登录再反馈</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>登录后我们才能跟进你的问题</div>
<Button variant="primary" onClick={() => invoke('open_login')}>微信登录</Button>
</div>,
);
}
const count = [...content].length;
return frame(
<>
<div style={{ fontSize: 17, fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>告诉我们哪里不对</div>
<div style={{ position: 'relative' }}>
<textarea
value={content}
maxLength={MAX_LEN}
onChange={(e) => setContent(e.target.value)}
placeholder="说说遇到的问题或建议"
style={{
width: '100%', height: 170, resize: 'none', boxSizing: 'border-box',
padding: '10px 12px 26px', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-1)', background: 'var(--surface-card)',
color: 'var(--text-1)', fontSize: 'var(--text-base)', fontFamily: 'var(--font-sans)',
lineHeight: 1.6, outline: 'none',
}} />
<span style={{ position: 'absolute', right: 12, bottom: 10, fontSize: 12, color: 'var(--text-3)' }}>
{count} / {MAX_LEN}
</span>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
{images.map((img, i) => (
<Thumb key={`${img.name}-${i}`} img={img} onRemove={() => setImages(images.filter((_, j) => j !== i))} />
))}
{images.length < MAX_IMAGES && <AddBox dragging={dragging} onClick={() => fileRef.current?.click()} />}
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/webp" multiple
style={{ display: 'none' }}
onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }} />
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}>
粘贴截图拖进来或点击添加 · 最多 3 · 单张不超过 5MB
</div>
<SettingRow label="附带诊断信息" description="版本、系统、设备标识">
<Switch checked={diag} onChange={setDiag} />
</SettingRow>
{err && <div style={{ fontSize: 'var(--text-sm)', color: 'var(--danger)' }}>{err}</div>}
<div style={{ marginTop: 'auto' }}>
<Button variant="primary" block disabled={!content.trim() || submitting} onClick={submit}>
{submitting ? '提交中' : '提交'}
</Button>
</div>
</>,
);
}
createRoot(document.getElementById('root')).render(<FeedbackApp />);
+160
View File
@@ -0,0 +1,160 @@
// 登录 / 购买窗口四步流:扫码登录 → 时长包 → 支付二维码 → 完成
// 规格见 doc/frontend-design.html 4.2;二维码内容来自后端,轮询自动推进。
import { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import '@dudu/design/styles.css';
import { Button } from '@dudu/design/components/core/Button';
import { Badge } from '@dudu/design/components/core/Badge';
import { invoke } from '../shared/tauri';
function Center({ children }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14,
padding: '26px 24px 30px', fontFamily: 'var(--font-sans)',
background: 'var(--bg-app)', minHeight: '100vh', boxSizing: 'border-box',
}}>{children}</div>
);
}
// 简易二维码占位:真实渲染由 qr 内容字符串绘制(接入 qrcode 库或后端返图)。
function QrBox({ value }) {
return (
<div style={{
width: 180, height: 180, borderRadius: 'var(--radius-md)', border: '1px solid var(--border-1)',
background: 'var(--surface-card)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'var(--text-xs)', color: 'var(--text-3)', padding: 12, textAlign: 'center',
wordBreak: 'break-all', overflow: 'hidden',
}}>{value || '加载中'}</div>
);
}
function LoginApp() {
const [step, setStep] = useState('login'); // login | plans | pay | done
const [qr, setQr] = useState(null);
const [packs, setPacks] = useState([]);
const [sel, setSel] = useState('pack_500');
const [order, setOrder] = useState(null);
const [me, setMe] = useState(null);
const timer = useRef(null);
// 扫码登录:创建二维码 + 轮询
useEffect(() => {
if (step !== 'login') return undefined;
let stop = false;
invoke('login_qr_create').then((r) => {
if (!r || stop) return;
setQr(r);
timer.current = setInterval(async () => {
const st = await invoke('login_qr_poll', { state: r.state });
if (st && st.status === 'confirmed') {
clearInterval(timer.current);
const m = await invoke('fetch_me');
setMe(m);
const ps = await invoke('fetch_packs');
setPacks(ps || []);
setStep('plans');
}
}, 1000);
});
return () => { stop = true; clearInterval(timer.current); };
}, [step]);
// 支付轮询
useEffect(() => {
if (step !== 'pay' || !order) return undefined;
const t = setInterval(async () => {
const st = await invoke('order_status', { orderId: order.order_id });
if (st && st.status === 'paid') {
clearInterval(t);
setMe(await invoke('fetch_me'));
setStep('done');
}
}, 1000);
return () => clearInterval(t);
}, [step, order]);
const pack = packs.find((p) => p.id === sel);
if (step === 'login') {
return (
<Center>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>微信扫码登录</div>
<QrBox value={qr && qr.qr_url} />
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>打开微信扫一扫确认后自动登录</div>
</Center>
);
}
if (step === 'plans') {
return (
<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>
{me && <span style={{ display: 'inline-flex', gap: 8, alignItems: 'center', fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>
{me.nickname_masked} <Badge tone="blue">{me.account_state === 'ok' ? '余额充足' : '试用中'}</Badge>
</span>}
</div>
<div style={{ alignSelf: 'stretch', display: 'flex', flexDirection: 'column', gap: 10 }}>
{packs.map((p) => {
const active = sel === p.id;
return (
<button key={p.id} type="button" onClick={() => setSel(p.id)} 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)',
}}>
<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.minutes} 分钟</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.unit_desc}</span>
</span>
<span style={{ fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-bold)', color: active ? 'var(--accent-text)' : 'var(--text-1)' }}>
¥{p.price_cents / 100}
</span>
</button>
);
})}
</div>
<div style={{ alignSelf: 'stretch', fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}>时长不过期 · 每天另有 3 分钟免费试用</div>
<Button variant="primary" block disabled={!pack} onClick={async () => {
const o = await invoke('create_order', { packId: sel });
if (o) { setOrder(o); setStep('pay'); }
}}>微信支付 ¥{pack ? pack.price_cents / 100 : '—'}</Button>
</Center>
);
}
if (step === 'pay') {
return (
<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)' }}>
¥{pack.price_cents / 100}
<span style={{ fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-regular)', color: 'var(--text-3)' }}> · {pack.minutes} 分钟时长包</span>
</div>
<QrBox value={order && order.code_url} />
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>支付完成后自动开通</div>
<Button variant="secondary" size="sm" onClick={() => setStep('plans')}>返回</Button>
</Center>
);
}
return (
<Center>
<span style={{
width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28,
}}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</span>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>已到账 {pack.minutes} 分钟</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>
时长余额 {me ? Math.floor(me.balance_seconds / 60) : '—'} 分钟 · 不过期 · 现在就按住 Space 试试
</div>
<Button variant="primary" onClick={() => invoke('close_login')}>开始使用</Button>
</Center>
);
}
createRoot(document.getElementById('root')).render(<LoginApp />);
+200
View File
@@ -0,0 +1,200 @@
// 首次启动引导(11F):欢迎 → 权限 → 登录 → 试一试。
// 规格见 doc/frontend-design.html 4.3;完成 / 关窗后置 onboarding_done。
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import '@dudu/design/styles.css';
import { Button } from '@dudu/design/components/core/Button';
import { Badge } from '@dudu/design/components/core/Badge';
import { HotkeyCombo } from '@dudu/design/components/core/Kbd';
import { invoke } from '../shared/tauri';
import { setThemePref } from '../shared/theme';
const isMac = navigator.platform.toUpperCase().includes('MAC');
const STEPS = ['welcome', 'perms', 'login', 'try'];
function Dots({ step }) {
return (
<div style={{ display: 'flex', gap: 7, justifyContent: 'center' }}>
{STEPS.map((s, i) => (
<span key={s} style={{
width: i === step ? 18 : 6, height: 6, borderRadius: 'var(--radius-full)',
background: i === step ? 'var(--accent)' : 'var(--border-2)',
transition: 'all var(--dur-base) var(--ease-out)',
}} />
))}
</div>
);
}
function Title({ children }) {
return <div style={{ fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)', textAlign: 'center' }}>{children}</div>;
}
function Desc({ children }) {
return <div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center', lineHeight: 1.7 }}>{children}</div>;
}
// 权限检测卡片:状态徽标 + 去开启按钮
function PermCard({ icon, title, desc, granted, onGrant }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px',
borderRadius: 'var(--radius-md)', border: '1px solid var(--border-1)', background: 'var(--surface-card)',
}}>
<span style={{
width: 36, height: 36, flex: 'none', borderRadius: 'var(--radius-sm)',
background: 'var(--accent-soft)', color: 'var(--accent-text)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{icon}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{title}</span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-3)', marginTop: 2 }}>{desc}</span>
</span>
{granted ? <Badge tone="green">已开启</Badge> : <Button variant="secondary" size="sm" onClick={onGrant}>去开启</Button>}
</div>
);
}
const MicIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>
</svg>
);
const KeyIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15.5 7.5 3 3L22 7l-3-3"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>
</svg>
);
function OnboardingApp() {
const [step, setStep] = useState(0);
const [micOk, setMicOk] = useState(false);
const [axOk, setAxOk] = useState(!isMac);
const [me, setMe] = useState(null);
useEffect(() => {
invoke('get_settings').then((s) => s && setThemePref(s.theme));
}, []);
// 权限步:辅助功能状态轮询(用户去系统设置开启后自动变绿)
useEffect(() => {
if (STEPS[step] !== 'perms' || !isMac) return undefined;
let live = true;
const tick = () => invoke('check_accessibility').then((ok) => live && setAxOk(!!ok));
tick();
const t = setInterval(tick, 1000);
return () => { live = false; clearInterval(t); };
}, [step]);
// 登录步:轮询账号,扫码成功后自动亮起
useEffect(() => {
if (STEPS[step] !== 'login') return undefined;
let live = true;
const tick = () => invoke('fetch_me').then((m) => live && m && setMe(m));
tick();
const t = setInterval(tick, 1500);
return () => { live = false; clearInterval(t); };
}, [step]);
const next = () => setStep((s) => Math.min(s + 1, STEPS.length - 1));
const prev = () => setStep((s) => Math.max(s - 1, 0));
const finish = () => invoke('finish_onboarding');
let body = null;
let primary = null;
const name = STEPS[step];
if (name === 'welcome') {
body = (
<>
<span style={{
width: 64, height: 64, borderRadius: 18, background: 'var(--accent)', color: 'var(--text-on-accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto',
}}>
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>
</svg>
</span>
<Title>dudu 语音输入</Title>
<Desc>按住快捷键说话松开文字就出现在光标处<br />全程比打字快三倍</Desc>
</>
);
primary = <Button variant="primary" block onClick={next}>继续</Button>;
} else if (name === 'perms') {
body = (
<>
<Title>先开两个权限</Title>
<Desc>{isMac ? '麦克风用来听你说话,辅助功能让文字上屏' : '麦克风用来听你说话'}</Desc>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 4 }}>
<PermCard icon={MicIcon} title="麦克风" desc="录入你的声音"
granted={micOk}
onGrant={() => invoke('request_microphone').then((ok) => setMicOk(!!ok))} />
{isMac && (
<PermCard icon={KeyIcon} title="辅助功能" desc="把识别结果输入到任意应用"
granted={axOk}
onGrant={() => invoke('open_accessibility_settings')} />
)}
</div>
</>
);
primary = <Button variant="primary" block onClick={next}>{micOk && axOk ? '下一步' : '稍后开启,先继续'}</Button>;
} else if (name === 'login') {
body = (
<>
<Title>登录同步时长</Title>
<Desc>微信扫码登录购买的时长跟着账号走<br />也可以先试用每天免费 3 分钟</Desc>
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 4 }}>
{me ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 'var(--text-sm)', color: 'var(--text-1)' }}>
{me.nickname_masked} <Badge tone="green">已登录</Badge>
</span>
) : (
<Button variant="secondary" onClick={() => invoke('open_login')}>微信扫码登录</Button>
)}
</div>
</>
);
primary = <Button variant="primary" block onClick={next}>{me ? '下一步' : '先试用,跳过'}</Button>;
} else {
body = (
<>
<Title>试一试</Title>
<Desc>点一下下面的输入框按住快捷键说句话松开看看</Desc>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<HotkeyCombo keys={['⌘', '⇧', 'Space']} />
</div>
<textarea
autoFocus
placeholder="文字会出现在这里"
style={{
width: '100%', height: 88, resize: 'none', boxSizing: 'border-box',
padding: '10px 12px', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-1)', background: 'var(--surface-card)',
color: 'var(--text-1)', fontSize: 'var(--text-base)', fontFamily: 'var(--font-sans)',
lineHeight: 1.6, outline: 'none',
}} />
</>
);
primary = <Button variant="primary" block onClick={finish}>开始使用</Button>;
}
return (
<div style={{
fontFamily: 'var(--font-sans)', background: 'var(--bg-app)', minHeight: '100vh',
boxSizing: 'border-box', padding: '34px 44px 24px',
display: 'flex', flexDirection: 'column', gap: 14,
}}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 14 }}>
{body}
</div>
<Dots step={step} />
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
{step > 0 && <Button variant="ghost" onClick={prev}>上一步</Button>}
<span style={{ flex: 1 }}>{primary}</span>
</div>
</div>
);
}
createRoot(document.getElementById('root')).render(<OnboardingApp />);
+53
View File
@@ -0,0 +1,53 @@
// 识别浮层窗口:深色玻璃、不抢焦点;状态完全由 Rust 事件驱动。
// listening / text 两态 + 错误态文案,规格见 doc/frontend-design.html 5.1/5.2
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import '@dudu/design/styles.css';
import { RecognitionOverlay } from '@dudu/design/components/voice/RecognitionOverlay';
import { listen } from '../shared/tauri';
const ERR_TEXT = {
QUOTA_EXCEEDED: '今日试用已用完,去购买时长',
RATE_LIMITED: '操作过于频繁,稍后再试',
SESSION_LIMIT: '单次最长 3 分钟,松开后可继续',
ASR_UNAVAILABLE: '识别服务暂不可用,稍后再试',
NETWORK: '连接中断,松开重试',
NO_ACCESSIBILITY: '需要辅助功能权限才能上屏,去系统设置开启 dudu',
};
function OverlayApp() {
const [finalText, setFinalText] = useState('');
const [partialText, setPartialText] = useState('');
const [error, setError] = useState('');
useEffect(() => {
const offs = [];
listen('hotkey', ({ payload }) => {
if (payload.state === 'down') { setFinalText(''); setPartialText(''); setError(''); }
}).then((off) => offs.push(off));
listen('asr', ({ payload }) => {
if (payload.type === 'partial') setPartialText(payload.text);
else if (payload.type === 'final') { setFinalText((f) => f + payload.text); setPartialText(''); }
else if (payload.type === 'error') setError(ERR_TEXT[payload.code] || payload.message || '');
}).then((off) => offs.push(off));
return () => offs.forEach((off) => off());
}, []);
const hasText = finalText || partialText;
return (
<div style={{ padding: 8 }}>
{error ? (
<RecognitionOverlay state="text" finalText="" partialText={error} hint="dudu" />
) : (
<RecognitionOverlay
state={hasText ? 'text' : 'listening'}
finalText={finalText}
partialText={partialText}
hint="松开 ⌘⇧Space 完成输入"
/>
)}
</div>
);
}
createRoot(document.getElementById('root')).render(<OverlayApp />);
+134
View File
@@ -0,0 +1,134 @@
// dudu 设置窗口 — 四分组(输入 / 通用 / 账号与时长 / 帮助),规格见 doc/frontend-design.html 4.2
import { useEffect, useState } from 'react';
import { Button } from '@dudu/design/components/core/Button';
import { Badge } from '@dudu/design/components/core/Badge';
import { HotkeyCombo } from '@dudu/design/components/core/Kbd';
import { ProgressBar } from '@dudu/design/components/core/ProgressBar';
import { Switch } from '@dudu/design/components/forms/Switch';
import { SettingRow } from '@dudu/design/components/forms/SettingRow';
import { invoke } from '../shared/tauri';
import { setThemePref } from '../shared/theme';
const THEME_OPTIONS = [['system', '跟随系统'], ['light', '浅色'], ['dark', '深色']];
function Section({ title, children }) {
return (
<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 Sep = () => <div style={{ height: 1, background: 'var(--border-1)' }}></div>;
export function SettingsApp() {
const [settings, setSettings] = useState({
hotkey: 'CmdOrCtrl+Shift+Space', mic: '', sound: false, autostart: true, theme: 'system',
});
const [mics, setMics] = useState([]);
const [me, setMe] = useState(null); // null = 未登录
useEffect(() => {
invoke('get_settings').then((s) => { if (s) { setSettings(s); setThemePref(s.theme); } });
invoke('list_mic_devices').then((d) => d && setMics(d));
invoke('fetch_me').then((m) => m && setMe(m));
}, []);
const update = (patch) => {
const next = { ...settings, ...patch };
setSettings(next);
invoke('set_settings', { settings: next });
if (patch.theme) setThemePref(patch.theme);
};
const minutes = me ? Math.floor(me.balance_seconds / 60) : 0;
const trialUsedMin = me ? Math.floor(me.trial_used_today / 60) : 0;
return (
<div style={{ fontFamily: 'var(--font-sans)', background: 'var(--bg-app)', minHeight: '100vh' }}>
<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="麦克风">
<select
value={settings.mic}
onChange={(e) => update({ mic: e.target.value })}
style={{
height: 'var(--control-sm)', padding: '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)',
}}>
<option value="">系统默认</option>
{mics.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
</SettingRow>
<SettingRow label="提示音" description="开始 / 完成时播放轻提示音">
<Switch checked={settings.sound} onChange={(v) => update({ sound: v })} />
</SettingRow>
</Section>
<Sep />
<Section title="通用">
<SettingRow label="开机自启">
<Switch checked={settings.autostart} onChange={(v) => update({ autostart: v })} />
</SettingRow>
<SettingRow label="外观">
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
{THEME_OPTIONS.map(([val, lab]) => (
<button key={val} type="button" onClick={() => update({ theme: val })} style={{
height: 24, padding: '0 10px', borderRadius: 4, border: 'none', cursor: 'pointer',
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
background: settings.theme === val ? 'var(--surface-card)' : 'transparent',
color: settings.theme === val ? 'var(--text-1)' : 'var(--text-2)',
boxShadow: settings.theme === val ? 'var(--shadow-card)' : 'none',
}}>{lab}</button>
))}
</span>
</SettingRow>
</Section>
<Sep />
<Section title="账号与时长">
<SettingRow label="登录状态" description={me ? '微信已登录' : '登录后开始使用'}>
{me ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 'var(--text-sm)', color: 'var(--text-1)' }}>
{me.nickname_masked}
<Badge tone={me.account_state === 'quota' ? 'orange' : 'green'}>
{me.account_state === 'ok' ? '余额充足' : me.account_state === 'trial' ? '试用中' : '余额不足'}
</Badge>
</span>
) : (
<Button variant="primary" size="sm" onClick={() => invoke('open_login')}>微信登录</Button>
)}
</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)' }}>
{minutes} 分钟
</span>
<Button variant="secondary" size="sm" onClick={() => invoke('open_login')}>购买时长</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)' }}>{trialUsedMin} / 3 分钟</span>
</div>
<ProgressBar value={trialUsedMin} max={3} />
</div>
</Section>
<Sep />
<Section title="帮助">
<SettingRow label="反馈问题" description="遇到问题或建议,告诉我们">
<Button variant="ghost" size="sm" onClick={() => invoke('open_feedback')}>去反馈</Button>
</SettingRow>
</Section>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client';
import '@dudu/design/styles.css';
import { SettingsApp } from './SettingsApp';
createRoot(document.getElementById('root')).render(<SettingsApp />);
+14
View File
@@ -0,0 +1,14 @@
// Tauri 桥封装:在纯浏览器(vite dev 预览设计稿)环境下安全降级为 no-op。
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
export async function invoke(cmd, args) {
if (!inTauri) return null;
const { invoke } = await import('@tauri-apps/api/core');
return invoke(cmd, args);
}
export async function listen(event, handler) {
if (!inTauri) return () => {};
const { listen } = await import('@tauri-apps/api/event');
return listen(event, handler);
}
+16
View File
@@ -0,0 +1,16 @@
// 外观三态:system | light | dark;驱动根节点 data-theme(设计系统约定)。
const media = window.matchMedia('(prefers-color-scheme: dark)');
let pref = 'system';
function apply() {
const dark = pref === 'dark' || (pref === 'system' && media.matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
}
export function setThemePref(p) {
pref = p;
apply();
}
media.addEventListener('change', apply);
apply();
+66
View File
@@ -0,0 +1,66 @@
// 自绘托盘菜单(248px、blur、hover accent 反白),规格见 doc/frontend-design.html 4.2
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import '@dudu/design/styles.css';
import { Badge } from '@dudu/design/components/core/Badge';
import { invoke, listen } from '../shared/tauri';
function Item({ children, right, onClick }) {
const [hover, setHover] = 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)' : 'var(--text-1)',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>
<span style={{ whiteSpace: 'nowrap' }}>{children}</span>
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'rgba(255,255,255,.8)' : 'var(--text-3)' }}>{right}</span>}
</div>
);
}
const Sep = () => <div style={{ height: 1, background: 'var(--border-1)', margin: '4px 0' }}></div>;
function TrayApp() {
const [me, setMe] = useState(null);
const [paused, setPaused] = useState(false);
useEffect(() => {
invoke('fetch_me').then((m) => m && setMe(m));
invoke('is_paused').then((p) => setPaused(!!p));
listen('account', ({ payload }) => setMe(payload));
}, []);
const minutes = me ? Math.floor(me.balance_seconds / 60) : 0;
return (
<div style={{
fontFamily: 'var(--font-sans)', padding: '5px 0',
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)',
overflow: 'hidden',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 14px 6px' }}>
<span style={{ fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>dudu 语音输入</span>
<Badge tone={paused ? 'orange' : 'green'}>{paused ? '已暂停' : '已就绪'}</Badge>
</div>
<Sep />
<Item right="⌘⇧Space">按住说话</Item>
<Item onClick={() => invoke('toggle_pause').then((p) => setPaused(!!p))}>
{paused ? '继续使用' : '暂停使用'}
</Item>
<Item onClick={() => invoke('open_settings')}>设置</Item>
<Item right={me ? `${minutes} 分钟` : '—'}>时长余额</Item>
<Sep />
<Item right={me ? '已登录' : '未登录'} onClick={() => invoke('open_login')}>
{me ? `账号 ${me.nickname_masked}` : '微信登录'}
</Item>
<Item onClick={() => invoke('check_update')}>检查更新</Item>
<Sep />
<Item onClick={() => invoke('quit_app')}>退出 dudu</Item>
</div>
);
}
createRoot(document.getElementById('root')).render(<TrayApp />);