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>
This commit is contained in:
+62
-36
@@ -1,11 +1,13 @@
|
||||
// 登录 / 购买窗口四步流:扫码登录 → 时长包 → 支付二维码 → 完成
|
||||
// 规格见 doc/frontend-design.html 4.2;二维码内容来自后端,轮询自动推进。
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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 (
|
||||
@@ -32,47 +34,64 @@ function QrBox({ value }) {
|
||||
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 timer = useRef(null);
|
||||
const [qrNonce, setQrNonce] = useState(0); // 递增以触发二维码重建(刷新)
|
||||
|
||||
// 扫码登录:创建二维码 + 轮询
|
||||
// 启动应用主题:随系统/已存偏好(深色模式下不再停在浅色)。
|
||||
useEffect(() => {
|
||||
invoke('get_settings').then((s) => s && setThemePref(s.theme));
|
||||
}, []);
|
||||
|
||||
// 扫码登录:创建二维码(进入 login 步或点击刷新时重建)。
|
||||
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]);
|
||||
let live = true;
|
||||
setQrExpired(false);
|
||||
setQr(null);
|
||||
invoke('login_qr_create').then((r) => { if (live && r) setQr(r); });
|
||||
return () => { live = false; };
|
||||
}, [step, qrNonce]);
|
||||
|
||||
// 支付轮询
|
||||
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]);
|
||||
// 扫码登录轮询:拿到 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);
|
||||
|
||||
@@ -80,8 +99,15 @@ function LoginApp() {
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
import { usePoll } from '../shared/usePoll';
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().includes('MAC');
|
||||
const STEPS = ['welcome', 'perms', 'login', 'try'];
|
||||
@@ -78,24 +79,16 @@ function OnboardingApp() {
|
||||
}, []);
|
||||
|
||||
// 权限步:辅助功能状态轮询(用户去系统设置开启后自动变绿)
|
||||
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]);
|
||||
usePoll(async (alive) => {
|
||||
const ok = await invoke('check_accessibility');
|
||||
if (alive()) setAxOk(!!ok);
|
||||
}, 1000, [step], STEPS[step] === 'perms' && isMac);
|
||||
|
||||
// 登录步:轮询账号,扫码成功后自动亮起
|
||||
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]);
|
||||
usePoll(async (alive) => {
|
||||
const m = await invoke('fetch_me');
|
||||
if (alive() && m) setMe(m);
|
||||
}, 1500, [step], STEPS[step] === 'login');
|
||||
|
||||
const next = () => setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
||||
const prev = () => setStep((s) => Math.max(s - 1, 0));
|
||||
|
||||
@@ -13,8 +13,19 @@ const ERR_TEXT = {
|
||||
ASR_UNAVAILABLE: '识别服务暂不可用,稍后再试',
|
||||
NETWORK: '连接中断,松开重试',
|
||||
NO_ACCESSIBILITY: '需要辅助功能权限才能上屏,去系统设置开启 dudu',
|
||||
UNAUTHORIZED: '登录已失效,去重新登录',
|
||||
SESSION_ABORTED: '连接断了,这次没说完,重新说一次',
|
||||
AUTH_HEADER: '登录信息有误,去重新登录',
|
||||
DEVICE_HEADER: '设备标识有误,重启应用试试',
|
||||
};
|
||||
|
||||
// 未知 code(如 BAD_REQUEST/INTERNAL)且服务端 message(omitempty)缺失时的兜底,
|
||||
// 保证任何 error 帧都有可见错误态,不会因空串停在「听写中」。
|
||||
const ERR_FALLBACK = '出错了,稍后再试';
|
||||
function errText(payload) {
|
||||
return ERR_TEXT[payload.code] || payload.message || payload.text || ERR_FALLBACK;
|
||||
}
|
||||
|
||||
function OverlayApp() {
|
||||
const [finalText, setFinalText] = useState('');
|
||||
const [partialText, setPartialText] = useState('');
|
||||
@@ -28,7 +39,7 @@ function OverlayApp() {
|
||||
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 || '');
|
||||
else if (payload.type === 'error') setError(errText(payload));
|
||||
}).then((off) => offs.push(off));
|
||||
return () => offs.forEach((off) => off());
|
||||
}, []);
|
||||
|
||||
@@ -38,11 +38,22 @@ export function SettingsApp() {
|
||||
invoke('fetch_me').then((m) => m && setMe(m));
|
||||
}, []);
|
||||
|
||||
// 仅这些字段属于「用户可改的设置」,回写时只动这几项,
|
||||
// 其余字段(token / device_id / onboarding_done / server_url)保持后端最新值。
|
||||
const USER_FIELDS = ['hotkey', 'mic', 'sound', 'autostart', 'theme'];
|
||||
|
||||
const update = (patch) => {
|
||||
const next = { ...settings, ...patch };
|
||||
setSettings(next);
|
||||
invoke('set_settings', { settings: next });
|
||||
if (patch.theme) setThemePref(patch.theme);
|
||||
// 保存前重新读最新 settings 再合并,避免用陈旧(可能空)token 覆盖登录流程写入的 token,
|
||||
// 导致先开设置窗再扫码登录、回设置页改开关时静默登出(丢失更新)。
|
||||
invoke('get_settings').then((latest) => {
|
||||
const base = latest || settings;
|
||||
const merged = { ...base };
|
||||
for (const k of USER_FIELDS) merged[k] = next[k];
|
||||
invoke('set_settings', { settings: merged });
|
||||
});
|
||||
};
|
||||
|
||||
const minutes = me ? Math.floor(me.balance_seconds / 60) : 0;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// 统一轮询钩子:interval + 存活标志 + 卸载清理 + 在途去重。
|
||||
// 用法:usePoll(async () => {...; return 'stop' 以提前停止}, intervalMs, deps, enabled)
|
||||
// - fn 在每个 tick 调用,await 期间组件卸载则丢弃结果(不 setState)。
|
||||
// - fn 返回 'stop'(或经 alive() 自行判断)时停止本轮轮询,避免拿到终态后还永久 1Hz 打后端。
|
||||
// - 同一时刻只有一个在途请求;上一次未返回则跳过本 tick,避免请求堆积/竞态。
|
||||
// - deps 变化或卸载时清理定时器并失活,await 中的回调不再写状态。
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function usePoll(fn, intervalMs, deps = [], enabled = true) {
|
||||
// 用 ref 持有最新 fn,避免把 fn 放进 deps 造成每次渲染重启轮询。
|
||||
const fnRef = useRef(fn);
|
||||
fnRef.current = fn;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
let alive = true;
|
||||
let inFlight = false;
|
||||
let timer = null;
|
||||
|
||||
const stop = () => {
|
||||
alive = false;
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (!alive || inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const r = await fnRef.current(() => alive);
|
||||
// await 期间组件可能已卸载/deps 变化:丢弃结果。
|
||||
if (!alive) return;
|
||||
if (r === 'stop') stop();
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 立即触发一次,再按间隔轮询。
|
||||
tick();
|
||||
timer = setInterval(tick, intervalMs);
|
||||
return stop;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [intervalMs, enabled, ...deps]);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
import { setThemePref } from '../shared/theme';
|
||||
|
||||
function Item({ children, right, onClick }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
@@ -28,6 +29,8 @@ function TrayApp() {
|
||||
const [me, setMe] = useState(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
useEffect(() => {
|
||||
// 启动应用主题:深色模式下托盘菜单不再停在浅色。
|
||||
invoke('get_settings').then((s) => s && setThemePref(s.theme));
|
||||
invoke('fetch_me').then((m) => m && setMe(m));
|
||||
invoke('is_paused').then((p) => setPaused(!!p));
|
||||
listen('account', ({ payload }) => setMe(payload));
|
||||
|
||||
Reference in New Issue
Block a user