门店账号管理修改

This commit is contained in:
2026-07-07 00:01:16 +08:00
parent e85c4b9bcb
commit 7ca9fc8a35
25 changed files with 599 additions and 134 deletions
+15 -12
View File
@@ -1,4 +1,5 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import AuthGate from './components/AuthGate';
import TabLayout from './layouts/TabLayout';
import LoginPage from './pages/LoginPage';
import HomePage from './pages/HomePage';
@@ -10,17 +11,19 @@ import MinePage from './pages/MinePage';
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/redeem" element={<RedeemConfirmPage />} />
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
<Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/records" element={<RecordsPage />} />
<Route path="/status" element={<StatusPage />} />
<Route path="/mine" element={<MinePage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<AuthGate>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/redeem" element={<RedeemConfirmPage />} />
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
<Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/records" element={<RecordsPage />} />
<Route path="/status" element={<StatusPage />} />
<Route path="/mine" element={<MinePage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthGate>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
const PUBLIC_PATHS = new Set(['/login']);
export default function AuthGate({ children }: { children: React.ReactNode }) {
const { ready, authenticated } = useStoreSession();
const location = useLocation();
if (!ready) {
return (
<div className="session-boot">
<p className="session-boot-text"></p>
</div>
);
}
if (authenticated && location.pathname === '/login') {
return <Navigate to="/" replace />;
}
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <>{children}</>;
}
@@ -0,0 +1,76 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import {
clearAuth,
ensureSession,
saveAuth,
type ShopSessionPayload,
type StoreSessionStore,
} from '../lib/api';
type StoreSessionContextValue = {
ready: boolean;
authenticated: boolean;
store: StoreSessionStore | null;
applySession: (session: ShopSessionPayload) => void;
resetSession: () => void;
};
const StoreSessionContext = createContext<StoreSessionContextValue | null>(null);
export function StoreSessionProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [store, setStore] = useState<StoreSessionStore | null>(null);
const applySession = useCallback((session: ShopSessionPayload) => {
saveAuth(session);
setAuthenticated(true);
if (session.store) setStore(session.store);
}, []);
const resetSession = useCallback(() => {
clearAuth();
setAuthenticated(false);
setStore(null);
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await ensureSession();
if (cancelled) return;
setAuthenticated(result.authenticated);
setStore(result.store);
} catch {
if (!cancelled) resetSession();
} finally {
if (!cancelled) setReady(true);
}
})();
return () => {
cancelled = true;
};
}, [resetSession]);
const value = useMemo(
() => ({ ready, authenticated, store, applySession, resetSession }),
[ready, authenticated, store, applySession, resetSession],
);
return <StoreSessionContext.Provider value={value}>{children}</StoreSessionContext.Provider>;
}
export function useStoreSession() {
const ctx = useContext(StoreSessionContext);
if (!ctx) throw new Error('useStoreSession must be used within StoreSessionProvider');
return ctx;
}
+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: '首页' },
@@ -8,11 +7,6 @@ const TABS = [
] as const;
export default function TabLayout() {
const navigate = useNavigate();
if (!isLoggedIn()) {
navigate('/login');
return null;
}
return (
<>
<Outlet />
+162 -16
View File
@@ -1,27 +1,173 @@
export const apiBase = '/api/v1';
const CLIENT_APP = 'SHOP_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}`;
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 StoreSessionStore = {
id: string;
storeId: string;
name: string;
phone: string;
storeName: string;
};
export type StoreProfile = {
id: string;
storeId: string;
name: string;
phone: string;
status?: string;
store?: { id: string; name: string };
};
export type ShopSessionPayload = {
accessToken: string;
refreshToken: string;
store?: StoreSessionStore;
};
const ACCESS_TOKEN = 'accessToken';
const REFRESH_TOKEN = 'refreshToken';
const LAST_PHONE = 'shopLastPhone';
const STORE_PROFILE = 'shopStoreProfile';
const AUTH_RECOVERY_EXEMPT_PATHS = ['/shop/auth/token/refresh', '/shop/auth/sms/send', '/shop/auth/login/sms'];
export function getLastPhone() {
return localStorage.getItem(LAST_PHONE) ?? '';
}
export function saveAuth(data: { accessToken: string }) {
localStorage.setItem('accessToken', data.accessToken);
export function getStoreProfile(): StoreSessionStore | null {
try {
const raw = localStorage.getItem(STORE_PROFILE);
return raw ? (JSON.parse(raw) as StoreSessionStore) : null;
} catch {
return null;
}
}
export function saveAuth(data: ShopSessionPayload) {
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
if (data.store) {
localStorage.setItem(STORE_PROFILE, JSON.stringify(data.store));
localStorage.setItem(LAST_PHONE, data.store.phone);
}
}
export function clearAuth() {
localStorage.removeItem('accessToken');
localStorage.removeItem(ACCESS_TOKEN);
localStorage.removeItem(REFRESH_TOKEN);
localStorage.removeItem(STORE_PROFILE);
}
export function isLoggedIn() {
return !!localStorage.getItem('accessToken');
return !!localStorage.getItem(ACCESS_TOKEN);
}
function profileFromMe(me: StoreProfile): StoreSessionStore {
return {
id: me.id,
storeId: me.storeId,
name: me.name,
phone: me.phone,
storeName: me.store?.name ?? me.name,
};
}
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;
}
async function refreshSession(): Promise<ShopSessionPayload | null> {
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
if (!refreshToken) return null;
try {
const data = await rawRequest<ShopSessionPayload>(
'/shop/auth/token/refresh',
{
method: 'POST',
body: JSON.stringify({ refreshToken }),
},
null,
);
saveAuth(data);
return data;
} catch {
return null;
}
}
async function requestWithAuthRetry<T>(
path: string,
options: RequestInit = {},
retried = false,
): Promise<T> {
try {
return await rawRequest<T>(path, options);
} catch (e) {
const err = e as Error & { status?: number };
const canRecover =
err.status === 401 &&
!retried &&
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
if (!canRecover) throw e;
const refreshed = await refreshSession();
if (!refreshed) {
clearAuth();
throw e;
}
return requestWithAuthRetry<T>(path, options, true);
}
}
export async function request<T>(clientApp: string, path: string, options: RequestInit = {}): Promise<T> {
void clientApp;
return requestWithAuthRetry<T>(path, options);
}
export async function ensureSession(): Promise<{ authenticated: boolean; store: StoreSessionStore | null }> {
if (!isLoggedIn()) {
return { authenticated: false, store: null };
}
try {
const me = await rawRequest<StoreProfile>('/shop/auth/me');
const store = profileFromMe(me);
saveAuth({
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
store,
});
return { authenticated: true, store };
} catch (e) {
const err = e as Error & { status?: number };
if (err.status === 401) {
const refreshed = await refreshSession();
if (refreshed?.store) {
return { authenticated: true, store: refreshed.store };
}
clearAuth();
return { authenticated: false, store: null };
}
const cached = getStoreProfile();
if (cached) return { authenticated: true, store: cached };
throw e;
}
}
+8 -1
View File
@@ -1,9 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { StoreSessionProvider } from './contexts/StoreSessionContext';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode><BrowserRouter><App /></BrowserRouter></React.StrictMode>,
<React.StrictMode>
<BrowserRouter>
<StoreSessionProvider>
<App />
</StoreSessionProvider>
</BrowserRouter>
</React.StrictMode>,
);
+2 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { request } from '../lib/api';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
function formatMoney(n: number) {
@@ -13,15 +13,11 @@ export default function HomePage() {
const [open, setOpen] = useState(true);
useEffect(() => {
if (!isLoggedIn()) {
navigate('/login');
return;
}
request('SHOP_H5', '/shop/dashboard').then((d) => {
setDash(d);
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
});
}, [navigate]);
}, []);
const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
+27 -16
View File
@@ -1,24 +1,33 @@
import { useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request, saveAuth } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { getLastPhone, getStoreProfile, request, type ShopSessionPayload } from '../lib/api';
function maskPhone(phone: string) {
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
const DEV_DEFAULT_PHONE = import.meta.env.DEV ? '13900000001' : '';
const DEV_DEFAULT_CODE = import.meta.env.DEV ? '123456' : '';
export default function LoginPage() {
const navigate = useNavigate();
const { applySession } = useStoreSession();
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const [phone, setPhone] = useState('13900000001');
const [code, setCode] = useState('123456');
const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone() || DEV_DEFAULT_PHONE);
const [code, setCode] = useState(DEV_DEFAULT_CODE);
const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0);
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
const quickPhone = savedProfile?.phone || phone;
function ensureAgreed() {
if (!agreed) {
setMsg('请先阅读并同意用户协议');
@@ -35,7 +44,7 @@ export default function LoginPage() {
method: 'POST',
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
});
setMsg('验证码已发送(Mock: 123456');
setMsg(import.meta.env.DEV ? '验证码已发送(Mock: 123456' : '验证码已发送');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((c) => {
@@ -51,20 +60,22 @@ export default function LoginPage() {
}
}
async function login() {
if (!ensureAgreed()) return;
async function login(options?: { quick?: boolean }) {
if (!options?.quick && !ensureAgreed()) return;
setLoading(true);
setMsg('');
try {
await request('SHOP_H5', '/shop/auth/sms/send', {
if (options?.quick) {
await request('SHOP_H5', '/shop/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone: quickPhone, scene: 'STORE_LOGIN' }),
});
}
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
body: JSON.stringify({ phone: options?.quick ? quickPhone : phone, code }),
});
const data = await request<{ accessToken: string }>('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone, code }),
});
saveAuth(data);
applySession(data);
navigate('/');
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
@@ -93,8 +104,8 @@ export default function LoginPage() {
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
</div>
<div>
<h2 className="shop-quick-store-name"></h2>
<p className="shop-quick-store-phone">{maskPhone(phone)}</p>
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
</div>
<span className="shop-quick-verified">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
@@ -115,7 +126,7 @@ export default function LoginPage() {
type="button"
className="shop-quick-login-btn"
disabled={loading}
onClick={login}
onClick={() => void login({ quick: true })}
>
<span>{loading ? '登录中...' : '一键登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
+5 -7
View File
@@ -1,18 +1,16 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { clearAuth, isLoggedIn, request } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api';
export default function MinePage() {
const navigate = useNavigate();
const { resetSession } = useStoreSession();
const [store, setStore] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
if (!isLoggedIn()) {
navigate('/login');
return;
}
request('SHOP_H5', '/shop/store').then(setStore);
}, [navigate]);
}, []);
const openTime = String(store?.openTime || '09:30');
const closeTime = String(store?.closeTime || '22:00');
@@ -65,7 +63,7 @@ export default function MinePage() {
<button
type="button"
className="shop-mine-logout"
onClick={() => { clearAuth(); navigate('/login'); }}
onClick={() => { resetSession(); navigate('/login'); }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
退
+4 -2
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { clearAuth, request } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api';
export default function StatusPage() {
const navigate = useNavigate();
const { resetSession } = useStoreSession();
const [open, setOpen] = useState(true);
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [lastUpdate, setLastUpdate] = useState('');
@@ -58,7 +60,7 @@ export default function StatusPage() {
<button
type="button"
className="shop-status-logout app-page-header-action app-page-header-action--end"
onClick={() => { clearAuth(); navigate('/login'); }}
onClick={() => { resetSession(); navigate('/login'); }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
退
+13
View File
@@ -1918,3 +1918,16 @@
gap: 8px;
cursor: pointer;
}
.session-boot {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-background);
}
.session-boot-text {
color: var(--color-muted);
font-size: 14px;
}