用户静默注册

This commit is contained in:
2026-07-01 02:48:59 +08:00
parent d3dd3a0ad1
commit 9f4577d3d8
18 changed files with 912 additions and 93 deletions
+3
View File
@@ -17,9 +17,11 @@ import RedeemPage from './pages/RedeemPage';
import RedeemCodePage from './pages/RedeemCodePage';
import RedeemSuccessPage from './pages/RedeemSuccessPage';
import PayPage from './pages/PayPage';
import { UserSessionProvider } from './contexts/UserSessionContext';
export default function App() {
return (
<UserSessionProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<TabLayout />}>
@@ -43,5 +45,6 @@ export default function App() {
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</UserSessionProvider>
);
}
@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react';
import { bindPhone, request, saveSession } from '../lib/api';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
type PhoneVerifySheetProps = {
open: boolean;
onClose: () => void;
onSuccess: () => void;
};
export default function PhoneVerifySheet({ open, onClose, onSuccess }: PhoneVerifySheetProps) {
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
const [codeCooldown, setCodeCooldown] = useState(0);
useEffect(() => {
if (!open) {
setPhone('');
setCode('');
setMsg('');
setCodeCooldown(0);
}
}, [open]);
async function sendCode() {
const phoneCheck = validateMobilePhone(phone);
if (!phoneCheck.ok) {
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
return;
}
setMsg('');
await request('USER_H5', '/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'BIND_PHONE' }),
});
setMsg('验证码已发送(Mock: 123456');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((c) => {
if (c <= 1) {
clearInterval(timer);
return 0;
}
return c - 1;
});
}, 1000);
}
async function submit() {
const phoneCheck = validateMobilePhone(phone);
if (!phoneCheck.ok) {
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
return;
}
if (!code.trim()) {
setMsg('请输入验证码');
return;
}
setLoading(true);
setMsg('');
try {
const session = await bindPhone(phone, code);
saveSession(session);
onSuccess();
onClose();
} catch (e) {
setMsg(e instanceof Error ? e.message : '验证失败');
} finally {
setLoading(false);
}
}
if (!open) return null;
return (
<div className="phone-verify-overlay" role="dialog" aria-modal="true">
<button type="button" className="phone-verify-backdrop" aria-label="关闭" onClick={onClose} />
<div className="phone-verify-sheet">
<h3 className="phone-verify-title"></h3>
<p className="phone-verify-desc">便</p>
<div className="login-field">
<span className="login-field-prefix">+86</span>
<input
type="tel"
className="login-field-input"
placeholder="请输入手机号"
maxLength={11}
inputMode="numeric"
value={phone}
onChange={(e) => {
setPhone(normalizePhoneInput(e.target.value));
setMsg('');
}}
/>
</div>
<div className="login-field">
<input
type="text"
inputMode="numeric"
className="login-field-input"
placeholder="请输入验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<button
type="button"
className={`login-get-code${codeCooldown > 0 ? ' disabled' : ''}`}
disabled={codeCooldown > 0}
onClick={sendCode}
>
{codeCooldown > 0 ? `${codeCooldown}s 后重新获取` : '获取验证码'}
</button>
</div>
{msg && <p className="login-msg">{msg}</p>}
<button type="button" className="login-sms-btn" disabled={loading} onClick={submit}>
{loading ? '验证中...' : '确认验证'}
</button>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import {
bootstrapSession,
clearAuth,
ensureSession,
getDeviceKey,
request,
type SessionPayload,
type UserProfile,
} from '../lib/api';
type UserSessionContextValue = {
ready: boolean;
profile: UserProfile | null;
phoneVerified: boolean;
refreshProfile: () => Promise<void>;
resetSession: () => Promise<void>;
};
const UserSessionContext = createContext<UserSessionContextValue | null>(null);
export function UserSessionProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [phoneVerified, setPhoneVerified] = useState(false);
const applySession = useCallback((session: SessionPayload) => {
if (session.user) setProfile(session.user);
setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified);
}, []);
const refreshProfile = useCallback(async () => {
const me = await request<UserProfile>('USER_H5', '/auth/me');
setProfile(me);
setPhoneVerified(!!me.phoneVerified);
}, []);
const resetSession = useCallback(async () => {
clearAuth();
const session = await bootstrapSession();
applySession(session);
}, [applySession]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const session = await ensureSession();
if (cancelled) return;
applySession(session);
if (!session.user) {
await refreshProfile();
}
} catch {
if (!cancelled) {
try {
const session = await bootstrapSession();
applySession(session);
await refreshProfile();
} catch {
/* ignore */
}
}
} finally {
if (!cancelled) setReady(true);
}
})();
return () => {
cancelled = true;
};
}, [applySession, refreshProfile]);
const value = useMemo(
() => ({
ready,
profile,
phoneVerified,
refreshProfile,
resetSession,
}),
[ready, profile, phoneVerified, refreshProfile, resetSession],
);
if (!ready) {
return (
<div className="session-boot">
<p className="session-boot-text">...</p>
</div>
);
}
return <UserSessionContext.Provider value={value}>{children}</UserSessionContext.Provider>;
}
export function useUserSession() {
const ctx = useContext(UserSessionContext);
if (!ctx) throw new Error('useUserSession must be used within UserSessionProvider');
return ctx;
}
export function useOptionalUserSession() {
return useContext(UserSessionContext);
}
/** @deprecated use profile from useUserSession */
export { getDeviceKey };
+1 -7
View File
@@ -1,5 +1,4 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { isLoggedIn } from '../lib/api';
import { NavLink, Outlet } from 'react-router-dom';
const TABS = [
{ to: '/', end: true, icon: 'home', label: '首页', fillActive: false },
@@ -9,11 +8,6 @@ const TABS = [
] as const;
export default function TabLayout() {
const navigate = useNavigate();
if (!isLoggedIn()) {
navigate('/login');
return null;
}
return (
<>
<Outlet />
+134 -22
View File
@@ -7,36 +7,148 @@ export const BRAND = {
};
export const apiBase = '/api/v1';
const CLIENT_APP = 'USER_H5';
export async function request<T>(
clientApp: string,
path: string,
options: RequestInit = {},
): Promise<T> {
const token = localStorage.getItem('accessToken');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(options.headers as Record<string, string>),
};
if (token) headers.Authorization = `Bearer ${token}`;
export type UserProfile = {
id: string;
userNo: string;
phone: string | null;
phoneVerified: boolean;
nickname: string | null;
avatarUrl: string | null;
hasWechat: boolean;
};
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '请求失败');
return json.data as T;
export type SessionPayload = {
accessToken: string;
refreshToken: string;
deviceKey?: string;
phoneVerified: boolean;
user?: UserProfile;
};
const DEVICE_KEY = 'deviceKey';
const ACCESS_TOKEN = 'accessToken';
const REFRESH_TOKEN = 'refreshToken';
export function getDeviceKey() {
return localStorage.getItem(DEVICE_KEY);
}
export function saveAuth(data: { accessToken: string; refreshToken?: string }) {
localStorage.setItem('accessToken', data.accessToken);
if (data.refreshToken) localStorage.setItem('refreshToken', data.refreshToken);
export function saveSession(data: SessionPayload) {
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
}
export function saveAuth(data: { accessToken: string; refreshToken?: string; deviceKey?: string }) {
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
}
export function clearAuth() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem(ACCESS_TOKEN);
localStorage.removeItem(REFRESH_TOKEN);
}
export function isLoggedIn() {
return !!localStorage.getItem('accessToken');
return !!localStorage.getItem(ACCESS_TOKEN);
}
async function rawRequest<T>(
path: string,
options: RequestInit = {},
token?: string | null,
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
...(options.headers as Record<string, string>),
};
const authToken = token ?? localStorage.getItem(ACCESS_TOKEN);
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const json = await res.json();
if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = json.code;
throw err;
}
return json.data as T;
}
export async function request<T>(
_clientApp: string,
path: string,
options: RequestInit = {},
): Promise<T> {
return rawRequest<T>(path, options);
}
export async function bootstrapSession(): Promise<SessionPayload> {
const deviceKey = getDeviceKey();
const data = await rawRequest<SessionPayload>(
'/auth/session/bootstrap',
{
method: 'POST',
body: JSON.stringify(deviceKey ? { deviceKey } : {}),
},
null,
);
saveSession(data);
return data;
}
export async function refreshSession(): Promise<SessionPayload | null> {
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
if (!refreshToken) return null;
try {
const data = await rawRequest<SessionPayload>(
'/auth/token/refresh',
{
method: 'POST',
body: JSON.stringify({ refreshToken }),
},
null,
);
saveSession(data);
return data;
} catch {
return null;
}
}
export async function ensureSession(): Promise<SessionPayload> {
if (isLoggedIn()) {
try {
const me = await rawRequest<UserProfile>('/auth/me');
return {
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
deviceKey: getDeviceKey() ?? undefined,
phoneVerified: !!me.phoneVerified,
user: me,
};
} catch (e) {
const err = e as Error & { status?: number };
if (err.status === 401) {
clearAuth();
} else {
const refreshed = await refreshSession();
if (refreshed) return refreshed;
}
}
}
return bootstrapSession();
}
export async function bindPhone(phone: string, code: string): Promise<SessionPayload> {
const data = await rawRequest<SessionPayload>('/auth/phone/bind', {
method: 'POST',
body: JSON.stringify({ phone, code }),
});
saveSession(data);
return data;
}
+7 -3
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request, saveAuth } from '../lib/api';
import { request, saveSession } from '../lib/api';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
export default function LoginPage() {
@@ -60,11 +60,15 @@ export default function LoginPage() {
setLoading(true);
setMsg('');
try {
const data = await request<{ accessToken: string; refreshToken: string }>('USER_H5', '/auth/login/sms', {
const data = await request<{
accessToken: string;
refreshToken: string;
deviceKey?: string;
}>('USER_H5', '/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone, code }),
});
saveAuth(data);
saveSession(data);
navigate('/');
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
+7 -4
View File
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import TabMainHeader from '../components/TabMainHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import { clearAuth, request } from '../lib/api';
import { request } from '../lib/api';
import { useUserSession } from '../contexts/UserSessionContext';
const DEFAULT_AVATAR =
'https://lh3.googleusercontent.com/aida-public/AB6AXuAz_9Pnpk_Md4sEU6PXkeybus8oLZO9e-3pOpLuSwBX0jm_Z0JCfX1w2oZxz1VZayTh0PKUPjwjSuxJVX410fjtWFGR_f55f-nWppXWUweHRnEC7WyIWEqx4AyVHt-k02OhyaSGQfvY5cHG5IuRe9EqdcHy47gBQ82_cxGgX-DrKV4oYcwLoNRynAV0_xv2p1GOhisnQVulHwZcQClUJcP8q4nTY0Y3DR1w4ioa0DYTHePE43mLDJptjZcQqS7V8LihJdn4ze6fvQA';
@@ -27,7 +28,10 @@ function formatMoney(amount: number) {
export default function MinePage() {
const navigate = useNavigate();
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
const { profile: sessionProfile, resetSession } = useUserSession();
const [profile, setProfile] = useState<Record<string, unknown> | null>(
sessionProfile as Record<string, unknown> | null,
);
const [benefitBalance, setBenefitBalance] = useState(0);
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
const [toast, setToast] = useState('');
@@ -68,8 +72,7 @@ export default function MinePage() {
}
function logout() {
clearAuth();
navigate('/login');
void resetSession().then(() => navigate('/'));
}
const nickname = String(profile?.nickname || '用户');
+56 -17
View File
@@ -6,6 +6,9 @@ import { request } from '../lib/api';
import { buildProductDetailUrl } from '../lib/navigation';
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
import { tryGetClientGpsLocation } from '../lib/client-location';
import { getProductMainImage } from '../lib/product-images';
import PhoneVerifySheet from '../components/PhoneVerifySheet';
import { useUserSession } from '../contexts/UserSessionContext';
type Address = {
id: string;
@@ -48,6 +51,9 @@ function formatAddress(a: Address) {
export default function OrderConfirmPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const { phoneVerified, refreshProfile } = useUserSession();
const [showPhoneVerify, setShowPhoneVerify] = useState(false);
const [pendingSubmit, setPendingSubmit] = useState(false);
const productId = params.get('productId') || '';
const forceCross = params.get('cross') === '1';
const [quantity, setQuantity] = useState(Number(params.get('qty') || 2));
@@ -111,31 +117,55 @@ export default function OrderConfirmPage() {
const productImage =
productIndex === 0 ? STITCH_ORDER_PRODUCT_IMAGE : getProductMainImage(productIndex);
async function doSubmit() {
const clientLocation = await tryGetClientGpsLocation();
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
addressId,
...(clientLocation ? { clientLocation } : {}),
}),
});
const qs = new URLSearchParams();
qs.set('orderId', order.id);
qs.set('productId', productId);
qs.set('qty', String(quantity));
qs.set('addressId', addressId);
if (forceCross) qs.set('cross', '1');
navigate(`/pay?${qs.toString()}`);
}
async function submit() {
if (!addressId) {
setMsg('请选择收货地址');
return;
}
if (!phoneVerified) {
setPendingSubmit(true);
setShowPhoneVerify(true);
return;
}
setLoading(true);
setMsg('');
try {
const clientLocation = await tryGetClientGpsLocation();
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
addressId,
...(clientLocation ? { clientLocation } : {}),
}),
});
const qs = new URLSearchParams();
qs.set('orderId', order.id);
qs.set('productId', productId);
qs.set('qty', String(quantity));
qs.set('addressId', addressId);
if (forceCross) qs.set('cross', '1');
navigate(`/pay?${qs.toString()}`);
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
async function handlePhoneVerified() {
await refreshProfile();
if (!pendingSubmit) return;
setPendingSubmit(false);
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
@@ -296,6 +326,15 @@ export default function OrderConfirmPage() {
</button>
</div>
</footer>
<PhoneVerifySheet
open={showPhoneVerify}
onClose={() => {
setShowPhoneVerify(false);
setPendingSubmit(false);
}}
onSuccess={handlePhoneVerified}
/>
</div>
);
}
+56
View File
@@ -5449,3 +5449,59 @@
color: var(--color-on-surface-variant);
}
/* ── 会话启动 / 手机验证 ── */
.session-boot {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface, #fff);
}
.session-boot-text {
font-size: 14px;
color: var(--color-on-surface-variant, #666);
}
.phone-verify-overlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: flex-end;
justify-content: center;
}
.phone-verify-backdrop {
position: absolute;
inset: 0;
border: none;
background: rgba(0, 0, 0, 0.45);
cursor: pointer;
}
.phone-verify-sheet {
position: relative;
z-index: 1;
width: 100%;
max-width: 480px;
background: #fff;
border-radius: 16px 16px 0 0;
padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
}
.phone-verify-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--color-on-surface, #333);
}
.phone-verify-desc {
margin: 0 0 20px;
font-size: 13px;
color: var(--color-on-surface-variant, #666);
line-height: 1.5;
}