dudu MVP:五端语音输入法初始提交
- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调 - desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导) - android:Compose 主 App + IME(键盘内录音直传) - ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写) - design/design-pipeline:设计系统 + token 导出 iOS/Android 主题 - doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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 圆嘴 logo(currentColor 线条版)
|
||||
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 });
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,27 @@
|
||||
# dudu 桌面端 UI Kit
|
||||
|
||||
桌面端(Tauri 2,Mac / 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` — 登录/时长包/支付/完成 四步
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
@@ -0,0 +1,187 @@
|
||||
// dudu 移动端 · 主 App 四屏:启用引导 / 登录 / 购买时长 / 我的
|
||||
|
||||
function MAppHeader({ title }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)', padding: '4px 0 10px' }}>{title}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ① 启用引导 ---------- */
|
||||
function OnboardingScreen() {
|
||||
const { Button } = window.DuduDesignSystem_2e0172;
|
||||
const [step, setStep] = React.useState(0);
|
||||
const steps = [
|
||||
{ title: '添加键盘', desc: '设置 → 通用 → 键盘 → 添加新键盘,选择 dudu', icon: <DuMIcons.Keyboard size={26} /> },
|
||||
{ title: '开启完全访问', desc: '语音识别需要联网,请允许完全访问', icon: <DuMIcons.Shield size={26} /> },
|
||||
{ title: '试一试', desc: '在下面的输入框按住麦克风说一句话', icon: <DuMIcons.Mic size={26} /> },
|
||||
];
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '4px 18px 18px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, padding: '18px 0 6px' }}>
|
||||
<span style={{ color: 'var(--accent)' }}><DuduMLogo size={44} /></span>
|
||||
<div style={{ fontSize: 'var(--text-xl)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)', whiteSpace: 'nowrap' }}>三步开启 dudu</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>按住说话,松开上屏</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 14 }}>
|
||||
{steps.map((s, i) => {
|
||||
const state = i < step ? 'done' : i === step ? 'active' : 'todo';
|
||||
return (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '13px 14px',
|
||||
borderRadius: 'var(--radius-lg)', background: 'var(--surface-card)',
|
||||
border: state === 'active' ? '1.5px solid var(--accent)' : '1px solid var(--border-1)',
|
||||
opacity: state === 'todo' ? 0.62 : 1,
|
||||
transition: 'border-color var(--dur-fast) var(--ease-out), opacity var(--dur-fast) var(--ease-out)',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 40, height: 40, borderRadius: 'var(--radius-md)', flex: 'none',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: state === 'done' ? 'var(--positive-soft)' : 'var(--accent-soft)',
|
||||
color: state === 'done' ? 'var(--positive)' : 'var(--accent-text)',
|
||||
}}>{state === 'done' ? <DuMIcons.Check size={20} /> : s.icon}</span>
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{i + 1}. {s.title}</span>
|
||||
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 2, lineHeight: 'var(--leading-normal)' }}>{s.desc}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<Button variant="primary" size="lg" block onClick={() => setStep((s) => (s + 1) % 4)}>
|
||||
{step >= 3 ? '完成' : step === 2 ? '我说完了' : '去设置'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ② 登录 ---------- */
|
||||
function LoginScreen() {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '4px 22px 22px' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||
<span style={{ color: 'var(--accent)' }}><DuduMLogo size={64} sw={3.2} /></span>
|
||||
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)', letterSpacing: 'var(--tracking-wide)' }}>dudu</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>大模型语音输入 · 说话即上屏</div>
|
||||
</div>
|
||||
<button type="button" style={{
|
||||
height: 'var(--control-xl)', borderRadius: 'var(--radius-full)', border: 'none', cursor: 'pointer',
|
||||
background: '#07C160', color: '#fff', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-semibold)',
|
||||
fontFamily: 'var(--font-sans)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, width: '100%',
|
||||
}}>
|
||||
<DuMIcons.Wechat size={19} /> 微信一键登录
|
||||
</button>
|
||||
<div style={{ textAlign: 'center', fontSize: 'var(--text-xs)', color: 'var(--text-3)', marginTop: 12, lineHeight: 'var(--leading-normal)' }}>
|
||||
登录即同意《用户协议》与《隐私政策》
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ③ 购买时长 ---------- */
|
||||
function PaywallScreen() {
|
||||
const { Button } = window.DuduDesignSystem_2e0172;
|
||||
const [pack, setPack] = React.useState('m');
|
||||
const PACKS = [
|
||||
['s', '100 分钟', 9, '约 ¥0.09 / 分钟', ''],
|
||||
['m', '500 分钟', 39, '约 ¥0.078 / 分钟', '省 13%'],
|
||||
['l', '2000 分钟', 129, '约 ¥0.065 / 分钟', '省 28%'],
|
||||
];
|
||||
const price = PACKS.find((p) => p[0] === pack)[2];
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '4px 16px 16px', minHeight: 0, overflow: 'auto' }}>
|
||||
<MAppHeader title="购买时长" />
|
||||
|
||||
{/* 余额卡 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)', flex: 'none' }}>
|
||||
<span>
|
||||
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)' }}>时长余额</span>
|
||||
<span style={{ display: 'block', marginTop: 2, fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-bold)', color: 'var(--text-1)', fontFamily: 'var(--font-sans)' }}>472 <span style={{ fontSize: 'var(--text-xs)', fontWeight: 'var(--weight-regular)', color: 'var(--text-3)' }}>分钟</span></span>
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)', textAlign: 'right', lineHeight: 'var(--leading-normal)' }}>时长不过期<br />每天另有 3 分钟免费试用</span>
|
||||
</div>
|
||||
|
||||
{/* 时长包 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 12 }}>
|
||||
{PACKS.map(([key, lab, p, desc, tag]) => {
|
||||
const active = pack === key;
|
||||
return (
|
||||
<button key={key} type="button" onClick={() => setPack(key)} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center', textAlign: 'left',
|
||||
padding: '12px 14px', cursor: 'pointer', borderRadius: 'var(--radius-lg)', fontFamily: 'var(--font-sans)',
|
||||
border: active ? '1.5px solid var(--accent)' : '1px solid var(--border-1)',
|
||||
background: active ? 'var(--accent-soft)' : 'var(--surface-card)',
|
||||
transition: 'border-color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out)',
|
||||
}}>
|
||||
<span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
||||
<span style={{ fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{lab}</span>
|
||||
{tag && <span style={{ fontSize: 10, padding: '1px 7px', borderRadius: 'var(--radius-full)', background: 'var(--warning-soft)', color: 'var(--warning)', fontWeight: 'var(--weight-medium)', whiteSpace: 'nowrap' }}>{tag}</span>}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 2 }}>{desc}</span>
|
||||
</span>
|
||||
<span style={{ whiteSpace: 'nowrap', fontSize: 'var(--text-xl)', fontWeight: 'var(--weight-bold)', color: active ? 'var(--accent-text)' : 'var(--text-1)' }}>¥{p}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 'auto', paddingTop: 14 }}>
|
||||
<Button variant="primary" size="lg" block>微信支付 ¥{price}</Button>
|
||||
<div style={{ textAlign: 'center', fontSize: 'var(--text-xs)', color: 'var(--text-3)', marginTop: 8 }}>支付后立即到账 · 时长不过期</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ④ 我的 ---------- */
|
||||
function ProfileScreen() {
|
||||
const { Badge, ProgressBar } = window.DuduDesignSystem_2e0172;
|
||||
const perms = [
|
||||
{ label: '麦克风权限', ok: true },
|
||||
{ label: '键盘已添加', ok: true },
|
||||
{ label: '完全访问', ok: false, hint: '去开启' },
|
||||
];
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, padding: '4px 16px 16px', minHeight: 0, overflow: 'auto' }}>
|
||||
<MAppHeader title="我的" />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 14px', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)' }}>
|
||||
<span style={{ width: 46, height: 46, borderRadius: '50%', background: 'var(--accent-soft)', color: 'var(--accent-text)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
|
||||
<DuMIcons.User size={22} />
|
||||
</span>
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<span style={{ display: 'block', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>wang***</span>
|
||||
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 2 }}>时长余额 472 分钟 · 不过期</span>
|
||||
</span>
|
||||
<Badge tone="green">余额充足</Badge>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '14px 14px 16px', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginBottom: 8 }}>
|
||||
<span>今日免费试用</span><span style={{ fontFamily: 'var(--font-mono)' }}>1 / 3 分钟</span>
|
||||
</div>
|
||||
<ProgressBar value={1} max={3} />
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
|
||||
{perms.map((p, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 14px', borderBottom: i < perms.length - 1 ? '1px solid var(--border-2)' : 'none', fontSize: 'var(--text-base)', color: 'var(--text-1)' }}>
|
||||
<span>{p.label}</span>
|
||||
{p.ok ? (
|
||||
<span style={{ color: 'var(--positive)', display: 'inline-flex' }}><DuMIcons.Check size={16} /></span>
|
||||
) : (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 'var(--text-sm)', color: 'var(--danger)' }}>
|
||||
{p.hint} <DuMIcons.ChevronRight size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { OnboardingScreen, LoginScreen, PaywallScreen, ProfileScreen });
|
||||
@@ -0,0 +1,140 @@
|
||||
// dudu 移动端 · 语音键盘(键盘扩展)
|
||||
// 微信聊天上下文 + 以语音为主的键盘:工具条 / partial 文本条 / 功能键行 / 大麦克风条
|
||||
// 按住麦克风条:partial 流式显示 → 松开 final 上屏到宿主输入框
|
||||
|
||||
const KB_SENTENCES = [
|
||||
'好的,我马上把文件发给你,大概十分钟之内。',
|
||||
'今晚的聚餐改到八点,地址我发你定位。',
|
||||
];
|
||||
|
||||
function KeyboardScreen({ account = 'ok' }) {
|
||||
// account: ok(有余额) | trial(试用中) | guest(未登录) | quota(今日试用已用完)
|
||||
const { MicBar } = window.DuduDesignSystem_2e0172;
|
||||
const [recording, setRecording] = React.useState(false);
|
||||
const [finalText, setFinalText] = React.useState('');
|
||||
const [partialText, setPartialText] = React.useState('');
|
||||
const [inputText, setInputText] = React.useState('');
|
||||
const [seconds, setSeconds] = React.useState(0);
|
||||
const [idx, setIdx] = React.useState(0);
|
||||
const timers = React.useRef({});
|
||||
const ref = React.useRef({});
|
||||
ref.current = { finalText, partialText, idx };
|
||||
|
||||
const stop = () => {
|
||||
clearInterval(timers.current.t); clearInterval(timers.current.s);
|
||||
const { finalText: f, partialText: p, idx: i } = ref.current;
|
||||
const committed = f + p;
|
||||
if (committed) { setInputText((v) => v + committed); setIdx((i + 1) % KB_SENTENCES.length); }
|
||||
setFinalText(''); setPartialText(''); setSeconds(0);
|
||||
setRecording(false);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (account === 'guest' || account === 'quota') return;
|
||||
setRecording(true); setFinalText(''); setPartialText(''); setSeconds(0);
|
||||
const sentence = KB_SENTENCES[ref.current.idx];
|
||||
let i = 0;
|
||||
timers.current.t = setInterval(() => {
|
||||
i += 1;
|
||||
if (i >= sentence.length) { clearInterval(timers.current.t); setFinalText(sentence); setPartialText(''); return; }
|
||||
const lastPunct = Math.max(sentence.lastIndexOf(',', i), sentence.lastIndexOf('。', i));
|
||||
setFinalText(sentence.slice(0, lastPunct + 1));
|
||||
setPartialText(sentence.slice(lastPunct + 1, i));
|
||||
}, 95);
|
||||
timers.current.s = setInterval(() => setSeconds((s) => s + 1), 1000);
|
||||
};
|
||||
React.useEffect(() => () => { clearInterval(timers.current.t); clearInterval(timers.current.s); }, []);
|
||||
|
||||
const micState = account === 'guest' ? 'disabled' : account === 'quota' ? 'quota' : recording ? 'recording' : 'idle';
|
||||
const fmt = (s) => `00:${String(s).padStart(2, '0')}`;
|
||||
|
||||
const FnKey = ({ children, label, flex = 1, onClick }) => (
|
||||
<button type="button" onClick={onClick} aria-label={label} style={{
|
||||
flex, height: 40, border: 'none', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
|
||||
background: 'var(--surface-card)', color: 'var(--text-1)', boxShadow: 'var(--shadow-key)',
|
||||
fontFamily: 'var(--font-sans)', fontSize: 'var(--text-md)',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
}}>{children}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{/* 宿主 App:微信聊天 */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ textAlign: 'center', fontSize: 'var(--text-sm)', color: 'var(--text-2)', padding: '4px 0 8px', borderBottom: '1px solid var(--border-1)' }}>张三</div>
|
||||
<div style={{ flex: 1, padding: '12px 12px 8px', display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden', justifyContent: 'flex-end' }}>
|
||||
<div style={{ alignSelf: 'flex-start', maxWidth: '78%', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: '4px 12px 12px 12px', padding: '8px 11px', fontSize: 'var(--text-base)', color: 'var(--text-1)', lineHeight: 'var(--leading-normal)' }}>
|
||||
上次说的那份材料方便发我一下吗
|
||||
</div>
|
||||
<div style={{ alignSelf: 'flex-end', maxWidth: '78%', background: 'var(--accent)', color: 'var(--text-on-accent)', borderRadius: '12px 4px 12px 12px', padding: '8px 11px', fontSize: 'var(--text-base)', lineHeight: 'var(--leading-normal)' }}>
|
||||
稍等,我看一下
|
||||
</div>
|
||||
</div>
|
||||
{/* 宿主输入框 */}
|
||||
<div style={{ padding: '0 10px 10px' }}>
|
||||
<div style={{
|
||||
minHeight: 38, boxSizing: 'border-box', padding: '8px 12px', borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--surface-card)', border: recording ? '1.5px solid var(--accent)' : '1px solid var(--border-1)',
|
||||
fontSize: 'var(--text-base)', color: inputText ? 'var(--text-1)' : 'var(--text-3)', lineHeight: 'var(--leading-normal)',
|
||||
}}>
|
||||
{inputText || '发消息…'}
|
||||
<span style={{ color: 'var(--accent)', animation: 'dd-caret 1.1s steps(1) infinite' }}>▏</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 键盘扩展 ===== */}
|
||||
<div style={{ flex: 'none', background: 'var(--surface-2)', borderTop: '1px solid var(--border-1)', padding: '6px 8px 10px' }}>
|
||||
{/* 工具条 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '2px 6px 8px' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 'var(--text-xs)', color: 'var(--text-2)' }}>
|
||||
<DuduMLogo size={13} /> dudu 语音输入
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 'var(--text-xs)', fontFamily: recording ? 'var(--font-mono)' : 'var(--font-sans)', color: recording ? 'var(--positive)' : account === 'quota' ? 'var(--warning)' : 'var(--text-3)' }}>
|
||||
{account === 'guest' ? '未登录'
|
||||
: account === 'quota' ? '今日试用已用完'
|
||||
: recording ? fmt(seconds)
|
||||
: account === 'trial' ? '试用 · 今日剩 2 分钟'
|
||||
: <React.Fragment>余额 472 分钟 <DuMIcons.Check size={11} /></React.Fragment>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* partial 文本条(录音中替代功能键行) */}
|
||||
{recording ? (
|
||||
<div style={{
|
||||
margin: '0 2px 8px', minHeight: 40, boxSizing: 'border-box', padding: '9px 12px',
|
||||
background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-base)', lineHeight: 'var(--leading-normal)', color: 'var(--text-1)',
|
||||
}}>
|
||||
{finalText}<span style={{ color: 'var(--text-3)' }}>{partialText}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 6, margin: '0 2px 8px' }}>
|
||||
<FnKey label="切换键盘" flex={1.3}><DuMIcons.Globe size={17} /></FnKey>
|
||||
<FnKey label="逗号" onClick={() => setInputText((v) => v + ',')}>,</FnKey>
|
||||
<FnKey label="句号" onClick={() => setInputText((v) => v + '。')}>。</FnKey>
|
||||
<FnKey label="退格" flex={1.3} onClick={() => setInputText((v) => v.slice(0, -1))}><DuMIcons.Backspace size={17} /></FnKey>
|
||||
<FnKey label="发送" flex={1.3} onClick={() => setInputText('')}><DuMIcons.Enter size={16} /></FnKey>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 大麦克风条 */}
|
||||
<div style={{ padding: '0 2px' }}>
|
||||
<MicBar
|
||||
state={micState}
|
||||
onPointerDown={start}
|
||||
onPointerUp={stop}
|
||||
onPointerLeave={() => recording && stop()}
|
||||
/>
|
||||
</div>
|
||||
{recording && (
|
||||
<div style={{ textAlign: 'center', fontSize: 'var(--text-xs)', color: 'var(--text-3)', paddingTop: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4 }}>
|
||||
<DuMIcons.ArrowUp size={11} /> 上滑取消
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { KeyboardScreen });
|
||||
@@ -0,0 +1,103 @@
|
||||
// dudu 移动端 UI kit · 共享:Lucide 图标子集、DuduLogo、手机框
|
||||
|
||||
function DuMIcon({ size = 16, sw = 2, children, ...rest }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...rest}>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const DuMIcons = {
|
||||
Mic: (p) => (
|
||||
<DuMIcon {...p}>
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||
<line x1="12" x2="12" y1="19" y2="22"></line>
|
||||
</DuMIcon>
|
||||
),
|
||||
Globe: (p) => (
|
||||
<DuMIcon {...p}>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path>
|
||||
<path d="M2 12h20"></path>
|
||||
</DuMIcon>
|
||||
),
|
||||
Backspace: (p) => (
|
||||
<DuMIcon {...p}>
|
||||
<path d="M20 5H9l-7 7 7 7h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Z"></path>
|
||||
<line x1="18" x2="12" y1="9" y2="15"></line>
|
||||
<line x1="12" x2="18" y1="9" y2="15"></line>
|
||||
</DuMIcon>
|
||||
),
|
||||
Enter: (p) => (
|
||||
<DuMIcon {...p}>
|
||||
<polyline points="9 10 4 15 9 20"></polyline>
|
||||
<path d="M20 4v7a4 4 0 0 1-4 4H4"></path>
|
||||
</DuMIcon>
|
||||
),
|
||||
Check: (p) => <DuMIcon {...p}><path d="M20 6 9 17l-5-5"></path></DuMIcon>,
|
||||
ChevronRight: (p) => <DuMIcon {...p}><path d="m9 18 6-6-6-6"></path></DuMIcon>,
|
||||
User: (p) => (
|
||||
<DuMIcon {...p}>
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</DuMIcon>
|
||||
),
|
||||
Keyboard: (p) => (
|
||||
<DuMIcon {...p}>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
|
||||
<path d="M6 8h.01"></path><path d="M10 8h.01"></path><path d="M14 8h.01"></path><path d="M18 8h.01"></path>
|
||||
<path d="M6 12h.01"></path><path d="M10 12h.01"></path><path d="M14 12h.01"></path><path d="M18 12h.01"></path>
|
||||
<path d="M7 16h10"></path>
|
||||
</DuMIcon>
|
||||
),
|
||||
Shield: (p) => (
|
||||
<DuMIcon {...p}>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path>
|
||||
</DuMIcon>
|
||||
),
|
||||
ArrowUp: (p) => <DuMIcon {...p}><path d="m5 12 7-7 7 7"></path><path d="M12 19V5"></path></DuMIcon>,
|
||||
Wechat: (p) => (
|
||||
/* 简化对话气泡(代微信图标占位,正式包内用官方资源)*/
|
||||
<DuMIcon {...p}>
|
||||
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path>
|
||||
</DuMIcon>
|
||||
),
|
||||
};
|
||||
|
||||
function DuduMLogo({ size = 20, sw = 3.5 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 48 48" fill="none" stroke="currentColor"
|
||||
strokeWidth={sw} strokeLinecap="round" aria-hidden="true">
|
||||
<circle cx="24" cy="24" r="18"></circle>
|
||||
<path d="M17 20.5v7"></path>
|
||||
<path d="M24 16.5v15"></path>
|
||||
<path d="M31 20.5v7"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 中性手机框:深色边框 + 打孔/刘海,内容区由调用方填充
|
||||
function PhoneFrame({ width = 312, height = 596, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, boxSizing: 'border-box', background: '#0B0C10', borderRadius: 38,
|
||||
padding: 9, boxShadow: 'var(--shadow-window)', flex: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%', height: '100%', boxSizing: 'border-box', background: 'var(--bg-app)',
|
||||
borderRadius: 30, overflow: 'hidden', display: 'flex', flexDirection: 'column', position: 'relative',
|
||||
}}>
|
||||
{/* 状态栏 */}
|
||||
<div style={{ height: 30, flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ width: 84, height: 18, background: '#0B0C10', borderRadius: 10 }}></div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DuMIcon, DuMIcons, DuduMLogo, PhoneFrame });
|
||||
@@ -0,0 +1,30 @@
|
||||
# dudu 移动端 UI Kit
|
||||
|
||||
移动端(iOS / Android 原生键盘扩展 + 主 App)高保真交互演示,对应 MVP 设计方案 §8.2–8.3。
|
||||
|
||||
## 左:键盘扩展
|
||||
|
||||
微信聊天宿主上下文 + 以语音为主的键盘:
|
||||
|
||||
- 工具条(dudu 标识 + 余额/试用/计时状态)
|
||||
- 功能键行:切换键盘 / ,/ 。/ 退格 / 发送(语音为主,不做 26 键)
|
||||
- 大麦克风条(`MicBar`,56px 胶囊):**按住说话** → partial 文本条流式刷新 → **松开** final 上屏到宿主输入框;录音中显示"上滑取消"
|
||||
- 顶部状态 chips 切换四态:有余额 / 试用中(每天免费 3 分钟)/ 未登录(按键置灰)/ 试用已用完(橙色引导购买时长)
|
||||
|
||||
## 右:主 App 四屏
|
||||
|
||||
- **启用引导**:三步卡片(添加键盘 → 完全访问 → 试一试),点主按钮推进
|
||||
- **登录**:logo + 微信一键登录(微信品牌绿 #07C160)
|
||||
- **购买时长**:时长余额卡 + 时长包(100/500/2000 分钟,不过期)+ 微信支付
|
||||
- **我的**:账号卡(时长余额)+ 今日免费试用 ProgressBar + 权限自检(异常红色"去开启")
|
||||
|
||||
右上角切换 Light / Dark。键盘面板与 App 均随主题;键帽用 `--surface-card` + `--shadow-key` 底边。
|
||||
|
||||
## 文件
|
||||
|
||||
- `index.html` — 入口(两台手机并排 + 状态 chips + 主题)
|
||||
- `MobileShared.jsx` — Lucide 图标子集 · DuduMLogo · PhoneFrame
|
||||
- `KeyboardScreen.jsx` — 键盘扩展全态
|
||||
- `AppScreens.jsx` — Onboarding / Login / Paywall / Profile
|
||||
|
||||
注:微信图标为简化气泡占位,正式包请使用微信官方资源。
|
||||
@@ -0,0 +1,131 @@
|
||||
<!-- @dsCard group="移动端" viewport="1100x720" name="移动端 UI Kit" subtitle="语音键盘(按住说话)+ 主 App 四屏 — 可交互,Light/Dark" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dudu 移动端 UI Kit</title>
|
||||
<link rel="stylesheet" href="../../styles.css">
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script src="../../_ds_bundle.js"></script>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { font-family: var(--font-sans); }
|
||||
#root { height: 100%; }
|
||||
@keyframes dd-caret { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="./MobileShared.jsx"></script>
|
||||
<script type="text/babel" src="./KeyboardScreen.jsx"></script>
|
||||
<script type="text/babel" src="./AppScreens.jsx"></script>
|
||||
<script type="text/babel">
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} style={{
|
||||
height: 28, padding: '0 13px', borderRadius: 'var(--radius-full)', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
border: active ? '1px solid transparent' : '1px solid var(--border-1)',
|
||||
background: active ? 'var(--accent-soft)' : 'var(--surface-card)',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-2)',
|
||||
fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)',
|
||||
fontWeight: active ? 'var(--weight-medium)' : 'var(--weight-regular)',
|
||||
transition: 'background var(--dur-fast) var(--ease-out)',
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileKit() {
|
||||
const [override, setOverride] = React.useState(null); // null = 跟随系统
|
||||
const [systemDark, setSystemDark] = React.useState(
|
||||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const fn = (e) => setSystemDark(e.matches);
|
||||
mq.addEventListener('change', fn);
|
||||
return () => mq.removeEventListener('change', fn);
|
||||
}, []);
|
||||
const dark = override === null ? systemDark : override;
|
||||
const [account, setAccount] = React.useState('ok'); // 键盘状态
|
||||
const [appScreen, setAppScreen] = React.useState('onboarding');
|
||||
|
||||
const APP_SCREENS = [
|
||||
['onboarding', '启用引导'],
|
||||
['login', '登录'],
|
||||
['paywall', '购买时长'],
|
||||
['profile', '我的'],
|
||||
];
|
||||
|
||||
return (
|
||||
<div data-theme={dark ? 'dark' : undefined} style={{
|
||||
height: '100%', display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-app)', transition: 'background var(--dur-base) var(--ease-out)',
|
||||
}}>
|
||||
{/* 顶栏 */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14, padding: '12px 20px',
|
||||
borderBottom: '1px solid var(--border-1)', background: 'var(--surface-card)',
|
||||
}}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 9, color: 'var(--text-1)', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ color: 'var(--accent)', display: 'inline-flex' }}><DuduMLogo size={22} /></span>
|
||||
<span style={{ fontWeight: 'var(--weight-semibold)', fontSize: 'var(--text-base)', letterSpacing: 'var(--tracking-wide)' }}>dudu</span>
|
||||
<span style={{ fontSize: 'var(--text-sm)', color: 'var(--text-3)' }}>移动端 · iOS / Android</span>
|
||||
</span>
|
||||
<button type="button" onClick={() => setOverride(!dark)} aria-label="切换深浅模式" style={{
|
||||
marginLeft: 'auto', width: 32, height: 32, borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-1)', background: 'var(--surface-card)', color: 'var(--text-2)',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||||
}}>
|
||||
{dark ? (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2"></path><path d="M12 20v2"></path><path d="m4.93 4.93 1.41 1.41"></path><path d="m17.66 17.66 1.41 1.41"></path><path d="M2 12h2"></path><path d="M20 12h2"></path><path d="m6.34 17.66-1.41 1.41"></path><path d="m19.07 4.93-1.41 1.41"></path></svg>
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path></svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 舞台:键盘 + 主 App 并排 */}
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'auto' }}>
|
||||
<div style={{ margin: 'auto', display: 'flex', gap: 56, alignItems: 'flex-start', padding: 28 }}>
|
||||
{/* 键盘扩展 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<Chip active={account === 'ok'} onClick={() => setAccount('ok')}>有余额</Chip>
|
||||
<Chip active={account === 'trial'} onClick={() => setAccount('trial')}>试用中</Chip>
|
||||
<Chip active={account === 'guest'} onClick={() => setAccount('guest')}>未登录</Chip>
|
||||
<Chip active={account === 'quota'} onClick={() => setAccount('quota')}>试用已用完</Chip>
|
||||
</div>
|
||||
<PhoneFrame>
|
||||
<KeyboardScreen account={account} key={account} />
|
||||
</PhoneFrame>
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}>键盘扩展 — 按住麦克风条说话</span>
|
||||
</div>
|
||||
|
||||
{/* 主 App */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{APP_SCREENS.map(([key, label]) => (
|
||||
<Chip key={key} active={appScreen === key} onClick={() => setAppScreen(key)}>{label}</Chip>
|
||||
))}
|
||||
</div>
|
||||
<PhoneFrame>
|
||||
{appScreen === 'onboarding' && <OnboardingScreen />}
|
||||
{appScreen === 'login' && <LoginScreen />}
|
||||
{appScreen === 'paywall' && <PaywallScreen />}
|
||||
{appScreen === 'profile' && <ProfileScreen />}
|
||||
</PhoneFrame>
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}>主 App — 登录 / 购买 / 用量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<MobileKit />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user