feat(auth+desktop): 邮箱验证码登录 + 登录窗改版(微信/邮箱双方式,无边框)
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 10s

后端(协议 → 存储 → 接口,E2E 测试全绿):
- protocol:AuthEmailCodeRequest / AuthEmailRequest DTO
- store:EmailIdentity 表(邮箱唯一索引)+ authmail:* Redis key
- auth/email.go:POST /v1/auth/email/code 发 6 位码(crypto/rand,
  60s 冷却 SetNX,10 分钟 TTL);POST /v1/auth/email 常量时间比对、
  码一次性、邮箱建号(昵称取前缀)→ JWT(与微信登录同响应形态)
- Mailer 接口 + MockMailer(验证码打日志供联调;SMTP 未配置自动降级,
  装配结果入启动日志,上线前须换真实实现)
- TestEmailLogin E2E:发码/冷却 429/错码拒绝/登录/token 可用/码防复用

桌面(Rust 命令 + UI 改版):
- api.rs:login_email_code / login_email(成功保存 token + ws 重连,
  与扫码登录同路径)
- 登录窗改版(原型 LoginPurchase 先行,dark 截图验收):logo + 分段
  切换(微信扫码 / 邮箱登录,与设置页同控件语言)+ 邮箱表单
  (60s 倒计时、错误文案、未注册自动建号提示)
- 二维码真渲染:qrcode 库画 canvas(前景/背景取主题 token),
  替换原 URL 文本占位;过期态半透明化
