From e22cfe4646af6896fc570782c30bd42a6607812d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Fri, 13 Feb 2026 21:30:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=B7=BB=E5=8A=A0=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=AE=A4=E8=AF=81=E5=B7=A5=E5=85=B7=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=99=BB=E5=BD=95=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 auth 工具模块,包含 isLoggedIn、goToRegister、ensureLoggedIn 方法 - 将硬编码的服务器URL更新为 glt-server 域名 - 重构多个页面的登录检查逻辑,使用统一的认证工具 - 在用户注册/登录流程中集成邀请关系处理 - 更新注册页面配置和实现,支持跳转参数传递 - 优化分销商二维码页面的加载状态和错误处理 - 在水票使用页面添加无票时的购买引导 - 统一文件上传和API请求的服务器地址 - 添加加密库类型定义文件 --- docs/IMPLICIT_ANY_TYPE_FIX.md | 2 +- src/admin/components/UserCard.tsx | 2 +- src/api/system/file/index.ts | 6 +- src/app.config.ts | 1 + src/components/AddCartBar.tsx | 8 +- src/dealer/apply/add.tsx | 6 +- src/dealer/qrcode/index.tsx | 45 +++- src/pages/cart/cart.tsx | 4 + src/pages/index/index.tsx | 21 +- src/pages/user/components/UserCard.tsx | 9 +- src/passport/agreement.tsx | 43 +++- src/passport/register.config.ts | 5 + src/passport/register.tsx | 295 +++++++++++++++++++++++++ src/passport/sms-login.tsx | 60 ++++- src/shop/goodsDetail/index.tsx | 17 +- src/shop/orderConfirm/index.tsx | 16 ++ src/shop/orderConfirmCart/index.tsx | 48 ++-- src/store/index.tsx | 7 +- src/user/profile/profile.tsx | 2 +- src/user/ticket/use.tsx | 205 ++++++++++++----- src/utils/auth.ts | 30 +++ src/utils/common.ts | 12 +- types/crypto-js.d.ts | 22 ++ 23 files changed, 712 insertions(+), 154 deletions(-) create mode 100644 src/passport/register.config.ts create mode 100644 src/passport/register.tsx create mode 100644 src/utils/auth.ts create mode 100644 types/crypto-js.d.ts diff --git a/docs/IMPLICIT_ANY_TYPE_FIX.md b/docs/IMPLICIT_ANY_TYPE_FIX.md index c8720f6..f63abda 100644 --- a/docs/IMPLICIT_ANY_TYPE_FIX.md +++ b/docs/IMPLICIT_ANY_TYPE_FIX.md @@ -90,7 +90,7 @@ const handleGetPhoneNumber = ({detail}: {detail: {code?: string, encryptedData?: success: function () { if (code) { Taro.request({ - url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone', + url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone', method: 'POST', data: { code, diff --git a/src/admin/components/UserCard.tsx b/src/admin/components/UserCard.tsx index fb3a433..97d808e 100644 --- a/src/admin/components/UserCard.tsx +++ b/src/admin/components/UserCard.tsx @@ -144,7 +144,7 @@ function UserCard() { success: function () { if (code) { Taro.request({ - url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone', + url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone', method: 'POST', data: { code, diff --git a/src/api/system/file/index.ts b/src/api/system/file/index.ts index 18a06dd..a1b1b19 100644 --- a/src/api/system/file/index.ts +++ b/src/api/system/file/index.ts @@ -21,7 +21,7 @@ export async function uploadOssByPath(filePath: string) { let stsExpired = Taro.getStorageSync('stsExpiredAt'); if (!sts || (stsExpired && dayjs().isBefore(dayjs(stsExpired)))) { // @ts-ignore - const {data: {data: {credentials}}} = await request.get(`https://server.websoft.top/api/oss/getSTSToken`) + const {data: {data: {credentials}}} = await request.get(`https://gle-server.websoft.top/api/oss/getSTSToken`) Taro.setStorageSync('sts', credentials) Taro.setStorageSync('stsExpiredAt', credentials.expiration) sts = credentials @@ -49,7 +49,7 @@ export async function uploadOssByPath(filePath: string) { }) } -const computeSignature = (accessKeySecret, canonicalString) => { +const computeSignature = (accessKeySecret: string, canonicalString: string): string => { return crypto.enc.Base64.stringify(crypto.HmacSHA1(canonicalString, accessKeySecret)); } @@ -66,7 +66,7 @@ export async function uploadFile() { const tempFilePath = res.tempFilePaths[0]; // 上传图片到OSS Taro.uploadFile({ - url: 'https://server.websoft.top/api/oss/upload', + url: 'https://glt-server.websoft.top/api/oss/upload', filePath: tempFilePath, name: 'file', header: { diff --git a/src/app.config.ts b/src/app.config.ts index b077560..f0e520f 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -11,6 +11,7 @@ export default { "root": "passport", "pages": [ "login", + "register", "forget", "setting", "agreement", diff --git a/src/components/AddCartBar.tsx b/src/components/AddCartBar.tsx index c095208..c2e807e 100644 --- a/src/components/AddCartBar.tsx +++ b/src/components/AddCartBar.tsx @@ -5,6 +5,7 @@ import {getUserInfo} from "@/api/layout"; import {useEffect, useState} from "react"; import {getCmsArticle} from "@/api/cms/cmsArticle"; import {CmsArticle} from "@/api/cms/cmsArticle/model"; +import { goToRegister } from '@/utils/auth' function AddCartBar() { const { router } = getCurrentInstance(); @@ -13,13 +14,8 @@ function AddCartBar() { const [IsLogin, setIsLogin] = useState(false) const onPay = () => { if (!IsLogin) { - Taro.showToast({title: `请先登录`, icon: 'error'}) setTimeout(() => { - Taro.switchTab( - { - url: '/pages/user/user', - }, - ) + goToRegister({ redirect: '/pages/user/user' }) }, 1000) return false; } diff --git a/src/dealer/apply/add.tsx b/src/dealer/apply/add.tsx index 112671f..a6aea7f 100644 --- a/src/dealer/apply/add.tsx +++ b/src/dealer/apply/add.tsx @@ -61,7 +61,7 @@ const AddUserAddress = () => { setFormData(tempFormData) Taro.uploadFile({ - url: 'https://server.websoft.top/api/oss/upload', + url: 'https://glt-server.websoft.top/api/oss/upload', filePath: detail.avatarUrl, name: 'file', header: { @@ -144,8 +144,8 @@ const AddUserAddress = () => { const nickname = values.realName || FormData?.nickname || ''; if (!nickname || nickname.trim() === '') { Taro.showToast({ - title: '请填写昵称', - icon: 'error' + title: '请上传头像和填写昵称', + icon: 'none' }); return; } diff --git a/src/dealer/qrcode/index.tsx b/src/dealer/qrcode/index.tsx index d32301f..3c4aaa0 100644 --- a/src/dealer/qrcode/index.tsx +++ b/src/dealer/qrcode/index.tsx @@ -10,11 +10,11 @@ import {businessGradients} from '@/styles/gradients' const DealerQrcode: React.FC = () => { const [miniProgramCodeUrl, setMiniProgramCodeUrl] = useState('') - const [loading, setLoading] = useState(false) + const [codeLoading, setCodeLoading] = useState(false) const [saving, setSaving] = useState(false) // const [inviteStats, setInviteStats] = useState(null) // const [statsLoading, setStatsLoading] = useState(false) - const {dealerUser} = useDealerUser() + const {dealerUser, loading: dealerLoading, error, refresh} = useDealerUser() // 生成小程序码 const generateMiniProgramCode = async () => { @@ -23,7 +23,7 @@ const DealerQrcode: React.FC = () => { } try { - setLoading(true) + setCodeLoading(true) // 生成邀请小程序码 const codeUrl = await generateInviteCode(dealerUser.userId) @@ -41,7 +41,7 @@ const DealerQrcode: React.FC = () => { // 清空之前的二维码 setMiniProgramCodeUrl('') } finally { - setLoading(false) + setCodeLoading(false) } } @@ -248,7 +248,7 @@ const DealerQrcode: React.FC = () => { // }) // } - if (!dealerUser) { + if (dealerLoading) { return ( @@ -257,6 +257,33 @@ const DealerQrcode: React.FC = () => { ) } + if (error) { + return ( + + 加载失败 + {error} + + + ) + } + + // 未成为分销商时给出明确引导,避免一直停留在“加载中” + if (!dealerUser) { + return ( + + 你还不是分销商 + 申请成为分销商后即可生成分享码 + + + ) + } + return ( {/* 头部卡片 */} @@ -282,7 +309,7 @@ const DealerQrcode: React.FC = () => { {/* 小程序码展示区 */} - {loading ? ( + {codeLoading ? ( 生成中... @@ -344,7 +371,7 @@ const DealerQrcode: React.FC = () => { block icon={} onClick={saveMiniProgramCode} - disabled={!miniProgramCodeUrl || loading || saving} + disabled={!miniProgramCodeUrl || codeLoading || saving} > 保存小程序码到相册 @@ -355,7 +382,7 @@ const DealerQrcode: React.FC = () => { {/* block*/} {/* icon={}*/} {/* onClick={copyInviteInfo}*/} - {/* disabled={!dealerUser?.userId || loading}*/} + {/* disabled={!dealerUser?.userId || codeLoading}*/} {/* >*/} {/* 复制邀请信息*/} {/* */} @@ -367,7 +394,7 @@ const DealerQrcode: React.FC = () => { {/* fill="outline"*/} {/* icon={}*/} {/* onClick={shareMiniProgramCode}*/} - {/* disabled={!dealerUser?.userId || loading}*/} + {/* disabled={!dealerUser?.userId || codeLoading}*/} {/* >*/} {/* 分享给好友*/} {/* */} diff --git a/src/pages/cart/cart.tsx b/src/pages/cart/cart.tsx index d7a2e37..15f87e6 100644 --- a/src/pages/cart/cart.tsx +++ b/src/pages/cart/cart.tsx @@ -14,6 +14,7 @@ import {ArrowLeft, Del} from '@nutui/icons-react-taro'; import {View} from '@tarojs/components'; import {CartItem, useCart} from "@/hooks/useCart"; import './cart.scss'; +import { ensureLoggedIn } from '@/utils/auth' function Cart() { const [statusBarHeight, setStatusBarHeight] = useState(0); @@ -150,6 +151,9 @@ function Cart() { // 将选中的商品信息存储到本地,供结算页面使用 Taro.setStorageSync('checkout_items', JSON.stringify(selectedCartItems)); + // 未登录则引导去注册/登录;登录后回到购物车结算页 + if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return + // 跳转到购物车结算页面 Taro.navigateTo({ url: '/shop/orderConfirmCart/index' diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index 435583a..a8333b9 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -9,6 +9,7 @@ import { checkAndHandleInviteRelation, hasPendingInvite } from '@/utils/invite' import { pageShopGoods } from '@/api/shop/shopGoods' import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model' import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket' +import { ensureLoggedIn } from '@/utils/auth' import './index.scss' function Home() { @@ -201,19 +202,28 @@ function Home() { key: 'ticket', title: '我的水票', icon: , - onClick: () => Taro.navigateTo({ url: '/user/ticket/index' }), + onClick: () => { + if (!ensureLoggedIn('/user/ticket/index')) return + Taro.navigateTo({ url: '/user/ticket/index' }) + }, }, { key: 'order', title: '立即送水', icon: , - onClick: () => Taro.navigateTo({ url: '/user/ticket/use?goodsId=10074' }), + onClick: () => { + if (!ensureLoggedIn('/user/ticket/use?goodsId=10074')) return + Taro.navigateTo({ url: '/user/ticket/use?goodsId=10074' }) + }, }, { key: 'invite', title: '邀请有礼', icon: , - onClick: () => Taro.navigateTo({ url: '/dealer/qrcode/index' }), + onClick: () => { + if (!ensureLoggedIn('/dealer/qrcode/index')) return + Taro.navigateTo({ url: '/dealer/qrcode/index' }) + }, }, // { // key: 'coupon', @@ -313,7 +323,10 @@ function Home() { Taro.navigateTo({ url: '/shop/orderConfirm/index?goodsId=10074' })} + onClick={() => { + if (!ensureLoggedIn('/shop/orderConfirm/index?goodsId=10074')) return + Taro.navigateTo({ url: '/shop/orderConfirm/index?goodsId=10074' }) + }} > 买水票更优惠 diff --git a/src/pages/user/components/UserCard.tsx b/src/pages/user/components/UserCard.tsx index f1c935f..3f3e13f 100644 --- a/src/pages/user/components/UserCard.tsx +++ b/src/pages/user/components/UserCard.tsx @@ -8,7 +8,7 @@ import navTo from "@/utils/common"; import {TenantId} from "@/config/app"; import {useUser} from "@/hooks/useUser"; import {useUserData} from "@/hooks/useUserData"; -import {getStoredInviteParams} from "@/utils/invite"; +import {checkAndHandleInviteRelation, getStoredInviteParams, hasPendingInvite} from "@/utils/invite"; import UnifiedQRButton from "@/components/UnifiedQRButton"; import {useThemeStyles} from "@/hooks/useTheme"; import {getRootDomain} from "@/utils/domain"; @@ -206,6 +206,13 @@ const UserCard = forwardRef((_, ref) => { // 登录态已就绪后刷新卡片统计(余额/积分/券/水票) refresh().then() reloadTicketTotal() + + // 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定 + if (hasPendingInvite()) { + checkAndHandleInviteRelation().catch((e) => { + console.error('个人中心登录后处理邀请关系失败:', e) + }) + } } }) } else { diff --git a/src/passport/agreement.tsx b/src/passport/agreement.tsx index 5ce26d1..fbbae42 100644 --- a/src/passport/agreement.tsx +++ b/src/passport/agreement.tsx @@ -1,28 +1,47 @@ -import {useEffect, useState} from "react"; +import { useEffect, useState } from 'react' import Taro from '@tarojs/taro' -import {View, RichText} from '@tarojs/components' +import { Loading } from '@nutui/nutui-react-taro' +import { RichText, View } from '@tarojs/components' +import { getByCode } from '@/api/cms/cmsArticle' +import { wxParse } from '@/utils/common' const Agreement = () => { + const [loading, setLoading] = useState(true) + const [content, setContent] = useState('') - const [content, setContent] = useState('') - const reload = () => { - Taro.hideTabBar() - setContent('

' + - '欢迎使用' + - ' ' + - '【WebSoft】' + - '服务协议 ' + - '

') + const reload = async () => { + try { + Taro.hideTabBar() + } catch (_) { + // ignore (e.g. H5 / unsupported env) + } + + try { + const article = await getByCode('xieyi') + setContent(article?.content ? wxParse(article.content) : '

暂无协议内容

') + } catch (e) { + // Keep UI usable even if CMS/API fails. + // eslint-disable-next-line no-console + console.error('load agreement failed', e) + setContent('

协议内容加载失败

') + Taro.showToast({ title: '协议加载失败', icon: 'none' }) + } finally { + setLoading(false) + } } useEffect(() => { reload() }, []) + if (loading) { + return 加载中 + } + return ( <> - + ) diff --git a/src/passport/register.config.ts b/src/passport/register.config.ts new file mode 100644 index 0000000..8018733 --- /dev/null +++ b/src/passport/register.config.ts @@ -0,0 +1,5 @@ +export default definePageConfig({ + navigationBarTitleText: '注册/登录', + navigationBarTextStyle: 'black' +}) + diff --git a/src/passport/register.tsx b/src/passport/register.tsx new file mode 100644 index 0000000..2569e44 --- /dev/null +++ b/src/passport/register.tsx @@ -0,0 +1,295 @@ +import { useEffect, useMemo, useState } from 'react' +import Taro from '@tarojs/taro' +import { Button, Radio } from '@nutui/nutui-react-taro' +import { TenantId } from '@/config/app' +import { getUserInfo, getWxOpenId } from '@/api/layout' +import { saveStorageByLoginUser } from '@/utils/server' +import { + getStoredInviteParams, + parseInviteParams, + saveInviteParams, + trackInviteSource, + checkAndHandleInviteRelation, +} from '@/utils/invite' + +interface GetPhoneNumberDetail { + code?: string + encryptedData?: string + iv?: string + errMsg?: string +} + +interface GetPhoneNumberEvent { + detail: GetPhoneNumberDetail +} + +interface LoginResponse { + data: { + code?: number + message?: string + data?: { + access_token: string + user: any + } + } +} + +async function getWeappLoginCode(): Promise { + try { + const res = await new Promise<{ code?: string }>((resolve, reject) => { + Taro.login({ + success: (r) => resolve(r as any), + fail: (e) => reject(e), + }) + }) + return res?.code + } catch (_e) { + return undefined + } +} + +async function ensureWxOpenIdSaved(opts: { user?: any; wxLoginCode?: string }) { + // JSAPI 微信支付必须有 openid;注册/登录后立刻补齐,避免后续创建支付单失败。 + try { + if (Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) return + } catch (_e) { + if (process.env.TARO_ENV !== 'weapp') return + } + + if (opts.user?.openid) return + + const code = opts.wxLoginCode || (await getWeappLoginCode()) + if (!code) return + + // 该接口一般会在服务端把 openid 绑定到当前登录用户;返回值并不一定包含 openid。 + await getWxOpenId({ code }) + + // 同步本地 User(让后续页面/逻辑能直接读到 openid) + try { + const fresh = await getUserInfo() + if (fresh) Taro.setStorageSync('User', fresh) + } catch (_e) { + // ignore: openid 已在服务端绑定,本地不同步也不影响后端创建支付订单 + } +} + +function safeDecodeMaybeEncoded(input?: string): string { + if (!input) return '' + try { + // Taro 路由参数通常是 URL 编码过的字符串 + return decodeURIComponent(input) + } catch (_e) { + return input + } +} + +function isTabBarUrl(url: string) { + const pure = url.split('?')[0] + return ( + pure === '/pages/index/index' || + pure === '/pages/cart/cart' || + pure === '/pages/user/user' || + pure === '/pages/category/index' + ) +} + +const Register = () => { + const [isAgree, setIsAgree] = useState(false) + const [loading, setLoading] = useState(false) + + // 短信验证码登录仅在非微信小程序端展示 + const isWeapp = useMemo(() => { + try { + return Taro.getEnv() === Taro.ENV_TYPE.WEAPP + } catch (_e) { + return process.env.TARO_ENV === 'weapp' + } + }, []) + + const router = Taro.getCurrentInstance().router + + useEffect(() => { + // 注册/登录页不需要展示 tabBar + Taro.hideTabBar() + }, []) + + const redirectUrl = useMemo(() => { + const raw = (router?.params as any)?.redirect as string | undefined + const decoded = safeDecodeMaybeEncoded(raw) + if (!decoded) return '' + return decoded.startsWith('/') ? decoded : `/${decoded}` + }, [router?.params]) + + // 如果从分享/二维码直接进入注册页(携带 inviter/source/t),先暂存邀请信息 + useEffect(() => { + try { + const inviteParams = parseInviteParams({ query: router?.params }) + if (inviteParams?.inviter) { + saveInviteParams(inviteParams) + trackInviteSource(inviteParams.source || 'qrcode', parseInt(inviteParams.inviter, 10)) + } + } catch (e) { + console.error('注册页处理邀请参数失败:', e) + } + }, [router?.params]) + + const navigateAfterLogin = async () => { + if (!redirectUrl) { + await Taro.reLaunch({ url: '/pages/index/index' }) + return + } + + if (isTabBarUrl(redirectUrl)) { + // switchTab 不支持携带 query,这里按纯路径跳转 + await Taro.switchTab({ url: redirectUrl.split('?')[0] }) + return + } + + // 替换当前注册页,避免返回栈里再回到注册页 + await Taro.redirectTo({ url: redirectUrl }) + } + + const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => { + if (!isAgree) { + Taro.showToast({ title: '请先勾选同意协议', icon: 'none' }) + return + } + if (loading) return + + const { code: phoneCode, encryptedData, iv, errMsg } = detail || {} + if (!phoneCode || (errMsg && errMsg.includes('fail'))) { + Taro.showToast({ title: '未授权手机号', icon: 'none' }) + return + } + + try { + setLoading(true) + + // 获取存储的邀请参数(推荐人ID) + const inviteParams = getStoredInviteParams() + const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0 + + // 获取小程序登录 code(用于后续绑定 openid) + const wxLoginCode = await getWeappLoginCode() + + const res = (await Taro.request({ + url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone', + method: 'POST', + data: { + code: phoneCode, + encryptedData, + iv, + notVerifyPhone: true, + refereeId: refereeId, + sceneType: 'save_referee', + tenantId: TenantId, + }, + header: { + 'content-type': 'application/json', + TenantId, + }, + })) as unknown as LoginResponse + + if ((res as any)?.data?.code === 1) { + Taro.showToast({ title: res.data.message || '登录失败', icon: 'none' }) + return + } + + const token = res?.data?.data?.access_token + const user = res?.data?.data?.user + if (!token || !user?.userId) { + Taro.showToast({ title: '登录失败,请重试', icon: 'none' }) + return + } + + saveStorageByLoginUser(token, user) + + // 注册/登录成功后,立即补齐 openid(JSAPI 支付必需) + try { + await ensureWxOpenIdSaved({ user, wxLoginCode }) + } catch (e) { + console.error('注册页绑定 openid 失败:', e) + } + + // 登录成功后尝试绑定推荐关系(如果有待处理 inviter,会自动处理并清理参数) + try { + await checkAndHandleInviteRelation() + } catch (e) { + console.error('注册页登录后处理邀请关系失败:', e) + } + + Taro.showToast({ title: '登录成功', icon: 'success' }) + setTimeout(() => { + navigateAfterLogin().catch((e) => console.error('登录后跳转失败:', e)) + }, 800) + } catch (e: any) { + console.error('注册/登录失败:', e) + Taro.showToast({ title: e?.message || '登录失败', icon: 'none' }) + } finally { + setLoading(false) + } + } + + const goSmsLogin = () => { + const inviteParams = getStoredInviteParams() + const inviter = inviteParams?.inviter + const source = inviteParams?.source + const t = inviteParams?.t + + const params: Record = {} + if (redirectUrl) params.redirect = redirectUrl + // 兜底:把 inviter 带过去,避免“先点注册再进入”时丢失 + if (inviter) params.inviter = inviter + if (source) params.source = source + if (t) params.t = t + + const qs = Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&') + Taro.navigateTo({ url: `/passport/sms-login${qs ? `?${qs}` : ''}` }) + } + + return ( + <> +
+
注册/登录
+ +
+ + + {!isWeapp && ( + + )} +
+ +
+ setIsAgree(!isAgree)} /> + setIsAgree(!isAgree)}> + 勾选表示您已阅读并同意 + + Taro.navigateTo({ url: '/passport/agreement' })} + className={'text-blue-600'} + > + 《服务协议及隐私政策》 + +
+
+ + ) +} + +export default Register diff --git a/src/passport/sms-login.tsx b/src/passport/sms-login.tsx index c30933c..7b3dad1 100644 --- a/src/passport/sms-login.tsx +++ b/src/passport/sms-login.tsx @@ -3,7 +3,7 @@ import Taro from '@tarojs/taro' import {Input, Button} from '@nutui/nutui-react-taro' import {loginBySms, sendSmsCaptcha} from "@/api/passport/login"; import {LoginParam} from "@/api/passport/login/model"; -import {checkAndHandleInviteRelation, hasPendingInvite} from "@/utils/invite"; +import {checkAndHandleInviteRelation, hasPendingInvite, parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite"; const SmsLogin = () => { const [loading, setLoading] = useState(false) @@ -14,6 +14,46 @@ const SmsLogin = () => { code: '' }) + const router = Taro.getCurrentInstance().router + const redirectParam = (router?.params as any)?.redirect as string | undefined + + const safeDecodeMaybeEncoded = (input?: string) => { + if (!input) return '' + try { + return decodeURIComponent(input) + } catch (_e) { + return input + } + } + + const redirectUrl = (() => { + const decoded = safeDecodeMaybeEncoded(redirectParam) + if (!decoded) return '' + return decoded.startsWith('/') ? decoded : `/${decoded}` + })() + + const isTabBarUrl = (url: string) => { + const pure = url.split('?')[0] + return ( + pure === '/pages/index/index' || + pure === '/pages/cart/cart' || + pure === '/pages/user/user' || + pure === '/pages/category/index' + ) + } + + const navigateAfterLogin = async () => { + if (!redirectUrl) { + await Taro.reLaunch({ url: '/pages/index/index' }) + return + } + if (isTabBarUrl(redirectUrl)) { + await Taro.switchTab({ url: redirectUrl.split('?')[0] }) + return + } + await Taro.redirectTo({ url: redirectUrl }) + } + const reload = () => { Taro.hideTabBar() } @@ -22,6 +62,19 @@ const SmsLogin = () => { reload() }, []) + // 如果从分享/二维码链接进入短信登录页,先暂存邀请信息 + useEffect(() => { + try { + const inviteParams = parseInviteParams({ query: router?.params }) + if (inviteParams?.inviter) { + saveInviteParams(inviteParams) + trackInviteSource(inviteParams.source || 'share', parseInt(inviteParams.inviter, 10)) + } + } catch (e) { + console.error('短信登录页处理邀请参数失败:', e) + } + }, [router?.params]) + // 倒计时效果 useEffect(() => { let timer: NodeJS.Timeout @@ -148,8 +201,9 @@ const SmsLogin = () => { // 延迟跳转到首页 setTimeout(() => { - Taro.reLaunch({ - url: '/pages/index/index' + navigateAfterLogin().catch((e) => { + console.error('短信登录后跳转失败:', e) + Taro.reLaunch({ url: '/pages/index/index' }) }) }, 1500) diff --git a/src/shop/goodsDetail/index.tsx b/src/shop/goodsDetail/index.tsx index df66a4b..80c04d4 100644 --- a/src/shop/goodsDetail/index.tsx +++ b/src/shop/goodsDetail/index.tsx @@ -16,6 +16,7 @@ import "./index.scss"; import {useCart} from "@/hooks/useCart"; import {useConfig} from "@/hooks/useConfig"; import {parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite"; +import { ensureLoggedIn } from '@/utils/auth' const GoodsDetail = () => { const [statusBarHeight, setStatusBarHeight] = useState(44); @@ -62,13 +63,7 @@ const GoodsDetail = () => { const handleAddToCart = () => { if (!goods) return; - if (!Taro.getStorageSync('UserId')) { - return Taro.showToast({ - title: '请先登录', - icon: 'none', - duration: 2000 - }); - } + if (!ensureLoggedIn(`/shop/goodsDetail/index?id=${goods.goodsId}`)) return // 如果有规格,显示规格选择器 if (specs.length > 0) { @@ -90,13 +85,7 @@ const GoodsDetail = () => { const handleBuyNow = () => { if (!goods) return; - if (!Taro.getStorageSync('UserId')) { - return Taro.showToast({ - title: '请先登录', - icon: 'none', - duration: 2000 - }); - } + if (!ensureLoggedIn(`/shop/orderConfirm/index?goodsId=${goods.goodsId}`)) return // 如果有规格,显示规格选择器 if (specs.length > 0) { diff --git a/src/shop/orderConfirm/index.tsx b/src/shop/orderConfirm/index.tsx index a6ba606..422c754 100644 --- a/src/shop/orderConfirm/index.tsx +++ b/src/shop/orderConfirm/index.tsx @@ -43,6 +43,7 @@ import dayjs from 'dayjs' import type {ShopStore} from "@/api/shop/shopStore/model"; import {getShopStore, listShopStore} from "@/api/shop/shopStore"; import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection"; +import { ensureLoggedIn, isLoggedIn } from '@/utils/auth' const OrderConfirm = () => { @@ -88,6 +89,16 @@ const OrderConfirm = () => { const router = Taro.getCurrentInstance().router; const goodsId = router?.params?.goodsId; + // 页面级兜底:未登录直接进入下单页时,引导去注册/登录并回跳 + useEffect(() => { + if (!goodsId) { + // 也可能是 orderData 模式;这里只做最小兜底 + if (!ensureLoggedIn('/shop/orderConfirm/index')) return + return + } + if (!ensureLoggedIn(`/shop/orderConfirm/index?goodsId=${goodsId}`)) return + }, [goodsId]) + const isTicketTemplateActive = !!ticketTemplate && ticketTemplate.enabled !== false && @@ -607,6 +618,9 @@ const OrderConfirm = () => { // 统一的数据加载函数 const loadAllData = async () => { + // 未登录时不发起接口请求;页面会被登录兜底逻辑引导走注册/登录页 + if (!isLoggedIn()) return + try { setLoading(true) setError('') @@ -694,12 +708,14 @@ const OrderConfirm = () => { useDidShow(() => { // 返回/切换到该页面时,刷新一下当前已选门店 + if (!isLoggedIn()) return setSelectedStore(getSelectedStoreFromStorage()) loadAllData() }) useEffect(() => { // 切换商品时重置配送时间,避免沿用上一次选择 + if (!isLoggedIn()) return setSendTime(dayjs().startOf('day').toDate()) setSendTimePickerVisible(false) loadAllData() diff --git a/src/shop/orderConfirmCart/index.tsx b/src/shop/orderConfirmCart/index.tsx index b9cb9dd..1978185 100644 --- a/src/shop/orderConfirmCart/index.tsx +++ b/src/shop/orderConfirmCart/index.tsx @@ -2,8 +2,6 @@ import {useEffect, useState} from "react"; import {Image, Button, Cell, CellGroup, Input, Space} from '@nutui/nutui-react-taro' import {Location, ArrowRight} from '@nutui/icons-react-taro' import Taro from '@tarojs/taro' -import {ShopGoods} from "@/api/shop/shopGoods/model"; -import {getShopGoods} from "@/api/shop/shopGoods"; import {View} from '@tarojs/components'; import {listShopUserAddress} from "@/api/shop/shopUserAddress"; import {ShopUserAddress} from "@/api/shop/shopUserAddress/model"; @@ -12,14 +10,12 @@ import {useCart, CartItem} from "@/hooks/useCart"; import Gap from "@/components/Gap"; import {Payment} from "@/api/system/payment/model"; import {PaymentHandler, PaymentType, buildCartOrder} from "@/utils/payment"; +import { ensureLoggedIn } from '@/utils/auth' const OrderConfirm = () => { - const [goods, setGoods] = useState(null); const [address, setAddress] = useState() - const [payment, setPayment] = useState() + const [payment] = useState() const [checkoutItems, setCheckoutItems] = useState([]); - const router = Taro.getCurrentInstance().router; - const goodsId = router?.params?.goodsId; const { cartItems, @@ -27,13 +23,18 @@ const OrderConfirm = () => { } = useCart(); const reload = async () => { - const address = await listShopUserAddress({isDefault: true}); - if (address.length > 0) { - console.log(address, '111') - setAddress(address[0]) + const addressList = await listShopUserAddress({isDefault: true}); + if (addressList.length > 0) { + setAddress(addressList[0]) } } + // 页面级兜底:防止未登录时进入结算页导致接口报错/仅提示“请先登录” + useEffect(() => { + // redirect 到当前结算页,登录成功后返回继续支付 + if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return + }, []) + // 加载结算商品数据 const loadCheckoutItems = () => { try { @@ -57,6 +58,8 @@ const OrderConfirm = () => { * 统一支付入口 */ const onPay = async () => { + if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return + // 基础校验 if (!address) { Taro.showToast({ @@ -77,7 +80,7 @@ const OrderConfirm = () => { // 构建订单数据 const orderData = buildCartOrder( checkoutItems.map(item => ({ - goodsId: item.goodsId!, + goodsId: item.goodsId, quantity: item.quantity || 1 })), address.id, @@ -102,16 +105,11 @@ const OrderConfirm = () => { }; useEffect(() => { - if (goodsId) { - getShopGoods(Number(goodsId)).then(res => { - setGoods(res); - }).catch(error => { - console.error("Failed to fetch goods detail:", error); - }); - } + if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return + reload().then(); loadCheckoutItems(); - }, [goodsId, cartItems]); + }, [cartItems]); // 计算总价 const getTotalPrice = () => { @@ -157,19 +155,19 @@ const OrderConfirm = () => { - {checkoutItems.map((goods, _) => ( - + {checkoutItems.map((item) => ( + - - {goods.name} + {item.name} 80g/袋 - ¥{goods.price} - x {goods.quantity} + ¥{item.price} + x {item.quantity} diff --git a/src/store/index.tsx b/src/store/index.tsx index 37ef85a..948f192 100644 --- a/src/store/index.tsx +++ b/src/store/index.tsx @@ -9,6 +9,7 @@ import {getSelectedStoreFromStorage} from '@/utils/storeSelection' import {listShopStoreUser} from '@/api/shop/shopStoreUser' import {getShopStore} from '@/api/shop/shopStore' import type {ShopStore as ShopStoreModel} from '@/api/shop/shopStore/model' +import { goToRegister } from '@/utils/auth' const StoreIndex: React.FC = () => { const themeStyles = useThemeStyles() @@ -43,7 +44,7 @@ const StoreIndex: React.FC = () => { const navigateToPage = (url: string) => { if (!isLoggedIn) { - Taro.showToast({title: '请先登录', icon: 'none', duration: 1500}) + goToRegister({ redirect: '/store/index' }) return } Taro.navigateTo({url}) @@ -121,8 +122,8 @@ const StoreIndex: React.FC = () => { 请先登录后再进入门店中心 - diff --git a/src/user/profile/profile.tsx b/src/user/profile/profile.tsx index 1353e2d..18ad9f4 100644 --- a/src/user/profile/profile.tsx +++ b/src/user/profile/profile.tsx @@ -73,7 +73,7 @@ function Profile() { avatar: `${detail.avatarUrl}`, }) Taro.uploadFile({ - url: 'https://server.websoft.top/api/oss/upload', + url: 'https://glt-server.websoft.top/api/oss/upload', filePath: detail.avatarUrl, name: 'file', header: { diff --git a/src/user/ticket/use.tsx b/src/user/ticket/use.tsx index 2c093ae..83adf17 100644 --- a/src/user/ticket/use.tsx +++ b/src/user/ticket/use.tsx @@ -6,6 +6,7 @@ import { Cell, CellGroup, ConfigProvider, + Empty, Input, InputNumber, Popup, @@ -70,6 +71,7 @@ const OrderConfirm = () => { const [selectedTicketId, setSelectedTicketId] = useState(undefined) const [ticketPopupVisible, setTicketPopupVisible] = useState(false) const [ticketLoading, setTicketLoading] = useState(false) + const noTicketPromptedRef = useRef(false) // Delivery range (geofence): block ordering if address/current location is outside. const [fences, setFences] = useState([]) @@ -119,6 +121,11 @@ const OrderConfirm = () => { return Number(selectedTicket?.availableQty || 0) }, [selectedTicket?.availableQty]) + const noUsableTickets = useMemo(() => { + // Only show "go buy tickets" guidance after we have finished loading. + return !!userId && !ticketLoading && usableTickets.length === 0 + }, [ticketLoading, usableTickets.length, userId]) + const maxQuantity = useMemo(() => { const stockMax = goods?.stock ?? 999 return Math.max(0, Math.min(stockMax, availableTicketTotal)) @@ -428,6 +435,21 @@ const OrderConfirm = () => { } } + const goBuyTickets = async () => { + try { + setTicketPopupVisible(false) + // If this page is opened with a goodsId, guide user back to that goods detail to purchase. + if (numericGoodsId) { + await Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${numericGoodsId}` }) + return + } + await Taro.switchTab({ url: '/pages/index/index' }) + } catch (e) { + console.error('跳转购买水票失败:', e) + Taro.showToast({ title: '跳转失败,请稍后重试', icon: 'none' }) + } + } + const onSubmit = async () => { if (submitLoading) return if (deliveryRangeCheckingRef.current) return @@ -623,6 +645,26 @@ const OrderConfirm = () => { } }, [usableTickets, selectedTicketId]) + // If user has no usable tickets, proactively guide them to purchase (only once per page lifecycle). + useEffect(() => { + if (!noUsableTickets) return + if (noTicketPromptedRef.current) return + noTicketPromptedRef.current = true + + ;(async () => { + const r = await Taro.showModal({ + title: '暂无可用水票', + content: '您当前没有可用水票,购买后再来下单更方便。', + confirmText: '去购买', + cancelText: '暂不' + }) + if (r.confirm) { + await goBuyTickets() + } + })() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [noUsableTickets]) + // 重新加载数据 const handleRetry = () => { loadAllData() @@ -738,26 +780,50 @@ const OrderConfirm = () => { 选择水票
)} - extra={( - - - {ticketLoading - ? '加载中...' - : (selectedTicket - ? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0})` - : '请选择' - ) - } - - - + extra={( + + + {ticketLoading + ? '加载中...' + : (selectedTicket + ? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0})` + : (noUsableTickets ? '暂无可用水票' : '请选择') + ) + } + + + + )} + onClick={async () => { + if (ticketLoading) return + if (noUsableTickets) { + const r = await Taro.showModal({ + title: '暂无可用水票', + content: '您还没有可用水票,是否前往购买?', + confirmText: '去购买', + cancelText: '暂不' + }) + if (r.confirm) await goBuyTickets() + return + } + setTicketPopupVisible(true) + }} + /> + {noUsableTickets && ( + 还没有购买水票} + description="购买水票后即可在这里直接下单送水" + extra={( + + )} + /> )} - onClick={() => !ticketLoading && setTicketPopupVisible(true)} - /> - {displayQty} 张
} - /> + {displayQty} 张
} + /> @@ -795,26 +861,37 @@ const OrderConfirm = () => { 加载中...
) : ( - - {usableTickets.map((t) => { - const active = selectedTicket?.id && Number(selectedTicket.id) === Number(t.id) - return ( - 票号 {t.id}} - description={t.orderNo ? `来源订单:${t.orderNo}` : ''} - extra={可用 {t.availableQty ?? 0}} - onClick={() => { - setSelectedTicketId(Number(t.id)) - setTicketPopupVisible(false) - Taro.showToast({ title: '水票已选择', icon: 'success' }) - }} - /> - )})} - {!usableTickets.length && ( - 暂无可用水票} /> + <> + {!!usableTickets.length ? ( + + {usableTickets.map((t) => { + const active = selectedTicket?.id && Number(selectedTicket.id) === Number(t.id) + return ( + 票号 {t.id}} + description={t.orderNo ? `来源订单:${t.orderNo}` : ''} + extra={可用 {t.availableQty ?? 0}} + onClick={() => { + setSelectedTicketId(Number(t.id)) + setTicketPopupVisible(false) + Taro.showToast({ title: '水票已选择', icon: 'success' }) + }} + /> + ) + })} + + ) : ( + + + + + + )} - + )}
@@ -889,29 +966,35 @@ const OrderConfirm = () => {
- -
- -
- - + +
+ {noUsableTickets ? ( + + ) : ( + + )} +
+ + ); }; diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..45f8352 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,30 @@ +import Taro from '@tarojs/taro' +import { goTo } from '@/utils/navigation' +import { getStoredInviteParams } from '@/utils/invite' + +export function isLoggedIn(): boolean { + const token = Taro.getStorageSync('access_token') + const userId = Taro.getStorageSync('UserId') + return !!token && !!userId +} + +export function goToRegister(options?: { redirect?: string }) { + const inviteParams = getStoredInviteParams() + goTo('/passport/register', { + redirect: options?.redirect, + inviter: inviteParams?.inviter, + source: inviteParams?.source, + t: inviteParams?.t, + }) +} + +/** + * Ensure user is logged in; if not, navigate to register/login page. + * @returns true when already logged in; false when redirected to register + */ +export function ensureLoggedIn(redirect?: string): boolean { + if (isLoggedIn()) return true + goToRegister({ redirect }) + return false +} + diff --git a/src/utils/common.ts b/src/utils/common.ts index 5c2b526..0d1fd36 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,15 +1,13 @@ import Taro from '@tarojs/taro' import { goTo } from './navigation' +import { goToRegister, isLoggedIn } from '@/utils/auth' export default function navTo(url: string, isLogin = false) { if (isLogin) { - if (!Taro.getStorageSync('access_token') || !Taro.getStorageSync('UserId')) { - Taro.showToast({ - title: '请先登录', - icon: 'none', - duration: 500 - }); - return false; + if (!isLoggedIn()) { + const redirect = url.startsWith('/') ? url : `/${url}` + goToRegister({ redirect }) + return false } } // 使用新的导航工具,自动处理路径格式化 diff --git a/types/crypto-js.d.ts b/types/crypto-js.d.ts new file mode 100644 index 0000000..95a0537 --- /dev/null +++ b/types/crypto-js.d.ts @@ -0,0 +1,22 @@ +declare module 'crypto-js' { + // Minimal typings for the subset used in `src/api/system/file/index.ts`. + export interface WordArray { + toString(encoder?: unknown): string; + } + + export const enc: { + Base64: { + stringify(wordArray: WordArray): string; + }; + }; + + export function HmacSHA1(message: string, key: string): WordArray; + + const CryptoJS: { + enc: typeof enc; + HmacSHA1: typeof HmacSHA1; + }; + + export default CryptoJS; +} +