diff --git a/README.md b/README.md index 67a40e4..c5dbbf6 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,10 @@ pnpm dev:partner # http://localhost:5175 | 端 | 手机号 | |----|--------| | C端用户 | 13800000001 | -| 门店 | 13900000001 | | 合伙人 | 13700000001 | +| HQ | 13600000001 | + +门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001`。 ## 主链路冒烟 diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 0f3d4e7..544947b 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -21,7 +21,7 @@ import StoreMediaPage from './pages/StoreMediaPage'; import ProductsPage from './pages/ProductsPage'; import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage'; import ResourcesPage from './pages/ResourcesPage'; -import StorePayoutsPage from './pages/StorePayoutsPage'; +import StoreBillsPage from './pages/StoreBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; import TicketsPage from './pages/TicketsPage'; import UserLogsPage from './pages/UserLogsPage'; @@ -60,7 +60,8 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 846f117..9285d0d 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -40,6 +40,7 @@ const MENU_ITEMS: MenuProps['items'] = [ children: [ { key: '/stores', label: '门店列表' }, { key: '/store-accounts', label: '门店账户' }, + { key: '/store-bills', label: '门店账单' }, { key: '/store-media', label: '门店资源' }, ], }, @@ -62,7 +63,6 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/benefit/ledgers', label: '流水' }, { key: '/redeem-records', label: '核销记录' }, { key: '/redeem/debug', label: '核销调试' }, - { key: '/store-payouts', label: '门店打款' }, ], }, { key: '/partner-bills', icon: , label: '合伙人结算' }, 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/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx new file mode 100644 index 0000000..15e96ea --- /dev/null +++ b/apps/admin-web/src/pages/StoreBillsPage.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState } from 'react'; +import { Button, Descriptions, Drawer, Form, Select, Space, Table, Tag, Typography, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { request, type Paginated } from '../lib/api'; +import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + id: string; + redeemAmount: number; + payoutAmount: number; + settlementRate: number; + status: string; + expectedPayAt: string; + paidAt?: string; + createdAt: string; + store?: { id: string; name: string; cityName: string; phone?: string }; + redeemRecord?: { redeemNo: string; amount?: number }; +}; + +type StoreOption = { id: string; name: string; phone: string }; + +const PAYOUT_STATUS_LABELS: Record = { + PENDING: '待打款', + PAID: '已打款', +}; + +const PAYOUT_STATUS_COLORS: Record = { + PENDING: 'orange', + PAID: 'green', +}; + +export default function StoreBillsPage() { + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const [stores, setStores] = useState([]); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/store-payouts', + () => { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.storeId) qs.set('storeId', filters.storeId); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + useEffect(() => { + void request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setStores(res.items)) + .catch(() => {}); + }, []); + + async function confirmPayout(id: string) { + await request(`/admin/store-payouts/${id}/confirm`, { + method: 'POST', + body: JSON.stringify({ remark: '财务确认打款' }), + }); + message.success('已确认打款'); + reload(); + } + + const columns: ColumnsType = [ + { title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true }, + { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, + { title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, + { + title: '核销单号', + dataIndex: ['redeemRecord', 'redeemNo'], + width: 160, + ellipsis: true, + render: (v) => v || '—', + }, + { title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` }, + { + title: '到账金额', + dataIndex: 'payoutAmount', + width: 100, + render: (v, row) => `¥${v}${row.settlementRate ? ` (${Math.round(row.settlementRate * 100)}%)` : ''}`, + }, + { + title: '打款状态', + dataIndex: 'status', + width: 100, + render: (s) => {PAYOUT_STATUS_LABELS[s] || s}, + }, + { title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime }, + { title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') }, + { + title: '操作', + width: 140, + fixed: 'right', + render: (_, row) => ( + + + {row.status === 'PENDING' && ( + + )} + + ), + }, + ]; + + return ( +
+ + 门店核销账单 + + 每笔核销对应一条 T+1 打款账单,可按门店筛选查看 + + + { + setFilters(v); + setPage(1); + }} + > + + ({ value, label }))} + /> + + + + + + + + + { + setPage(p); + setPageSize(ps); + }, + }} + /> + setDrawerOpen(false)}> + {detail && ( + + {String((detail.store as { name?: string })?.name ?? '—')} + + {String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')} + + ¥{String(detail.redeemAmount)} + ¥{String(detail.payoutAmount)} + + {detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'} + + + {PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)} + + {fmtTime(String(detail.expectedPayAt))} + + {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} + + {fmtTime(String(detail.createdAt))} + + )} + + + ); +} diff --git a/apps/admin-web/src/pages/StorePayoutsPage.tsx b/apps/admin-web/src/pages/StorePayoutsPage.tsx deleted file mode 100644 index ec4bf8c..0000000 --- a/apps/admin-web/src/pages/StorePayoutsPage.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useState } from 'react'; -import { Button, Descriptions, Drawer, Form, Input, Select, Table, Typography, message } from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { request } from '../lib/api'; -import { fmtTime } from '../lib/constants'; -import { useAdminList } from '../lib/useAdminList'; - -type Row = { - id: string; - redeemAmount: number; - payoutAmount: number; - status: string; - expectedPayAt: string; - paidAt?: string; - store?: { name: string; cityName: string }; - redeemRecord?: { redeemNo: string }; -}; - -export default function StorePayoutsPage() { - const [form] = Form.useForm(); - const [filters, setFilters] = useState>({}); - const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/store-payouts', - () => { - const qs = new URLSearchParams(); - if (filters.status) qs.set('status', filters.status); - if (filters.storeId) qs.set('storeId', filters.storeId); - return qs; - }, - [filters], - ); - const [detail, setDetail] = useState | null>(null); - const [drawerOpen, setDrawerOpen] = useState(false); - - async function confirmPayout(id: string) { - await request(`/admin/store-payouts/${id}/confirm`, { - method: 'POST', - body: JSON.stringify({ remark: '财务确认打款' }), - }); - message.success('已确认打款'); - reload(); - } - - const columns: ColumnsType = [ - { title: '门店', dataIndex: ['store', 'name'] }, - { title: '城市', dataIndex: ['store', 'cityName'], width: 100 }, - { title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` }, - { title: '打款额', dataIndex: 'payoutAmount', width: 90, render: (v) => `¥${v}` }, - { title: '状态', dataIndex: 'status', width: 90 }, - { title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime }, - { title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') }, - { - title: '操作', width: 140, - render: (_, row) => ( - <> - - {row.status === 'PENDING' && ( - - )} - - ), - }, - ]; - - return ( -
- 门店打款(T+1) -
{ setFilters(v); setPage(1); }}> - - - - -
{ setPage(p); setPageSize(ps); } }} /> - setDrawerOpen(false)}> - {detail && ( - - ¥{String(detail.redeemAmount)} - ¥{String(detail.payoutAmount)} - {String(detail.status)} - {fmtTime(String(detail.expectedPayAt))} - - )} - - - ); -} 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/components/WechatScanAuthModal.tsx b/apps/h5-shop/src/components/WechatScanAuthModal.tsx new file mode 100644 index 0000000..d557326 --- /dev/null +++ b/apps/h5-shop/src/components/WechatScanAuthModal.tsx @@ -0,0 +1,40 @@ +type WechatScanAuthModalProps = { + open: boolean; + loading?: boolean; + error?: string; + onAuthorize: () => void; + onCancel: () => void; +}; + +export default function WechatScanAuthModal({ + open, + loading, + error, + onAuthorize, + onCancel, +}: WechatScanAuthModalProps) { + if (!open) return null; + + return ( +
+
+
+ qr_code_scanner +
+

微信授权

+

+ 扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。 +

+ {error &&

{error}

} +
+ + +
+
+
+ ); +} 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/lib/redeem-scan.ts b/apps/h5-shop/src/lib/redeem-scan.ts new file mode 100644 index 0000000..ff640d6 --- /dev/null +++ b/apps/h5-shop/src/lib/redeem-scan.ts @@ -0,0 +1,22 @@ +/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */ +export function parseRedeemTokenFromScan(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + if (/^[a-f0-9]{32}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + + try { + const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid'); + const fromQuery = url.searchParams.get('token'); + if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) { + return fromQuery.toLowerCase(); + } + } catch { + /* not a URL */ + } + + const hexMatch = trimmed.match(/[a-f0-9]{32}/i); + return hexMatch ? hexMatch[0].toLowerCase() : null; +} diff --git a/apps/h5-shop/src/lib/wechat-auth.ts b/apps/h5-shop/src/lib/wechat-auth.ts new file mode 100644 index 0000000..395e3b2 --- /dev/null +++ b/apps/h5-shop/src/lib/wechat-auth.ts @@ -0,0 +1,57 @@ +import type { WechatLoginResult } from '@dukang/shared-types'; +import { isWechatEnv, weixinSdk } from './weixin'; +import { request, saveAuth, type ShopSessionPayload } from './api'; + +export type ShopAccountProfile = { + id: string; + storeId: string; + name: string; + phone: string; + wxOpenId?: string | null; + store?: { name: string }; +}; + +export async function fetchShopAccount(): Promise { + return request('SHOP_H5', '/shop/auth/me'); +} + +export function needsWechatAuth(profile: ShopAccountProfile | null): boolean { + return isWechatEnv() && !!profile && !profile.wxOpenId; +} + +export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null { + if (!result.accessToken || !result.refreshToken) return null; + const store = result.store; + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + store: store + ? { + id: String(store.id ?? ''), + storeId: String(store.storeId ?? ''), + name: String(store.name ?? ''), + phone: String(store.phone ?? ''), + storeName: String(store.storeName ?? store.name ?? ''), + } + : undefined, + }; +} + +export function saveShopWechatAuth(result: WechatLoginResult): boolean { + const session = sessionFromWechatLogin(result); + if (!session) return false; + saveAuth(session); + return true; +} + +export async function authorizeShopWechat(): Promise { + if (!isWechatEnv()) { + throw new Error('请在微信内打开以完成授权'); + } + return weixinSdk.login(); +} + +export async function handleShopWechatCallback(): Promise { + if (!isWechatEnv()) return null; + return weixinSdk.handleOAuthCallback(); +} diff --git a/apps/h5-shop/src/lib/weixin.ts b/apps/h5-shop/src/lib/weixin.ts index 48349a4..861394f 100644 --- a/apps/h5-shop/src/lib/weixin.ts +++ b/apps/h5-shop/src/lib/weixin.ts @@ -4,6 +4,7 @@ export const weixinSdk = createWeixinSdk({ apiBase: '/api/v1', clientApp: 'SHOP_H5', getAccessToken: () => localStorage.getItem('accessToken'), + wechatLoginPath: '/shop/auth/login/wechat', }); export { isWechatEnv }; 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..cc17170 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -1,7 +1,20 @@ import { useEffect, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { isLoggedIn, request } from '../lib/api'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useStoreSession } from '../contexts/StoreSessionContext'; +import { request } from '../lib/api'; +import { parseRedeemTokenFromScan } from '../lib/redeem-scan'; +import { + authorizeShopWechat, + fetchShopAccount, + handleShopWechatCallback, + needsWechatAuth, + saveShopWechatAuth, + sessionFromWechatLogin, +} from '../lib/wechat-auth'; import { isWechatEnv, weixinSdk } from '../lib/weixin'; +import WechatScanAuthModal from '../components/WechatScanAuthModal'; + +const PENDING_SCAN_KEY = 'shop_pending_scan'; function formatMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); @@ -9,41 +22,107 @@ function formatMoney(n: number) { export default function HomePage() { const navigate = useNavigate(); + const { applySession } = useStoreSession(); + const [searchParams, setSearchParams] = useSearchParams(); const [dash, setDash] = useState | null>(null); const [open, setOpen] = useState(true); + const [scanMsg, setScanMsg] = useState(''); + const [scanning, setScanning] = useState(false); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [authLoading, setAuthLoading] = useState(false); + const [authError, setAuthError] = useState(''); useEffect(() => { - if (!isLoggedIn()) { - navigate('/login'); - return; - } - request('SHOP_H5', '/shop/dashboard').then((d) => { + request>('SHOP_H5', '/shop/dashboard').then((d) => { setDash(d); setOpen(String((d.store as Record)?.status) === 'OPEN'); }); - }, [navigate]); + }, []); + + useEffect(() => { + if (!isWechatEnv() || !searchParams.get('code')) return; + void handleShopWechatCallback() + .then((result) => { + if (!result) return; + const session = sessionFromWechatLogin(result); + if (session) { + saveShopWechatAuth(result); + applySession(session); + } + setAuthModalOpen(false); + setAuthError(''); + setSearchParams({}, { replace: true }); + const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1'; + sessionStorage.removeItem(PENDING_SCAN_KEY); + if (shouldScan) { + void runScan(); + } + }) + .catch((e) => { + setAuthError(e instanceof Error ? e.message : '微信授权失败'); + }); + }, [searchParams, applySession, setSearchParams]); + + async function runScan() { + if (!isWechatEnv()) { + setScanMsg('请在微信内打开门店端进行扫码核销'); + return; + } + setScanning(true); + setScanMsg(''); + try { + await weixinSdk.init(); + const raw = await weixinSdk.scanQrCode(); + if (!raw) return; + const token = parseRedeemTokenFromScan(raw); + if (!token) { + setScanMsg('无法识别核销码,请扫描用户出示的核销二维码'); + return; + } + navigate(`/redeem?token=${encodeURIComponent(token)}`); + } catch (e) { + setScanMsg(e instanceof Error ? e.message : '扫码失败,请重试'); + } finally { + setScanning(false); + } + } + + async function handleScan() { + setScanMsg(''); + if (!isWechatEnv()) { + setScanMsg('请在微信内打开门店端进行扫码核销'); + return; + } + try { + const profile = await fetchShopAccount(); + if (needsWechatAuth(profile)) { + setAuthModalOpen(true); + return; + } + await runScan(); + } catch (e) { + setScanMsg(e instanceof Error ? e.message : '无法发起扫码'); + } + } + + async function startWechatAuth() { + setAuthLoading(true); + setAuthError(''); + try { + sessionStorage.setItem(PENDING_SCAN_KEY, '1'); + await authorizeShopWechat(); + } catch (e) { + sessionStorage.removeItem(PENDING_SCAN_KEY); + setAuthError(e instanceof Error ? e.message : '微信授权失败'); + setAuthLoading(false); + } + } const store = dash?.store as Record | undefined; const recent = (dash?.recentRecords as Array>) || []; const openTime = String(store?.openTime || '10:00'); const closeTime = String(store?.closeTime || '22:00'); - async function handleScan() { - if (isWechatEnv()) { - try { - await weixinSdk.init(); - const token = await weixinSdk.scanQrCode(); - if (token) { - navigate(`/redeem?token=${encodeURIComponent(token)}`); - return; - } - } catch { - /* fall through to manual redeem page */ - } - } - navigate('/redeem'); - } - return (
@@ -72,10 +151,16 @@ export default function HomePage() {
- -

扫码核销

+

{scanning ? '正在打开相机…' : '扫码核销'}

+ {scanMsg &&

{scanMsg}

}
@@ -121,6 +206,18 @@ export default function HomePage() {
+ + void startWechatAuth()} + onCancel={() => { + setAuthModalOpen(false); + setAuthError(''); + sessionStorage.removeItem(PENDING_SCAN_KEY); + }} + />
); } diff --git a/apps/h5-shop/src/pages/LoginPage.tsx b/apps/h5-shop/src/pages/LoginPage.tsx index c131711..afc6341 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -1,7 +1,8 @@ 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; @@ -10,15 +11,20 @@ function maskPhone(phone: string) { 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 [agreed, setAgreed] = useState(false); + const savedProfile = getStoreProfile(); + const [phone, setPhone] = useState(getLastPhone()); + const [code, setCode] = useState(''); + const [agreed, setAgreed] = useState(true); 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 +41,7 @@ export default function LoginPage() { method: 'POST', body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }), }); - setMsg('验证码已发送(Mock: 123456)'); + setMsg('验证码已发送'); setCodeCooldown(60); const timer = setInterval(() => { setCodeCooldown((c) => { @@ -51,20 +57,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 +101,8 @@ export default function LoginPage() { storefront
-

门店管理中心

-

{maskPhone(phone)}

+

{quickStoreName}

+

{maskPhone(quickPhone)}

verified_user @@ -115,7 +123,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} @@ -192,7 +200,7 @@ export default function LoginPage() { type="button" className="shop-login-submit" disabled={loading} - onClick={login} + onClick={() => void login()} > {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() {

请核对金额后点击确认

- -
- 开发者选项 · 手动输入核销码 -
- setToken(e.target.value)} - placeholder="粘贴用户核销码" - /> -
-
diff --git a/apps/h5-shop/src/pages/RedeemSuccessPage.tsx b/apps/h5-shop/src/pages/RedeemSuccessPage.tsx index 500053a..a05c11c 100644 --- a/apps/h5-shop/src/pages/RedeemSuccessPage.tsx +++ b/apps/h5-shop/src/pages/RedeemSuccessPage.tsx @@ -21,7 +21,9 @@ export default function RedeemSuccessPage() { }, [location.state]); const storeName = (location.state as { storeName?: string })?.storeName || '当前门店'; - const amount = Number(result?.amount || 100); + const user = (location.state as { user?: { nickname?: string; phone?: string; userNo?: string } })?.user; + const userLabel = user?.nickname || user?.phone || user?.userNo || '—'; + const amount = Number(result?.amount ?? 0); const redeemNo = String(result?.redeemNo || '—'); const createdAt = result?.createdAt ? new Date(String(result.createdAt)).toLocaleString('zh-CN') @@ -58,8 +60,7 @@ export default function RedeemSuccessPage() { person
-
杜康用户
-
待完善
+
{userLabel}
@@ -78,7 +79,7 @@ export default function RedeemSuccessPage() {
- diff --git a/apps/h5-shop/src/pages/StatusPage.tsx b/apps/h5-shop/src/pages/StatusPage.tsx index 6bc3a01..0f5bba9 100644 --- a/apps/h5-shop/src/pages/StatusPage.tsx +++ b/apps/h5-shop/src/pages/StatusPage.tsx @@ -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 | null>(null); const [lastUpdate, setLastUpdate] = useState(''); @@ -58,7 +60,7 @@ export default function StatusPage() {