From e85c4b9bcb37ccd34795a85c5d935c60cec0707b Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Mon, 6 Jul 2026 23:40:12 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E9=97=A8=E5=BA=97=E6=A0=B8=E9=94=80?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/auto-release.env.example | 3 +++ deploy/auto-release.sh | 6 +++++- .../src/modules/ops/admin-redeem-debug.controller.ts | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/deploy/auto-release.env.example b/deploy/auto-release.env.example index 2286e63..a3593b9 100644 --- a/deploy/auto-release.env.example +++ b/deploy/auto-release.env.example @@ -13,3 +13,6 @@ DEPLOY_WEBHOOK_HOST=127.0.0.1 DEPLOY_WEBHOOK_PORT=8095 DEPLOY_LOG_FILE=/var/log/dukang/deploy.log DEPLOY_LOCK_FILE=/var/run/dukang-deploy.lock + +# Prisma db push 遇到 schema 变更警告时自动继续(开发/测试环境可开) +PRISMA_ACCEPT_DATA_LOSS=true diff --git a/deploy/auto-release.sh b/deploy/auto-release.sh index f9c5d9b..2c3dede 100644 --- a/deploy/auto-release.sh +++ b/deploy/auto-release.sh @@ -74,6 +74,10 @@ sed -i 's/\r$//' "$ENV_FILE" 2>/dev/null || true find "$APP_ROOT/deploy" -maxdepth 1 -name '*.sh' -exec sed -i 's/\r$//' {} + 2>/dev/null || true log "RUN remote-release.sh" -bash "$APP_ROOT/deploy/remote-release.sh" +RELEASE_ARGS=() +if [[ "${PRISMA_ACCEPT_DATA_LOSS:-false}" == "true" ]]; then + RELEASE_ARGS+=(--accept-data-loss) +fi +bash "$APP_ROOT/deploy/remote-release.sh" "${RELEASE_ARGS[@]}" log "DONE auto-release ($REMOTE_SHA)" diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts index 9c40450..abdf2ea 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts @@ -3,7 +3,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { AdminRedeemDebugService } from './admin-redeem-debug.service'; -import type { +import { AdminRedeemDebugCreateTokenDto, AdminRedeemDebugStoreTokenDto, } from './dto/admin-mutate.dto'; From 7ca9fc8a358220968a125ff2b0c8b0326c5dee5a Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Tue, 7 Jul 2026 00:01:16 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E9=97=A8=E5=BA=97=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/lib/storeCreate.ts | 10 +- .../admin-web/src/pages/StoreAccountsPage.tsx | 5 +- apps/admin-web/src/pages/StoresPage.tsx | 55 ++++-- apps/h5-partner/src/lib/storeDraft.ts | 6 - apps/h5-partner/src/pages/StoreCreatePage.tsx | 20 +- apps/h5-shop/src/App.tsx | 27 +-- apps/h5-shop/src/components/AuthGate.tsx | 27 +++ .../src/contexts/StoreSessionContext.tsx | 76 ++++++++ apps/h5-shop/src/layouts/TabLayout.tsx | 8 +- apps/h5-shop/src/lib/api.ts | 178 ++++++++++++++++-- apps/h5-shop/src/main.tsx | 9 +- apps/h5-shop/src/pages/HomePage.tsx | 8 +- apps/h5-shop/src/pages/LoginPage.tsx | 43 +++-- apps/h5-shop/src/pages/MinePage.tsx | 12 +- apps/h5-shop/src/pages/StatusPage.tsx | 6 +- apps/h5-shop/src/styles.css | 13 ++ packages/shared-types/src/enums.ts | 1 + scripts/smoke-v3.mjs | 16 ++ scripts/test-shop-auth.mjs | 82 ++++++++ .../src/modules/iam/auth.controller.ts | 11 ++ .../src/modules/iam/auth.service.ts | 53 +++++- .../modules/ops/admin-stores.controller.ts | 5 + .../src/modules/ops/admin-stores.service.ts | 27 ++- .../src/modules/ops/dto/admin-mutate.dto.ts | 8 +- .../src/modules/store/store.service.ts | 27 ++- 25 files changed, 599 insertions(+), 134 deletions(-) create mode 100644 apps/h5-shop/src/components/AuthGate.tsx create mode 100644 apps/h5-shop/src/contexts/StoreSessionContext.tsx create mode 100644 scripts/test-shop-auth.mjs 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() { + {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/h5-shop/src/pages/LoginPage.tsx b/apps/h5-shop/src/pages/LoginPage.tsx index 8215468..ca21971 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -9,7 +9,6 @@ function maskPhone(phone: string) { 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() { @@ -18,7 +17,7 @@ export default function LoginPage() { const [params] = useSearchParams(); const quick = params.get('quick') === '1'; const savedProfile = getStoreProfile(); - const [phone, setPhone] = useState(getLastPhone() || DEV_DEFAULT_PHONE); + const [phone, setPhone] = useState(getLastPhone()); const [code, setCode] = useState(DEV_DEFAULT_CODE); const [agreed, setAgreed] = useState(false); const [loading, setLoading] = useState(false); diff --git a/scripts/smoke-prev1.mjs b/scripts/smoke-prev1.mjs index e90e02c..8e6157e 100644 --- a/scripts/smoke-prev1.mjs +++ b/scripts/smoke-prev1.mjs @@ -64,7 +64,7 @@ async function main() { console.log('5. Shop login + redeem preview'); const shopLogin = await loginSms( 'SHOP_H5', - '13900000001', + '13910000001', 'STORE_LOGIN', '/shop/auth/login/sms', '/shop/auth/sms/send', diff --git a/scripts/smoke-v3.mjs b/scripts/smoke-v3.mjs index 28999c2..005cba8 100644 --- a/scripts/smoke-v3.mjs +++ b/scripts/smoke-v3.mjs @@ -133,7 +133,7 @@ async function main() { console.log('8. Shop confirm redeem'); const shopLogin = await loginSms( 'SHOP_H5', - '13900000001', + '13910000001', 'STORE_LOGIN', '/shop/auth/login/sms', '/shop/auth/sms/send', diff --git a/scripts/test-shop-auth.mjs b/scripts/test-shop-auth.mjs index 35e0d6c..ce0c69a 100644 --- a/scripts/test-shop-auth.mjs +++ b/scripts/test-shop-auth.mjs @@ -40,17 +40,17 @@ async function main() { console.log('2. STORE_LOGIN bound phone'); await req('SHOP_H5', '/shop/auth/sms/send', { method: 'POST', - body: JSON.stringify({ phone: '13900000001', scene: 'STORE_LOGIN' }), + body: JSON.stringify({ phone: '13910000001', scene: 'STORE_LOGIN' }), }); const login = await req('SHOP_H5', '/shop/auth/login/sms', { method: 'POST', - body: JSON.stringify({ phone: '13900000001', code: MOCK_SMS_CODE }), + body: JSON.stringify({ phone: '13910000001', code: MOCK_SMS_CODE }), }); if (!login.accessToken || !login.refreshToken) throw new Error('login missing tokens'); console.log('3. Shop session me + refresh'); const me = await req('SHOP_H5', '/shop/auth/me', { token: login.accessToken }); - if (me.phone !== '13900000001') throw new Error('me phone mismatch'); + if (me.phone !== '13910000001') throw new Error('me phone mismatch'); const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', { method: 'POST', body: JSON.stringify({ refreshToken: login.refreshToken }), @@ -69,7 +69,7 @@ async function main() { const occupied = await expectFail('HQ_WEB', '/admin/stores/phone/sms/send', { method: 'POST', token: admin.accessToken, - body: JSON.stringify({ phone: '13900000001' }), + body: JSON.stringify({ phone: '13910000001' }), }); if (!occupied.includes('已绑定')) throw new Error(`unexpected: ${occupied}`); diff --git a/server/dukang-api/prisma/seed-prev1.ts b/server/dukang-api/prisma/seed-prev1.ts index 5769e3e..b6742a2 100644 --- a/server/dukang-api/prisma/seed-prev1.ts +++ b/server/dukang-api/prisma/seed-prev1.ts @@ -143,7 +143,7 @@ async function main() { partnerId: partner.id, categoryId: categories[0].id, name: '郑州老城店', - phone: '0371-88880001', + phone: '13910000001', province: '河南省', cityName: '郑州市', district: '金水区', @@ -165,7 +165,7 @@ async function main() { partnerId: partner.id, categoryId: categories[1].id, name: '郑州美食城店', - phone: '0371-88880002', + phone: '13910000002', province: '河南省', cityName: '郑州市', district: '二七区', @@ -179,11 +179,7 @@ async function main() { }); await prisma.storeAccount.create({ - data: { storeId: store1.id, phone: '13900000001', name: '老城店店长' }, - }); - - await prisma.storeAccount.create({ - data: { storeId: store2.id, phone: '13900000002', name: '美食城店长' }, + data: { storeId: store1.id, phone: '13910000001', name: '郑州老城店' }, }); await prisma.user.create({ @@ -233,7 +229,7 @@ async function main() { stores: 2, testPhones: { user: '13800000001', - store: '13900000001', + store: '13910000001', partner: '13700000001', }, }); diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index 792d36f..8bafbca 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -153,25 +153,23 @@ async function main() { const storeDefs = [ { name: '郑州老城店', - phone: '0371-88880001', + phone: '13910000001', district: '金水区', address: '花园路100号', intro: '正宗河南菜,欢迎核销好客权益', img: 'https://picsum.photos/seed/store1/400/300', categoryId: categories[0].id, - accountPhone: '13900000001', - accountName: '老城店店长', + withAccount: true, }, { name: '郑州美食城店', - phone: '0371-88880002', + phone: '13910000002', district: '二七区', address: '大学路200号', intro: '地方特色餐饮', img: 'https://picsum.photos/seed/store2/400/300', categoryId: categories[1].id, - accountPhone: '13900000002', - accountName: '美食城店长', + withAccount: false, }, ]; @@ -198,9 +196,11 @@ async function main() { }); const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img); await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } }); - await prisma.storeAccount.create({ - data: { storeId: store.id, phone: def.accountPhone, name: def.accountName }, - }); + if (def.withAccount) { + await prisma.storeAccount.create({ + data: { storeId: store.id, phone: def.phone, name: def.name }, + }); + } } await prisma.user.create({ @@ -252,7 +252,6 @@ async function main() { stores: storeDefs.length, testPhones: { user: '13800000001', - store: '13900000001', partner: '13700000001', hq: '13600000001', }, diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index e76a949..80fe196 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -73,7 +73,7 @@ export class SettlementService { skip: (page - 1) * pageSize, take: pageSize, include: { - store: { select: { id: true, name: true, cityName: true } }, + store: { select: { id: true, name: true, cityName: true, phone: true } }, redeemRecord: { select: { redeemNo: true, amount: true, userId: true } }, }, }), From 5d19089f912a2bb5d9d110cd55d319955e601226 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Tue, 7 Jul 2026 00:18:29 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E9=97=A8=E5=BA=97=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/WechatScanAuthModal.tsx | 40 +++++ apps/h5-shop/src/lib/redeem-scan.ts | 22 +++ apps/h5-shop/src/lib/wechat-auth.ts | 57 +++++++ apps/h5-shop/src/lib/weixin.ts | 1 + apps/h5-shop/src/pages/HomePage.tsx | 141 +++++++++++++++--- apps/h5-shop/src/pages/LoginPage.tsx | 10 +- apps/h5-shop/src/pages/RedeemConfirmPage.tsx | 2 +- apps/h5-shop/src/styles.css | 112 +++++++++++++- packages/weixin-sdk/src/scan.ts | 30 ++-- .../src/modules/iam/auth.controller.ts | 12 +- .../src/modules/iam/auth.service.ts | 44 ++++++ 11 files changed, 432 insertions(+), 39 deletions(-) create mode 100644 apps/h5-shop/src/components/WechatScanAuthModal.tsx create mode 100644 apps/h5-shop/src/lib/redeem-scan.ts create mode 100644 apps/h5-shop/src/lib/wechat-auth.ts 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/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/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 7488168..517abd7 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 { 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,37 +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(() => { - request('SHOP_H5', '/shop/dashboard').then((d) => { + request>('SHOP_H5', '/shop/dashboard').then((d) => { setDash(d); setOpen(String((d.store as Record)?.status) === 'OPEN'); }); }, []); + 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()) { + navigate('/redeem'); + 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()) { + navigate('/redeem'); + 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 (
@@ -68,10 +151,16 @@ export default function HomePage() {
- -

扫码核销

+

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

+ {scanMsg &&

{scanMsg}

}
@@ -117,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 ca21971..afc6341 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -9,8 +9,6 @@ function maskPhone(phone: string) { return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`; } -const DEV_DEFAULT_CODE = import.meta.env.DEV ? '123456' : ''; - export default function LoginPage() { const navigate = useNavigate(); const { applySession } = useStoreSession(); @@ -18,8 +16,8 @@ export default function LoginPage() { const quick = params.get('quick') === '1'; const savedProfile = getStoreProfile(); const [phone, setPhone] = useState(getLastPhone()); - const [code, setCode] = useState(DEV_DEFAULT_CODE); - const [agreed, setAgreed] = useState(false); + const [code, setCode] = useState(''); + const [agreed, setAgreed] = useState(true); const [loading, setLoading] = useState(false); const [msg, setMsg] = useState(''); const [codeCooldown, setCodeCooldown] = useState(0); @@ -43,7 +41,7 @@ export default function LoginPage() { method: 'POST', body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }), }); - setMsg(import.meta.env.DEV ? '验证码已发送(Mock: 123456)' : '验证码已发送'); + setMsg('验证码已发送'); setCodeCooldown(60); const timer = setInterval(() => { setCodeCooldown((c) => { @@ -202,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/RedeemConfirmPage.tsx b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx index 3f77ebf..910dfd3 100644 --- a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx +++ b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx @@ -60,7 +60,7 @@ export default function RedeemConfirmPage() { return; } if (!token.trim()) { - setMsg('请在开发者选项中输入核销码'); + setMsg('请先扫码获取核销码'); return; } setLoading(true); diff --git a/apps/h5-shop/src/styles.css b/apps/h5-shop/src/styles.css index 9ba2fd5..5e4fda1 100644 --- a/apps/h5-shop/src/styles.css +++ b/apps/h5-shop/src/styles.css @@ -152,23 +152,31 @@ .shop-login-code-row { display: flex; gap: 8px; + align-items: stretch; } .shop-login-code-row .shop-login-input-wrap { flex: 1; + min-width: 0; } .shop-login-code-btn { flex-shrink: 0; + align-self: stretch; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 48px; border: none; border-radius: var(--radius-sm); - padding: 0 16px; + padding: 0 14px; background: #ffdad7; color: var(--color-heritage-red); font-family: var(--font-label); font-size: 12px; font-weight: 600; - letter-spacing: 0.05em; + letter-spacing: 0.02em; + line-height: 1.2; cursor: pointer; white-space: nowrap; } @@ -618,6 +626,106 @@ color: var(--color-heritage-red); } +.shop-home-scan-msg { + margin-top: 8px; + font-size: 13px; + color: var(--color-heritage-red); + text-align: center; + max-width: 280px; +} + +.shop-scan-auth-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, 0.45); +} + +.shop-scan-auth-card { + width: 100%; + max-width: 320px; + padding: 28px 24px 24px; + border-radius: var(--radius-md); + background: var(--color-card); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + text-align: center; +} + +.shop-scan-auth-icon { + width: 56px; + height: 56px; + margin: 0 auto 16px; + border-radius: 50%; + background: rgba(166, 29, 36, 0.08); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-heritage-red); +} + +.shop-scan-auth-icon .material-symbols-outlined { + font-size: 28px; +} + +.shop-scan-auth-title { + margin: 0 0 8px; + font-family: var(--font-headline); + font-size: 18px; + font-weight: 600; + color: var(--color-on-surface); +} + +.shop-scan-auth-desc { + margin: 0 0 16px; + font-size: 14px; + line-height: 1.5; + color: var(--color-on-surface-variant); +} + +.shop-scan-auth-error { + margin: 0 0 12px; + font-size: 13px; + color: var(--color-heritage-red); +} + +.shop-scan-auth-actions { + display: flex; + gap: 12px; +} + +.shop-scan-auth-cancel, +.shop-scan-auth-confirm { + flex: 1; + min-height: 44px; + border-radius: var(--radius-sm); + font-family: var(--font-label); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.shop-scan-auth-cancel { + border: 1px solid var(--color-outline-variant); + background: var(--color-surface); + color: var(--color-on-surface); +} + +.shop-scan-auth-confirm { + border: none; + background: var(--color-heritage-red); + color: #fff; +} + +.shop-scan-auth-cancel:disabled, +.shop-scan-auth-confirm:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .shop-home-status { background: var(--color-card); border-radius: var(--radius-md); diff --git a/packages/weixin-sdk/src/scan.ts b/packages/weixin-sdk/src/scan.ts index 9844df2..5c9966a 100644 --- a/packages/weixin-sdk/src/scan.ts +++ b/packages/weixin-sdk/src/scan.ts @@ -23,16 +23,28 @@ export async function scanQrCode(config: WeixinSdkConfig): Promise { - window.wx!.scanQRCode!({ - needResult: 1, - scanType: ['qrCode', 'barCode'], - success: (res) => resolve(res.resultStr || null), - fail: () => resolve(null), - }); - }); + if (!window.wx?.scanQRCode) { + throw new Error('当前微信版本不支持扫码,请升级微信后重试'); } + return new Promise((resolve, reject) => { + window.wx!.scanQRCode!({ + needResult: 1, + scanType: ['qrCode', 'barCode'], + success: (res) => resolve(res.resultStr || null), + fail: (res) => { + const msg = res.errMsg || '扫码失败'; + if (/cancel/i.test(msg)) { + resolve(null); + return; + } + if (/permission|auth|denied|授权|拒绝|camera/i.test(msg)) { + reject(new Error('相机权限未开启,请在微信设置中允许使用摄像头')); + return; + } + reject(new Error(msg)); + }, + }); + }); } const manual = typeof window !== 'undefined' ? window.prompt('当前环境无法调起微信扫码,请手动输入核销码') : null; diff --git a/server/dukang-api/src/modules/iam/auth.controller.ts b/server/dukang-api/src/modules/iam/auth.controller.ts index ef2ca54..f6da4b6 100644 --- a/server/dukang-api/src/modules/iam/auth.controller.ts +++ b/server/dukang-api/src/modules/iam/auth.controller.ts @@ -112,7 +112,17 @@ export class ShopAuthController { } @Post('login/wechat') - wechatLogin(@Body() dto: LoginWechatDto) { + @UseGuards(OptionalJwtAuthGuard) + wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) { + const user = (req as Request & { user?: AuthUser }).user; + if (user?.actorType === 'STORE') { + return this.authService.bindStoreWechat( + user.actorId, + dto.code, + ClientApp.SHOP_H5, + dto.platform ?? 'h5', + ); + } return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5'); } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 4e8bcf7..361a4b3 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -837,6 +837,50 @@ export class AuthService { }); } + async bindStoreWechat( + storeAccountId: bigint, + code: string, + clientApp: ClientApp, + platform: 'h5' | 'mini' = 'h5', + ) { + this.assertWechatEnabled(); + const session = + platform === 'mini' + ? await this.wechatProvider.code2Session(code) + : await this.wechatProvider.oauth2AccessToken(code); + + const account = await this.prisma.storeAccount.findUnique({ + where: { id: storeAccountId }, + include: { store: true }, + }); + if (!account) throw new BadRequestException('门店账号不存在'); + + const conflict = await this.prisma.storeAccount.findFirst({ + where: { wxOpenId: session.openId, id: { not: storeAccountId } }, + }); + if (conflict) { + throw new BadRequestException('该微信已绑定其他门店账号'); + } + + const updated = await this.prisma.storeAccount.update({ + where: { id: storeAccountId }, + data: { + wxOpenId: session.openId, + wxUnionId: session.unionId ?? account.wxUnionId, + lastLoginAt: new Date(), + }, + include: { store: true }, + }); + + return this.issueToken('STORE', updated.id, clientApp, false, undefined, { + id: updated.id.toString(), + storeId: updated.storeId.toString(), + name: updated.name, + phone: updated.phone, + storeName: updated.store.name, + }); + } + async bindPartnerWechat( partnerAccountId: bigint, code: string, From 98022b936fd31980ac8e23269b4e223e05abeec3 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Tue, 7 Jul 2026 00:19:11 +0800 Subject: [PATCH 5/5] fix(h5-shop): remove redeem debug UI and enforce WeChat scan flow Co-authored-by: Cursor --- apps/h5-shop/src/pages/HomePage.tsx | 4 +-- apps/h5-shop/src/pages/RedeemConfirmPage.tsx | 27 ++++++----------- apps/h5-shop/src/pages/RedeemSuccessPage.tsx | 9 +++--- apps/h5-shop/src/styles.css | 32 -------------------- packages/weixin-sdk/src/scan.ts | 3 +- 5 files changed, 18 insertions(+), 57 deletions(-) diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 517abd7..cc17170 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -65,7 +65,7 @@ export default function HomePage() { async function runScan() { if (!isWechatEnv()) { - navigate('/redeem'); + setScanMsg('请在微信内打开门店端进行扫码核销'); return; } setScanning(true); @@ -90,7 +90,7 @@ export default function HomePage() { async function handleScan() { setScanMsg(''); if (!isWechatEnv()) { - navigate('/redeem'); + setScanMsg('请在微信内打开门店端进行扫码核销'); return; } try { diff --git a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx index 910dfd3..ae69ed8 100644 --- a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx +++ b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx @@ -34,9 +34,13 @@ export default function RedeemConfirmPage() { }, []); useEffect(() => { - const scanned = searchParams.get('token'); - if (scanned) setToken(scanned); - }, [searchParams]); + const scanned = searchParams.get('token')?.trim(); + if (!scanned) { + navigate('/', { replace: true }); + return; + } + setToken(scanned); + }, [searchParams, navigate]); useEffect(() => { if (!token.trim()) { @@ -71,7 +75,7 @@ export default function RedeemConfirmPage() { body: JSON.stringify({ token }), }); sessionStorage.setItem('lastRedeemResult', JSON.stringify(result)); - navigate('/redeem/success', { state: { result, storeName } }); + navigate('/redeem/success', { state: { result, storeName, user: preview?.user } }); } catch (e) { setMsg(e instanceof Error ? e.message : '核销失败'); } finally { @@ -80,7 +84,7 @@ export default function RedeemConfirmPage() { } const previewAmount = preview?.amount ?? 0; - const userLabel = preview?.user?.nickname || preview?.user?.phone || preview?.user?.userNo || '待扫码确认'; + const userLabel = preview?.user?.nickname || preview?.user?.phone || preview?.user?.userNo || '—'; return (
@@ -161,21 +165,10 @@ export default function RedeemConfirmPage() { {loading ? 'sync' : 'check_circle'} - {loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '等待扫码'} + {loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}

请核对金额后点击确认

- -
- 开发者选项 · 手动输入核销码 -
- 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/styles.css b/apps/h5-shop/src/styles.css index 5e4fda1..bc096de 100644 --- a/apps/h5-shop/src/styles.css +++ b/apps/h5-shop/src/styles.css @@ -1116,38 +1116,6 @@ color: var(--color-subtle-gray); } -.shop-redeem-dev { - margin-top: 8px; - border: 1px dashed var(--color-outline-variant); - border-radius: var(--radius-sm); - overflow: hidden; -} - -.shop-redeem-dev summary { - padding: 12px 16px; - font-family: var(--font-label); - font-size: 12px; - color: var(--color-subtle-gray); - cursor: pointer; - list-style: none; -} - -.shop-redeem-dev summary::-webkit-details-marker { - display: none; -} - -.shop-redeem-dev-body { - padding: 0 16px 16px; -} - -.shop-redeem-dev-body input { - width: 100%; - padding: 12px; - border: 1px solid var(--color-outline-variant); - border-radius: var(--radius-sm); - font-size: 14px; -} - .shop-redeem-error { color: var(--color-error); font-size: 13px; diff --git a/packages/weixin-sdk/src/scan.ts b/packages/weixin-sdk/src/scan.ts index 5c9966a..f1e2954 100644 --- a/packages/weixin-sdk/src/scan.ts +++ b/packages/weixin-sdk/src/scan.ts @@ -47,6 +47,5 @@ export async function scanQrCode(config: WeixinSdkConfig): Promise