Files
dudu/desktop/src/login/main.jsx
T
wangjia b5ab92a57e
ci / server (push) Failing after 13s
ci / design-tokens (push) Failing after 11s
fix: 应用 xhigh 代码评审的跨端修复
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端:
- server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、
  billing 行锁、redis Lua 过期与设备槽刷新、config 解析
- desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询
- android:握手时序、请求头封装、账户状态派生、按需重组
- ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:50:08 +08:00

187 lines
8.9 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 登录 / 购买窗口四步流:扫码登录 → 时长包 → 支付二维码 → 完成
// 规格见 doc/frontend-design.html 4.2;二维码内容来自后端,轮询自动推进。
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 { invoke } from '../shared/tauri';
import { setThemePref } from '../shared/theme';
import { usePoll } from '../shared/usePoll';
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 [qrExpired, setQrExpired] = useState(false);
const [packs, setPacks] = useState([]);
const [sel, setSel] = useState('pack_500');
const [order, setOrder] = useState(null);
const [me, setMe] = useState(null);
const [qrNonce, setQrNonce] = useState(0); // 递增以触发二维码重建(刷新)
// 启动应用主题:随系统/已存偏好(深色模式下不再停在浅色)。
useEffect(() => {
invoke('get_settings').then((s) => s && setThemePref(s.theme));
}, []);
// 扫码登录:创建二维码(进入 login 步或点击刷新时重建)。
useEffect(() => {
if (step !== 'login') 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]);
// 扫码登录轮询:拿到 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');
return 'stop';
}
// 后端 2 分钟 qrTTL 过期后返回 expired:停轮询并提示点击刷新。
if (st && st.status === 'expired') {
setQrExpired(true);
return 'stop';
}
return undefined;
}, 1000, [qr && qr.state], step === 'login' && !!qr && !qrExpired);
// 支付轮询:付款成功即停。
usePoll(async (alive) => {
const st = await invoke('order_status', { orderId: order.order_id });
if (!alive()) return 'stop';
if (st && st.status === 'paid') {
const m = await invoke('fetch_me');
if (!alive()) return 'stop';
setMe(m);
setStep('done');
return 'stop';
}
return undefined;
}, 1000, [order && order.order_id], step === 'pay' && !!order);
const refreshQr = () => setQrNonce((n) => n + 1);
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>
);
}
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 />);