- 登录窗无边框化:transparent + Overlay 标题栏 + 原生贴形阴影;
  抽公共 WindowFrame 组件(设置窗同步重构复用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-11 09:40:16 +08:00
parent 0ca1caa460
commit 19281d9c42
20 changed files with 871 additions and 86 deletions
+164 -40
View File
@@ -1,39 +1,89 @@
// 登录 / 购买窗口四步流:扫码登录 → 时长包 → 支付二维码 → 完成
// 规格见 doc/frontend-design.html 4.2;二维码内容来自后端,轮询自动推进。
import { useEffect, useState } from 'react';
// 登录 / 购买窗口四步流:登录(微信扫码 / 邮箱验证码)→ 时长包 → 支付二维码 → 完成
// 规格见 doc/frontend-design.html 4.2 与 ui_kits/desktop/LoginPurchase.jsx 原型;
// 二维码内容来自后端(qrcode 库本地渲染),轮询自动推进。
import { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import QRCode from 'qrcode';
import '@dudu/design/styles.css';
import { Button } from '@dudu/design/components/core/Button';
import { Badge } from '@dudu/design/components/core/Badge';
import { Icon } from '@dudu/design/components/core/Icon';
import { Input } from '@dudu/design/components/forms/Input';
import { logoMark } from '@dudu/design/icons.js';
import { invoke } from '../shared/tauri';
import { setThemePref } from '../shared/theme';
import { usePoll } from '../shared/usePoll';
import { WindowFrame } from '../shared/WindowFrame';
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',
padding: '22px 24px 28px', boxSizing: 'border-box',
}}>{children}</div>
);
}
// 简易二维码占位:真实渲染由 qr 内容字符串绘制(接入 qrcode 库或后端返图)。
function QrBox({ value }) {
function Logo({ size = 40 }) {
// 安全前提同 Icon 组件:内容为 icons.js 静态常量,无用户输入。
return (
<svg
width={size} height={size} viewBox="0 0 48 48" fill="none" stroke="currentColor"
strokeWidth="3.5" strokeLinecap="round" aria-hidden="true"
dangerouslySetInnerHTML={{ __html: logoMark }}
/>
);
}
// 二维码:qrcode 库画到 canvas(前景/背景取当前主题 token 计算值)。
function QrBox({ value, dimmed }) {
const ref = useRef(null);
useEffect(() => {
if (!value || !ref.current) return;
const css = getComputedStyle(document.documentElement);
QRCode.toCanvas(ref.current, value, {
width: 168,
margin: 1,
color: {
dark: css.getPropertyValue('--text-1').trim() || undefined,
light: css.getPropertyValue('--surface-card').trim() || undefined,
},
}).catch(() => {});
}, [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>
opacity: dimmed ? 0.25 : 1, transition: 'opacity var(--dur-base) var(--ease-out)',
}}>
{value
? <canvas ref={ref} style={{ borderRadius: 4 }} />
: <span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}>加载中</span>}
</div>
);
}
// 登录方式分段切换(与设置页「外观」同控件语言)
function ModeSwitch({ mode, onChange }) {
return (
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
{[['wechat', '微信扫码'], ['email', '邮箱登录']].map(([val, lab]) => (
<button key={val} type="button" onClick={() => onChange(val)} style={{
height: 26, padding: '0 14px', borderRadius: 4, border: 'none', cursor: 'pointer',
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
background: mode === val ? 'var(--surface-card)' : 'transparent',
color: mode === val ? 'var(--text-1)' : 'var(--text-2)',
boxShadow: mode === val ? 'var(--shadow-card)' : 'none',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{lab}</button>
))}
</span>
);
}
function LoginApp() {
const [step, setStep] = useState('login'); // login | plans | pay | done
const [mode, setMode] = useState('wechat'); // wechat | email
const [qr, setQr] = useState(null);
const [qrExpired, setQrExpired] = useState(false);
const [packs, setPacks] = useState([]);
@@ -42,41 +92,56 @@ function LoginApp() {
const [me, setMe] = useState(null);
const [qrNonce, setQrNonce] = useState(0); // 递增以触发二维码重建(刷新)
// 邮箱登录态
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [cooldown, setCooldown] = useState(0);
const [emailErr, setEmailErr] = useState('');
const [busy, setBusy] = useState(false);
// 启动应用主题:随系统/已存偏好(深色模式下不再停在浅色)。
useEffect(() => {
invoke('get_settings').then((s) => s && setThemePref(s.theme));
}, []);
// 扫码登录:创建二维码(进入 login 步或点击刷新时重建)。
useEffect(() => {
if (step !== 'login') return undefined;
if (cooldown <= 0) return undefined;
const t = setTimeout(() => setCooldown(cooldown - 1), 1000);
return () => clearTimeout(t);
}, [cooldown]);
// 扫码登录:创建二维码(进入 login 步或点击刷新时重建;邮箱模式下不建)。
useEffect(() => {
if (step !== 'login' || mode !== 'wechat') return undefined;
let live = true;
setQrExpired(false);
setQr(null);
invoke('login_qr_create').then((r) => { if (live && r) setQr(r); });
return () => { live = false; };
}, [step, qrNonce]);
}, [step, mode, qrNonce]);
const enterPlans = async () => {
const m = await invoke('fetch_me');
const ps = await invoke('fetch_packs');
setMe(m);
setPacks(ps || []);
setStep('plans');
};
// 扫码登录轮询:拿到 confirmed/expired 即停(不再永久 1Hz 打后端)。
usePoll(async (alive) => {
const st = await invoke('login_qr_poll', { state: qr.state });
if (!alive()) return 'stop';
if (st && st.status === 'confirmed') {
const m = await invoke('fetch_me');
const ps = await invoke('fetch_packs');
if (!alive()) return 'stop';
setMe(m);
setPacks(ps || []);
setStep('plans');
await enterPlans();
return 'stop';
}
// 后端 2 分钟 qrTTL 过期后返回 expired:停轮询并提示点击刷新。
if (st && st.status === 'expired') {
setQrExpired(true);
return 'stop';
}
return undefined;
}, 1000, [qr && qr.state], step === 'login' && !!qr && !qrExpired);
}, 1000, [qr && qr.state], step === 'login' && mode === 'wechat' && !!qr && !qrExpired);
// 支付轮询:付款成功即停。
usePoll(async (alive) => {
@@ -94,27 +159,81 @@ function LoginApp() {
const refreshQr = () => setQrNonce((n) => n + 1);
const sendCode = async () => {
setEmailErr('');
const status = await invoke('login_email_code', { email: email.trim() });
if (status === 204) setCooldown(60);
else if (status === 429) setEmailErr('发送太频繁,稍后再试');
else setEmailErr('发送失败,检查邮箱地址');
};
const emailLogin = async () => {
setBusy(true);
setEmailErr('');
const r = await invoke('login_email', { email: email.trim(), code });
setBusy(false);
if (r && r.token) {
await enterPlans();
} else {
setEmailErr('验证码错误或已过期');
}
};
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={qrExpired ? '二维码已过期' : (qr && qr.qr_url)} />
{qrExpired ? (
<>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>二维码已过期点击刷新重新扫码</div>
<Button variant="primary" size="sm" onClick={refreshQr}>刷新二维码</Button>
</>
) : (
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>打开微信扫一扫确认后自动登录</div>
)}
</Center>
<WindowFrame title="dudu — 登录">
<Center>
<span style={{ color: 'var(--accent)', marginTop: 4 }}><Logo size={40} /></span>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>登录 dudu</div>
<ModeSwitch mode={mode} onChange={setMode} />
{mode === 'wechat' ? (
<>
<QrBox value={qr && qr.qr_url} dimmed={qrExpired} />
{qrExpired ? (
<>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>二维码已过期点击刷新重新扫码</div>
<Button variant="primary" size="sm" onClick={refreshQr}>刷新二维码</Button>
</>
) : (
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>打开微信扫一扫确认后自动登录</div>
)}
</>
) : (
<div style={{ alignSelf: 'stretch', display: 'flex', flexDirection: 'column', gap: 10, padding: '4px 8px 0' }}>
<Input
size="lg" type="email" placeholder="邮箱地址" autoFocus
value={email} onChange={(e) => setEmail(e.target.value)}
style={{ width: '100%', boxSizing: 'border-box' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<Input
size="lg" inputMode="numeric" maxLength={6} placeholder="6 位验证码"
value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
style={{ flex: 1, minWidth: 0 }}
/>
<Button variant="secondary" disabled={cooldown > 0 || !email.includes('@')} onClick={sendCode}>
{cooldown > 0 ? `${cooldown}s` : '发送验证码'}
</Button>
</div>
<Button variant="primary" block disabled={code.length !== 6 || busy} onClick={emailLogin}>
{busy ? '登录中…' : '登录'}
</Button>
<div style={{ fontSize: 'var(--text-xs)', color: emailErr ? 'var(--danger)' : 'var(--text-3)', textAlign: 'center' }}>
{emailErr || '未注册的邮箱将自动创建账号'}
</div>
</div>
)}
</Center>
</WindowFrame>
);
}
if (step === 'plans') {
return (
<Center>
<WindowFrame title="dudu — 购买时长">
<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)' }}>
@@ -150,12 +269,14 @@ function LoginApp() {
const o = await invoke('create_order', { packId: sel });
if (o) { setOrder(o); setStep('pay'); }
}}>微信支付 ¥{pack ? pack.price_cents / 100 : '—'}</Button>
</Center>
</Center>
</WindowFrame>
);
}
if (step === 'pay') {
return (
<Center>
<WindowFrame title="dudu — 购买时长">
<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}
@@ -164,14 +285,16 @@ function LoginApp() {
<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>
</Center>
</WindowFrame>
);
}
return (
<Center>
<WindowFrame title="dudu — 购买时长">
<Center>
<span style={{
width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28,
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, marginTop: 24,
}}>
<Icon name="check" size={28} sw={2.5} />
</span>
@@ -180,7 +303,8 @@ function LoginApp() {
时长余额 {me ? Math.floor(me.balance_seconds / 60) : '—'} 分钟 · 不过期 · 现在就按住 Space 试试
</div>
<Button variant="primary" onClick={() => invoke('close_login')}>开始使用</Button>
</Center>
</Center>
</WindowFrame>
);
}
+4 -25
View File
@@ -10,6 +10,7 @@ import { SettingRow } from '@dudu/design/components/forms/SettingRow';
import { Select } from '@dudu/design/components/forms/Select';
import { invoke } from '../shared/tauri';
import { setThemePref } from '../shared/theme';
import { WindowFrame } from '../shared/WindowFrame';
const THEME_OPTIONS = [['system', '跟随系统'], ['light', '浅色'], ['dark', '深色']];
@@ -67,31 +68,9 @@ export function SettingsApp() {
const trialUsedMin = me ? Math.floor(me.trial_used_today / 60) : 0;
return (
// 透明窗口自绘 MacWindow 框(对齐原型):radius-md + border-1,占满窗口,
// 阴影用 macOS 原生(贴合内容圆角,窗口即卡片实际尺寸)。
// 原生 NSWindow 底色在深色内容外露白边,故窗口 transparent、框由页面画。
<div style={{
fontFamily: 'var(--font-sans)', position: 'fixed', inset: 0,
background: 'var(--bg-app)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-1)',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
<WindowFrame title="dudu 设置">
<style>{groupCss}</style>
{/* 自绘顶条:系统红绿灯悬浮于左上,标题绝对居中(mac 原生习惯,永不重叠);整条可拖拽 */}
<div
data-tauri-drag-region
style={{
position: 'relative', height: 36, flex: 'none', display: 'flex', alignItems: 'center',
background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)',
userSelect: 'none', WebkitUserSelect: 'none', cursor: 'default',
}}
>
<span style={{
position: 'absolute', left: '50%', transform: 'translateX(-50%)', pointerEvents: 'none',
fontSize: 'var(--text-xs)', fontWeight: 'var(--weight-medium)', color: 'var(--text-2)',
}}>dudu 设置</span>
</div>
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 16, padding: '10px 16px 16px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '10px 16px 16px' }}>
<Group title="输入" order={0}>
<SettingRow label="说话快捷键" description="按住说话,松开上屏">
<HotkeyCombo keys={['⌘', '⇧', 'Space']} />
@@ -164,6 +143,6 @@ export function SettingsApp() {
</SettingRow>
</Group>
</div>
</div>
</WindowFrame>
);
}
+31
View File
@@ -0,0 +1,31 @@
// WindowFrame:透明窗口的自绘 MacWindow 框(对齐原型 DesktopShared.MacWindow 规格)。
// 窗口须配 transparent + titleBarStyle Overlay + hiddenTitle + trafficLightPosition {18,18}
// 阴影用 macOS 原生(贴合内容圆角),窗口即卡片实际尺寸。设置 / 登录等窗口共用。
export function WindowFrame({ title, children }) {
return (
<div style={{
fontFamily: 'var(--font-sans)', position: 'fixed', inset: 0,
background: 'var(--bg-app)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-1)',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* 自绘顶条:系统红绿灯悬浮左上,标题绝对居中(mac 原生习惯);整条可拖拽 */}
<div
data-tauri-drag-region
style={{
position: 'relative', height: 36, flex: 'none', display: 'flex', alignItems: 'center',
background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)',
userSelect: 'none', WebkitUserSelect: 'none', cursor: 'default',
}}
>
<span style={{
position: 'absolute', left: '50%', transform: 'translateX(-50%)', pointerEvents: 'none',
fontSize: 'var(--text-xs)', fontWeight: 'var(--weight-medium)', color: 'var(--text-2)',
}}>{title}</span>
</div>
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column' }}>
{children}
</div>
</div>
);
}