diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index bd0df31..0536a38 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -8,6 +8,7 @@ export type StoreCreateForm = { districtCode?: string; name: string; phone: string; + smsCode: string; address: string; intro?: string; coverUrl?: string; @@ -16,22 +17,21 @@ export type StoreCreateForm = { bankAccountName: string; bankAccountNo: string; bankBranch: string; - accountPhone?: string; - accountName?: string; }; const PHONE_RE = /^1\d{10}$/; const BANK_RE = /^\d{16,19}$/; export function validateStoreCreateStep1( - form: Pick, + form: Pick, ): string | null { if (!form.partnerId) return '请选择开城合伙人'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 开城城市」配置对应区划'; if (!form.name?.trim()) return '请填写门店名称'; - if (!form.phone?.trim()) return '请填写联系电话'; - if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号'; + if (!form.phone?.trim()) return '请填写门店手机号'; + if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; + if (!form.smsCode?.trim()) return '请填写短信验证码'; if (!form.address?.trim()) return '请填写详细地址'; if (form.intro?.trim()) { const len = form.intro.trim().length; diff --git a/apps/admin-web/src/pages/StoreAccountsPage.tsx b/apps/admin-web/src/pages/StoreAccountsPage.tsx index 8fbd3b1..a415763 100644 --- a/apps/admin-web/src/pages/StoreAccountsPage.tsx +++ b/apps/admin-web/src/pages/StoreAccountsPage.tsx @@ -59,7 +59,10 @@ export default function StoreAccountsPage() { return (
- 门店账户 + + 门店账户 + 新建门店时自动开通主账号;「新建账户」仅用于补录历史无账号门店 +
{ setFilters(v); setPage(1); }}> diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 15c71f8..eeddae2 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -81,6 +81,7 @@ export default function StoresPage() { const [createOpen, setCreateOpen] = useState(false); const [createStep, setCreateStep] = useState(0); const [createError, setCreateError] = useState(''); + const [smsCooldown, setSmsCooldown] = useState(0); const [partners, setPartners] = useState([]); const [cities, setCities] = useState([]); const [optionsLoading, setOptionsLoading] = useState(false); @@ -138,9 +139,38 @@ export default function StoresPage() { setCreateOpen(false); setCreateStep(0); setCreateError(''); + setSmsCooldown(0); createForm.resetFields(); } + async function sendCreateSms() { + const phone = String(createForm.getFieldValue('phone') ?? '').trim(); + if (!/^1\d{10}$/.test(phone)) { + message.error('请先填写正确的11位门店手机号'); + return; + } + if (smsCooldown > 0) return; + try { + await request('/admin/stores/phone/sms/send', { + method: 'POST', + body: JSON.stringify({ phone }), + }); + message.success('验证码已发送'); + setSmsCooldown(60); + const timer = setInterval(() => { + setSmsCooldown((s) => { + if (s <= 1) { + clearInterval(timer); + return 0; + } + return s - 1; + }); + }, 1000); + } catch (e) { + message.error(e instanceof Error ? e.message : '发送失败'); + } + } + function openCreateModal() { void loadOptions(); createForm.setFieldsValue({ @@ -160,7 +190,7 @@ export default function StoresPage() { return; } try { - await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']); + await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'smsCode', 'address']); } catch { return; } @@ -188,6 +218,7 @@ export default function StoresPage() { city: values.city, name: values.name.trim(), phone: values.phone.trim(), + smsCode: values.smsCode.trim(), district: values.district.trim(), address: values.address.trim(), intro: values.intro?.trim() || undefined, @@ -197,8 +228,6 @@ export default function StoresPage() { bankAccountName: values.bankAccountName.trim(), bankAccountNo: values.bankAccountNo.replace(/\s/g, ''), bankBranch: values.bankBranch.trim(), - accountPhone: values.accountPhone?.trim() || undefined, - accountName: values.accountName?.trim() || undefined, }), }); message.success('门店已创建'); @@ -208,7 +237,7 @@ export default function StoresPage() { if (e && typeof e === 'object' && 'errorFields' in e) { const fields = e as { errorFields?: Array<{ name: string[] }> }; const first = fields.errorFields?.[0]?.name?.[0]; - if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') { + if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone' || first === 'smsCode') { setCreateStep(0); } return; @@ -370,21 +399,25 @@ export default function StoresPage() { - + + + + + + + + + - - - - - -
diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts index f480ce6..850a1e2 100644 --- a/apps/h5-partner/src/lib/storeDraft.ts +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -8,8 +8,6 @@ export type StoreDraftForm = { phone: string; address: string; intro: string; - accountPhone: string; - accountName: string; coverUrl: string; envPhotoUrls: string[]; contractUrl: string; @@ -35,8 +33,6 @@ export const defaultStoreForm = (): StoreDraftForm => ({ phone: '', address: '', intro: '', - accountPhone: '', - accountName: '', coverUrl: '', envPhotoUrls: ['', '', ''], contractUrl: '', @@ -67,8 +63,6 @@ function normalizeForm(raw: Record): StoreDraftForm { phone: String(raw.phone ?? base.phone), address: String(raw.address ?? base.address), intro: String(raw.intro ?? base.intro), - accountPhone: String(raw.accountPhone ?? base.accountPhone), - accountName: String(raw.accountName ?? base.accountName), coverUrl: String(raw.coverUrl ?? base.coverUrl), envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3), contractUrl: String(raw.contractUrl ?? base.contractUrl), diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index d84685b..6644c9e 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -148,8 +148,6 @@ export default function StoreCreatePage() { bankAccountName: form.bankAccountName.trim(), bankAccountNo: form.bankAccountNo.replace(/\s/g, ''), bankBranch: form.bankBranch.trim(), - accountPhone: form.accountPhone.trim() || undefined, - accountName: form.accountName.trim() || undefined, }), }); clearStoreDraft(); @@ -216,10 +214,10 @@ export default function StoreCreatePage() {
- +
call - patchForm({ phone: e.target.value })} /> + patchForm({ phone: e.target.value })} />
@@ -233,20 +231,6 @@ export default function StoreCreatePage() { {form.intro.length} / 500
-
- -
- smartphone - patchForm({ accountPhone: e.target.value })} /> -
-
-
- -
- person - patchForm({ accountName: e.target.value })} /> -
-
diff --git a/apps/h5-shop/src/App.tsx b/apps/h5-shop/src/App.tsx index 8dd5ecb..1a44895 100644 --- a/apps/h5-shop/src/App.tsx +++ b/apps/h5-shop/src/App.tsx @@ -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 ( - - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - - } /> - + + + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + + } /> + + ); } diff --git a/apps/h5-shop/src/components/AuthGate.tsx b/apps/h5-shop/src/components/AuthGate.tsx new file mode 100644 index 0000000..241df76 --- /dev/null +++ b/apps/h5-shop/src/components/AuthGate.tsx @@ -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 ( +
+

加载中…

+
+ ); + } + + if (authenticated && location.pathname === '/login') { + return ; + } + + if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { + return ; + } + + return <>{children}; +} diff --git a/apps/h5-shop/src/contexts/StoreSessionContext.tsx b/apps/h5-shop/src/contexts/StoreSessionContext.tsx new file mode 100644 index 0000000..342b247 --- /dev/null +++ b/apps/h5-shop/src/contexts/StoreSessionContext.tsx @@ -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(null); + +export function StoreSessionProvider({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(false); + const [authenticated, setAuthenticated] = useState(false); + const [store, setStore] = useState(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 {children}; +} + +export function useStoreSession() { + const ctx = useContext(StoreSessionContext); + if (!ctx) throw new Error('useStoreSession must be used within StoreSessionProvider'); + return ctx; +} diff --git a/apps/h5-shop/src/layouts/TabLayout.tsx b/apps/h5-shop/src/layouts/TabLayout.tsx index 1e800c5..8938fe8 100644 --- a/apps/h5-shop/src/layouts/TabLayout.tsx +++ b/apps/h5-shop/src/layouts/TabLayout.tsx @@ -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 ( <> diff --git a/apps/h5-shop/src/lib/api.ts b/apps/h5-shop/src/lib/api.ts index 1ae717f..d899d6d 100644 --- a/apps/h5-shop/src/lib/api.ts +++ b/apps/h5-shop/src/lib/api.ts @@ -1,27 +1,173 @@ export const apiBase = '/api/v1'; +const CLIENT_APP = 'SHOP_H5'; -export async function request(clientApp: string, path: string, options: RequestInit = {}): Promise { - const token = localStorage.getItem('accessToken'); - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Client-App': clientApp, - ...(options.headers as Record), - }; - 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( + path: string, + options: RequestInit = {}, + token?: string | null, +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': CLIENT_APP, + ...(options.headers as Record), + }; + 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 { + const refreshToken = localStorage.getItem(REFRESH_TOKEN); + if (!refreshToken) return null; + try { + const data = await rawRequest( + '/shop/auth/token/refresh', + { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }, + null, + ); + saveAuth(data); + return data; + } catch { + return null; + } +} + +async function requestWithAuthRetry( + path: string, + options: RequestInit = {}, + retried = false, +): Promise { + try { + return await rawRequest(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(path, options, true); + } +} + +export async function request(clientApp: string, path: string, options: RequestInit = {}): Promise { + void clientApp; + return requestWithAuthRetry(path, options); +} + +export async function ensureSession(): Promise<{ authenticated: boolean; store: StoreSessionStore | null }> { + if (!isLoggedIn()) { + return { authenticated: false, store: null }; + } + try { + const me = await rawRequest('/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; + } } diff --git a/apps/h5-shop/src/main.tsx b/apps/h5-shop/src/main.tsx index 06be8f2..50cf420 100644 --- a/apps/h5-shop/src/main.tsx +++ b/apps/h5-shop/src/main.tsx @@ -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( - , + + + + + + + , ); diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 4ec2df6..7488168 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -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)?.status) === 'OPEN'); }); - }, [navigate]); + }, []); const store = dash?.store as Record | undefined; const recent = (dash?.recentRecords as Array>) || []; diff --git a/apps/h5-shop/src/pages/LoginPage.tsx b/apps/h5-shop/src/pages/LoginPage.tsx index c131711..8215468 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -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('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() { storefront
-

门店管理中心

-

{maskPhone(phone)}

+

{quickStoreName}

+

{maskPhone(quickPhone)}

verified_user @@ -115,7 +126,7 @@ export default function LoginPage() { type="button" className="shop-quick-login-btn" disabled={loading} - onClick={login} + onClick={() => void login({ quick: true })} > {loading ? '登录中...' : '一键登录'} {!loading && arrow_forward} diff --git a/apps/h5-shop/src/pages/MinePage.tsx b/apps/h5-shop/src/pages/MinePage.tsx index 98a82a6..98fc313 100644 --- a/apps/h5-shop/src/pages/MinePage.tsx +++ b/apps/h5-shop/src/pages/MinePage.tsx @@ -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 | 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() {