fix(api): 替换所有接口请求的域名为 guilixu-api.websoft.top
- 将获取 STS Token 的接口地址由 paopao-api 改为 guilixu-api - 更新图片上传接口地址为新的 guilixu-api 域名 - 修改用户推广页面中邀请码链接和二维码接口的域名 - 更改注册页微信登录接口请求的域名为 guilixu-api
This commit is contained in:
4
src/passport/agreement.config.ts
Normal file
4
src/passport/agreement.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '协议详情',
|
||||
navigationBarTextStyle: 'black'
|
||||
})
|
||||
55
src/passport/agreement.scss
Normal file
55
src/passport/agreement.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
.agreement-page {
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
min-height: 100vh;
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #444;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 8px;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 4px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
178
src/passport/agreement.tsx
Normal file
178
src/passport/agreement.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Loading } from '@nutui/nutui-react-taro'
|
||||
import { RichText, View } from '@tarojs/components'
|
||||
import { wxParse } from '@/utils/common'
|
||||
import './agreement.scss'
|
||||
|
||||
/** 用户服务协议内容 */
|
||||
const TERMS_CONTENT = `
|
||||
<h2>用户服务协议</h2>
|
||||
<p><strong>最后更新日期:2026年5月16日</strong></p>
|
||||
|
||||
<h3>一、协议的范围与接受</h3>
|
||||
<p>欢迎使用鑫龙家电(以下简称"本小程序")。本小程序由南宁市网宿信息科技有限公司(以下简称"我们")开发并运营。请您在使用本小程序服务之前,仔细阅读并充分理解本协议的全部内容。您点击"同意"或实际使用本小程序服务,即视为您已阅读、理解并同意接受本协议的约束。</p>
|
||||
|
||||
<h3>二、服务内容</h3>
|
||||
<p>本小程序为用户提供以下服务:</p>
|
||||
<ul>
|
||||
<li>体育用品在线浏览与购买</li>
|
||||
<li>商品搜索、收藏与分享</li>
|
||||
<li>订单管理与物流查询</li>
|
||||
<li>会员积分与优惠活动</li>
|
||||
<li>在线客服咨询</li>
|
||||
</ul>
|
||||
|
||||
<h3>三、账号注册与安全</h3>
|
||||
<p>1. 您需要使用微信账号授权登录本小程序。您应确保提供的个人信息真实、准确、完整。</p>
|
||||
<p>2. 您应妥善保管自己的账号信息,对使用您账号进行的所有行为承担法律责任。</p>
|
||||
<p>3. 如发现账号异常,请立即联系我们。</p>
|
||||
|
||||
<h3>四、用户行为规范</h3>
|
||||
<p>您在使用本小程序时,应当遵守法律法规,不得从事以下行为:</p>
|
||||
<ul>
|
||||
<li>发布违法违规信息</li>
|
||||
<li>侵犯他人知识产权或其他合法权益</li>
|
||||
<li>恶意刷单、虚假交易</li>
|
||||
<li>利用漏洞或技术手段干扰本小程序正常运行</li>
|
||||
</ul>
|
||||
|
||||
<h3>五、订单与支付</h3>
|
||||
<p>1. 您下单后,我们将根据您提供的收货信息安排发货。</p>
|
||||
<p>2. 商品价格以您下单时的页面显示为准。</p>
|
||||
<p>3. 您可以选择微信支付等方式完成付款。</p>
|
||||
|
||||
<h3>六、售后服务</h3>
|
||||
<p>我们提供7天无理由退换货服务(特殊商品除外)。如有售后问题,请联系在线客服。</p>
|
||||
|
||||
<h3>七、免责声明</h3>
|
||||
<p>1. 因不可抗力导致的服务中断,我们不承担责任。</p>
|
||||
<p>2. 因您自身原因导致的损失,我们不承担责任。</p>
|
||||
|
||||
<h3>八、协议的变更</h3>
|
||||
<p>我们可能会根据业务需要修改本协议。修改后的协议将在本小程序内公布,您继续使用服务视为接受修改后的协议。</p>
|
||||
|
||||
<h3>九、联系我们</h3>
|
||||
<p>如您对本协议有任何疑问,请联系:</p>
|
||||
<p>客服电话:0771-5386339</p>
|
||||
<p>客服邮箱:support@paopao.com</p>
|
||||
`
|
||||
|
||||
/** 隐私政策内容 */
|
||||
const PRIVACY_CONTENT = `
|
||||
<h2>隐私政策</h2>
|
||||
<p><strong>最后更新日期:2026年5月16日</strong></p>
|
||||
|
||||
<p>南宁市网宿信息科技有限公司(以下简称"我们")非常重视您的个人信息保护。本隐私政策说明我们如何收集、使用、存储和保护您的个人信息。</p>
|
||||
|
||||
<h3>一、我们收集的信息</h3>
|
||||
<p>为了向您提供服务,我们可能需要收集以下信息:</p>
|
||||
|
||||
<h4>1. 您主动提供的信息</h4>
|
||||
<ul>
|
||||
<li><strong>账号信息</strong>:微信昵称、头像、OpenID(用于登录识别)</li>
|
||||
<li><strong>收货信息</strong>:收货人姓名、手机号码、详细地址(用于商品配送)</li>
|
||||
<li><strong>联系信息</strong>:您在与客服沟通时提供的信息</li>
|
||||
</ul>
|
||||
|
||||
<h4>2. 我们自动收集的信息</h4>
|
||||
<ul>
|
||||
<li><strong>设备信息</strong>:设备型号、操作系统版本</li>
|
||||
<li><strong>日志信息</strong>:访问时间、浏览记录、操作记录</li>
|
||||
<li><strong>位置信息</strong>:经您授权后获取的地理位置(用于推荐附近门店)</li>
|
||||
</ul>
|
||||
|
||||
<h3>二、我们如何使用您的信息</h3>
|
||||
<p>我们收集您的信息用于以下目的:</p>
|
||||
<ul>
|
||||
<li><strong>用户识别与登录</strong>:使用微信 OpenID 识别您的身份,实现免密登录</li>
|
||||
<li><strong>商品配送</strong>:使用收货地址信息完成订单配送</li>
|
||||
<li><strong>订单管理</strong>:处理您的订单、退换货申请</li>
|
||||
<li><strong>客户服务</strong>:响应您的咨询、投诉和建议</li>
|
||||
<li><strong>安全风控</strong>:防范欺诈、保障交易安全</li>
|
||||
<li><strong>服务优化</strong>:分析用户行为,改进产品体验</li>
|
||||
</ul>
|
||||
|
||||
<h3>三、信息的存储与保护</h3>
|
||||
<p>1. 我们采用加密技术保护您的个人信息安全。</p>
|
||||
<p>2. 您的个人信息存储在中国大陆境内的服务器上。</p>
|
||||
<p>3. 我们仅在实现服务目的所必需的期限内保留您的信息。</p>
|
||||
|
||||
<h3>四、信息的共享与披露</h3>
|
||||
<p>我们不会向第三方出售您的个人信息。仅在以下情况下可能共享:</p>
|
||||
<ul>
|
||||
<li>经您明确同意</li>
|
||||
<li>为完成商品配送,向物流公司提供必要的收货信息</li>
|
||||
<li>根据法律法规要求或政府机关的合法要求</li>
|
||||
</ul>
|
||||
|
||||
<h3>五、您的权利</h3>
|
||||
<p>您享有以下权利:</p>
|
||||
<ul>
|
||||
<li>查询、更正您的个人信息</li>
|
||||
<li>删除您的个人信息</li>
|
||||
<li>撤回授权同意</li>
|
||||
<li>注销账号</li>
|
||||
</ul>
|
||||
<p>如需行使上述权利,请联系我们的客服。</p>
|
||||
|
||||
<h3>六、未成年人保护</h3>
|
||||
<p>我们非常重视未成年人的个人信息保护。如果您是未成年人,请在监护人指导下使用本小程序。</p>
|
||||
|
||||
<h3>七、政策更新</h3>
|
||||
<p>我们可能会适时更新本隐私政策。更新后的政策将在本小程序内公布。</p>
|
||||
|
||||
<h3>八、联系我们</h3>
|
||||
<p>如您对本隐私政策有任何疑问,请联系:</p>
|
||||
<p>客服电话:0771-5386339</p>
|
||||
<p>客服邮箱:privacy@paopao.com</p>
|
||||
`
|
||||
|
||||
const Agreement = () => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [content, setContent] = useState<string>('')
|
||||
const [title, setTitle] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
// 获取页面参数 type=terms 或 type=privacy
|
||||
const pages = Taro.getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
const type = (current as any)?.options?.type || 'terms'
|
||||
|
||||
if (type === 'privacy') {
|
||||
setTitle('隐私政策')
|
||||
setContent(wxParse(PRIVACY_CONTENT))
|
||||
} else {
|
||||
setTitle('用户服务协议')
|
||||
setContent(wxParse(TERMS_CONTENT))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('load agreement failed', e)
|
||||
setContent('<p>协议内容加载失败</p>')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
// 动态设置导航栏标题
|
||||
useEffect(() => {
|
||||
if (title) {
|
||||
Taro.setNavigationBarTitle({ title })
|
||||
}
|
||||
}, [title])
|
||||
|
||||
if (loading) {
|
||||
return <Loading className='px-2'>加载中</Loading>
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='agreement-page'>
|
||||
<RichText nodes={content} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
export default Agreement
|
||||
112
src/passport/auth-flow.ts
Normal file
112
src/passport/auth-flow.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { getUserInfo, getWxOpenId } from '@/api/layout'
|
||||
import {
|
||||
checkAndHandleInviteRelation,
|
||||
getStoredInviteParams,
|
||||
hasPendingInvite,
|
||||
parseInviteParams,
|
||||
saveInviteParams,
|
||||
trackInviteSource,
|
||||
} from '@/utils/invite'
|
||||
|
||||
const TABBAR_URLS = ['/pages/index/index', '/pages/order/order', '/pages/user/user']
|
||||
|
||||
export function decodeRedirect(input?: string): string {
|
||||
if (!input) return ''
|
||||
try {
|
||||
const decoded = decodeURIComponent(input)
|
||||
return decoded.startsWith('/') ? decoded : `/${decoded}`
|
||||
} catch {
|
||||
return input.startsWith('/') ? input : `/${input}`
|
||||
}
|
||||
}
|
||||
|
||||
export function getRedirectUrl(): string {
|
||||
const router = Taro.getCurrentInstance().router
|
||||
return decodeRedirect((router?.params as Record<string, string> | undefined)?.redirect)
|
||||
}
|
||||
|
||||
export function hideLoginTabBar() {
|
||||
// passport 页面不是 tabBar 页面,无需调用 hideTabBar
|
||||
}
|
||||
|
||||
export function persistInviteFromRoute(sourceFallback = 'share') {
|
||||
try {
|
||||
const router = Taro.getCurrentInstance().router
|
||||
const inviteParams = parseInviteParams({ query: router?.params })
|
||||
if (inviteParams?.inviter) {
|
||||
saveInviteParams(inviteParams)
|
||||
trackInviteSource(inviteParams.source || sourceFallback, parseInt(inviteParams.inviter, 10))
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getInviteRefereeId(): number {
|
||||
const inviteParams = getStoredInviteParams()
|
||||
return inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
|
||||
}
|
||||
|
||||
export function buildAuthUrl(path: string, redirectUrl?: string): string {
|
||||
const inviteParams = getStoredInviteParams()
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
if (redirectUrl) params.redirect = redirectUrl
|
||||
if (inviteParams?.inviter) params.inviter = inviteParams.inviter
|
||||
if (inviteParams?.source) params.source = inviteParams.source
|
||||
if (inviteParams?.t) params.t = inviteParams.t
|
||||
|
||||
const query = Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&')
|
||||
|
||||
return `${path}${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
export async function getWeappLoginCode(): Promise<string | undefined> {
|
||||
try {
|
||||
const res = await new Promise<{ code?: string }>((resolve, reject) => {
|
||||
Taro.login({ success: (r) => resolve(r), fail: reject })
|
||||
})
|
||||
return res?.code
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWxOpenIdSaved(wxLoginCode?: string) {
|
||||
const code = wxLoginCode || (await getWeappLoginCode())
|
||||
if (!code) return
|
||||
|
||||
await getWxOpenId({ code })
|
||||
|
||||
try {
|
||||
const freshUser = await getUserInfo()
|
||||
if (freshUser) {
|
||||
Taro.setStorageSync('User', freshUser)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePostLogin(redirectUrl?: string) {
|
||||
if (hasPendingInvite()) {
|
||||
try {
|
||||
await checkAndHandleInviteRelation()
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
const target = redirectUrl || '/pages/index/index'
|
||||
if (TABBAR_URLS.includes(target.split('?')[0])) {
|
||||
await Taro.switchTab({ url: target.split('?')[0] })
|
||||
return
|
||||
}
|
||||
|
||||
if (target === '/pages/index/index') {
|
||||
await Taro.reLaunch({ url: target })
|
||||
return
|
||||
}
|
||||
|
||||
await Taro.redirectTo({ url: target })
|
||||
}
|
||||
4
src/passport/forget.config.ts
Normal file
4
src/passport/forget.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '忘记密码',
|
||||
navigationBarTextStyle: 'black'
|
||||
})
|
||||
37
src/passport/forget.tsx
Normal file
37
src/passport/forget.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import {useEffect} from "react";
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View } from '@tarojs/components'
|
||||
import {Input, Button} from '@nutui/nutui-react-taro'
|
||||
import {copyText} from "@/utils/common";
|
||||
|
||||
const Register = () => {
|
||||
const reload = () => {
|
||||
// forget 页面不是 tabBar 页面,无需调用 hideTabBar
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className='flex flex-col justify-center px-5 pt-3'>
|
||||
<View className='text-sm py-2'>请验证您的登录账号,以进行重设密码</View>
|
||||
<View className='flex flex-col justify-between items-center my-2'>
|
||||
<Input type='text' placeholder='手机号' maxLength={11} style={{backgroundColor: '#ffffff', borderRadius: '8px'}} />
|
||||
</View>
|
||||
<View className='flex flex-col justify-between items-center my-2'>
|
||||
<Input type='password' placeholder='新的密码' style={{backgroundColor: '#ffffff', borderRadius: '8px'}} />
|
||||
</View>
|
||||
<View className='flex justify-between items-center bg-white rounded-lg my-2 pr-2'>
|
||||
<Input type='text' placeholder='短信验证码' style={{ backgroundColor: '#ffffff', borderRadius: '8px'}} />
|
||||
<Button onClick={() => copyText('https://site-10398.shoplnk.cn?v=1.33')}>发送</Button>
|
||||
</View>
|
||||
<View className='flex justify-center my-5'>
|
||||
<Button type='info' size='large' className='w-full rounded-lg p-2'>确认修改</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default Register
|
||||
4
src/passport/login.config.ts
Normal file
4
src/passport/login.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '登录',
|
||||
navigationBarTextStyle: 'black'
|
||||
})
|
||||
377
src/passport/login.scss
Normal file
377
src/passport/login.scss
Normal file
@@ -0,0 +1,377 @@
|
||||
// 页面容器
|
||||
.page-login {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease-in-out;
|
||||
|
||||
&--show {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 背景装饰
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg__gradient {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
right: -50%;
|
||||
bottom: -50%;
|
||||
background: radial-gradient(circle at 30% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 70% 80%, rgba(255, 255, 255, 0.08) 0%, transparent 50%);
|
||||
animation: gradientMove 15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientMove {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -30px) rotate(120deg);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) rotate(240deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-bg__circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.1;
|
||||
background: #ffffff;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.login-bg__circle--1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
top: -150px;
|
||||
right: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.login-bg__circle--2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
bottom: 10%;
|
||||
left: -100px;
|
||||
animation-delay: -7s;
|
||||
}
|
||||
|
||||
.login-bg__circle--3 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
top: 40%;
|
||||
right: -50px;
|
||||
animation-delay: -14s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -30px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.login-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 0 50px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// 头部 Logo 和标题
|
||||
.login-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 120px;
|
||||
margin-bottom: 60px;
|
||||
animation: slideDown 0.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 40px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: logoFloat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes logoFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.login-logo__image {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
margin-bottom: 16px;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
animation: fadeIn 1s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 28px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
animation: fadeIn 1s ease-out 0.5s both;
|
||||
}
|
||||
|
||||
// 登录主体区域
|
||||
.login-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
animation: slideUp 0.8s ease-out 0.4s both;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
margin-top: -40px;
|
||||
}
|
||||
|
||||
.login-methods__tip {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
// 登录按钮
|
||||
.login-btn {
|
||||
height: 96px;
|
||||
width: 100%;
|
||||
border-radius: 48px;
|
||||
border: none;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-btn__icon {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.login-btn__text {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
// 分割线
|
||||
.login-methods__divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
// 特性列表
|
||||
.login-features {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 40px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
font-size: 22px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
// 非微信小程序环境提示
|
||||
.login-non-weapp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 40px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.non-weapp-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.non-weapp-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.non-weapp-desc {
|
||||
font-size: 26px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// 底部协议区域
|
||||
.login-footer {
|
||||
padding: 30px 0 50px;
|
||||
}
|
||||
|
||||
.login-agreement {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// 自定义勾选框
|
||||
.login-agreement__checkbox {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.5);
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&--checked {
|
||||
background: #07c160;
|
||||
border-color: #07c160;
|
||||
}
|
||||
}
|
||||
|
||||
.login-agreement__check {
|
||||
color: #ffffff;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-agreement__text {
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
339
src/passport/login.tsx
Normal file
339
src/passport/login.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Image, Text, Button } from '@tarojs/components'
|
||||
import { TenantId } from '@/config/app'
|
||||
import { getWxOpenId } from '@/api/layout'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import {
|
||||
checkAndHandleInviteRelation,
|
||||
hasPendingInvite,
|
||||
parseInviteParams,
|
||||
saveInviteParams,
|
||||
trackInviteSource,
|
||||
} from '@/utils/invite'
|
||||
import logoImg from '@/assets/logo.png'
|
||||
import './login.scss'
|
||||
|
||||
interface GetPhoneNumberDetail {
|
||||
code?: string
|
||||
encryptedData?: string
|
||||
iv?: string
|
||||
errMsg?: string
|
||||
}
|
||||
|
||||
interface GetPhoneNumberEvent {
|
||||
detail: GetPhoneNumberDetail
|
||||
}
|
||||
|
||||
/** 同步判断当前是否是微信小程序环境 */
|
||||
function detectIsWeapp(): boolean {
|
||||
try {
|
||||
return Taro.getEnv() === Taro.ENV_TYPE.WEAPP
|
||||
} catch {
|
||||
return process.env.TARO_ENV === 'weapp'
|
||||
}
|
||||
}
|
||||
|
||||
const IS_WEAPP = detectIsWeapp()
|
||||
|
||||
/** 小程序登录获取 code */
|
||||
async function getWeappLoginCode(): Promise<string | undefined> {
|
||||
try {
|
||||
const res = await new Promise<{ code?: string }>((resolve, reject) => {
|
||||
Taro.login({ success: (r) => resolve(r), fail: reject })
|
||||
})
|
||||
return res?.code
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 确保微信 openid 已保存到服务端 */
|
||||
async function ensureWxOpenIdSaved() {
|
||||
try {
|
||||
if (Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) return
|
||||
} catch {
|
||||
if (process.env.TARO_ENV !== 'weapp') return
|
||||
}
|
||||
|
||||
const code = await getWeappLoginCode()
|
||||
if (!code) return
|
||||
|
||||
try {
|
||||
await getWxOpenId({ code })
|
||||
const freshUser = await getUserInfo()
|
||||
if (freshUser) {
|
||||
const token = Taro.getStorageSync('access_token')
|
||||
saveStorageByLoginUser(token, freshUser)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('登录后绑定 openid 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取用户信息 */
|
||||
async function fetchUserInfo(token: string) {
|
||||
try {
|
||||
const serverUrl = 'https://server.websoft.top'
|
||||
const res: any = await Taro.request({
|
||||
url: `${serverUrl}/api/auth/user`,
|
||||
method: 'GET',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
TenantId: String(TenantId),
|
||||
},
|
||||
})
|
||||
|
||||
if (res.data?.code === 0 && res.data?.data) {
|
||||
return res.data.data
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
console.error('获取用户信息失败:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const Login = () => {
|
||||
const [isAgree, setIsAgree] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
|
||||
const router = Taro.getCurrentInstance().router
|
||||
const isWeapp = IS_WEAPP
|
||||
|
||||
/** 页面加载动画 */
|
||||
useEffect(() => {
|
||||
setTimeout(() => setShowContent(true), 100)
|
||||
}, [])
|
||||
|
||||
/** 解析 redirect 参数 */
|
||||
const redirectUrl = (() => {
|
||||
const raw = (router?.params as Record<string, string> | undefined)?.redirect
|
||||
if (!raw) return ''
|
||||
try {
|
||||
const decoded = decodeURIComponent(raw)
|
||||
return decoded.startsWith('/') ? decoded : `/${decoded}`
|
||||
} catch {
|
||||
return raw.startsWith('/') ? raw : `/${raw}`
|
||||
}
|
||||
})()
|
||||
|
||||
/** 处理邀请参数 */
|
||||
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])
|
||||
|
||||
/** 登录成功后跳转 */
|
||||
const navigateAfterLogin = async () => {
|
||||
if (!redirectUrl) {
|
||||
await Taro.reLaunch({ url: '/pages/index/index' })
|
||||
return
|
||||
}
|
||||
|
||||
const tabBarUrls = [
|
||||
'/pages/index/index',
|
||||
'/pages/shop/index',
|
||||
'/pages/points/index',
|
||||
'/pages/user/user',
|
||||
]
|
||||
const pure = redirectUrl.split('?')[0]
|
||||
|
||||
if (tabBarUrls.includes(pure)) {
|
||||
await Taro.switchTab({ url: pure })
|
||||
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, errMsg } = detail || {}
|
||||
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
|
||||
Taro.showToast({ title: '未授权手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const inviteParams = parseInviteParams({ query: router?.params })
|
||||
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
|
||||
|
||||
const serverUrl = 'https://server.websoft.top'
|
||||
const res: any = await Taro.request({
|
||||
url: `${serverUrl}/api/wx-login/loginByMpWxPhone`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: phoneCode,
|
||||
notVerifyPhone: true,
|
||||
refereeId,
|
||||
sceneType: 'save_referee',
|
||||
tenantId: Number(TenantId),
|
||||
},
|
||||
header: { 'content-type': 'application/json', TenantId: String(TenantId) },
|
||||
})
|
||||
|
||||
if (res.data?.code === 0 && res.data?.data?.access_token) {
|
||||
const token = res.data.data.access_token
|
||||
let user = res.data.data.user
|
||||
|
||||
// 获取最新的用户信息
|
||||
const freshUserInfo = await fetchUserInfo(token)
|
||||
if (freshUserInfo) {
|
||||
user = freshUserInfo
|
||||
}
|
||||
|
||||
saveStorageByLoginUser(token, user)
|
||||
|
||||
// 绑定 openid + 处理邀请关系
|
||||
await ensureWxOpenIdSaved()
|
||||
if (hasPendingInvite()) {
|
||||
try { await checkAndHandleInviteRelation() } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => navigateAfterLogin(), 800)
|
||||
} else {
|
||||
Taro.showToast({ title: res.data?.message || '登录失败', icon: 'none' })
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('微信登录失败:', e)
|
||||
Taro.showToast({ title: e?.message || '登录失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`page-login ${showContent ? 'page-login--show' : ''}`}>
|
||||
{/* 渐变背景 */}
|
||||
<View className='login-bg'>
|
||||
<View className='login-bg__gradient' />
|
||||
<View className='login-bg__circle login-bg__circle--1' />
|
||||
<View className='login-bg__circle login-bg__circle--2' />
|
||||
<View className='login-bg__circle login-bg__circle--3' />
|
||||
</View>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<View className='login-content'>
|
||||
{/* Logo 和标题 */}
|
||||
<View className='login-header'>
|
||||
<View className='login-logo'>
|
||||
<Image
|
||||
className='login-logo__image'
|
||||
src={logoImg}
|
||||
mode='aspectFit'
|
||||
/>
|
||||
</View>
|
||||
<Text className='login-title'>欢迎来到鑫龙家电</Text>
|
||||
<Text className='login-subtitle'>登录后享受更多精彩功能</Text>
|
||||
</View>
|
||||
|
||||
{/* 登录按钮区域 */}
|
||||
<View className='login-body'>
|
||||
{isWeapp && (
|
||||
<View className='login-methods'>
|
||||
|
||||
<Button
|
||||
className='login-btn'
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #07c160, #06ad56)',
|
||||
border: 'none',
|
||||
borderRadius: '48px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
opacity: (!isAgree || loading) ? 0.5 : 1,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
openType='getPhoneNumber'
|
||||
onGetPhoneNumber={handleGetPhoneNumber}
|
||||
disabled={!isAgree || loading}
|
||||
loading={loading}
|
||||
>
|
||||
手机号快捷登录
|
||||
</Button>
|
||||
|
||||
<View className='login-methods__tip text-gray-200'>
|
||||
<Text>点击上方按钮,快速登录</Text>
|
||||
</View>
|
||||
|
||||
<View className='login-methods__divider'>
|
||||
<View className='divider-line' />
|
||||
<Text className='divider-text'>安全快捷</Text>
|
||||
<View className='divider-line' />
|
||||
</View>
|
||||
|
||||
<View className='login-features'>
|
||||
<View className='feature-item'>
|
||||
<Text className='feature-icon'>🔒</Text>
|
||||
<Text className='feature-text'>安全可靠</Text>
|
||||
</View>
|
||||
<View className='feature-item'>
|
||||
<Text className='feature-icon'>⚡</Text>
|
||||
<Text className='feature-text'>一键登录</Text>
|
||||
</View>
|
||||
<View className='feature-item'>
|
||||
<Text className='feature-icon'>🎁</Text>
|
||||
<Text className='feature-text'>专属福利</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 非微信小程序环境提示 */}
|
||||
{!isWeapp && (
|
||||
<View className='login-non-weapp'>
|
||||
<View className='non-weapp-icon'>💻</View>
|
||||
<Text className='non-weapp-title'>请在微信小程序中打开</Text>
|
||||
<Text className='non-weapp-desc'>当前环境不支持手机号快捷登录,请在微信小程序中使用完整功能</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 协议勾选 - 自定义实现 */}
|
||||
<View className='login-footer'>
|
||||
<View className='login-agreement' onClick={() => setIsAgree(!isAgree)}>
|
||||
<View className={`login-agreement__checkbox ${isAgree ? 'login-agreement__checkbox--checked' : ''}`}>
|
||||
{isAgree && <Text className='login-agreement__check'>✓</Text>}
|
||||
</View>
|
||||
<Text className='login-agreement__text'>
|
||||
{'我已阅读并同意'}
|
||||
<Text className='link' onClick={(e) => { e.stopPropagation(); Taro.navigateTo({ url: '/passport/agreement?type=terms' }) }}>
|
||||
{'《用户协议》'}
|
||||
</Text>
|
||||
{'和'}
|
||||
<Text className='link' onClick={(e) => { e.stopPropagation(); Taro.navigateTo({ url: '/passport/agreement?type=privacy' }) }}>
|
||||
{'《隐私政策》'}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
5
src/passport/qr-confirm/index.config.ts
Normal file
5
src/passport/qr-confirm/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
navigationBarTitleText: '确认登录',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
}
|
||||
239
src/passport/qr-confirm/index.tsx
Normal file
239
src/passport/qr-confirm/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { Button, Loading, Card } from '@nutui/nutui-react-taro';
|
||||
import { Success, Failure, Tips, User } from '@nutui/icons-react-taro';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { confirmQRLogin } from '@/api/passport/qr-login';
|
||||
import { useUser } from '@/hooks/useUser';
|
||||
|
||||
/**
|
||||
* 扫码登录确认页面
|
||||
* 用于处理从二维码跳转过来的登录确认
|
||||
*/
|
||||
const QRConfirmPage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { user, getDisplayName } = useUser();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [token, setToken] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// 从URL参数中获取token
|
||||
const { qrCodeKey, token: urlToken } = router.params;
|
||||
const loginToken = qrCodeKey || urlToken;
|
||||
|
||||
if (loginToken) {
|
||||
setToken(loginToken);
|
||||
} else {
|
||||
setError('无效的登录链接');
|
||||
}
|
||||
}, [router.params]);
|
||||
|
||||
// 确认登录
|
||||
const handleConfirmLogin = async () => {
|
||||
if (!token) {
|
||||
setError('缺少登录token');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user?.userId) {
|
||||
setError('请先登录小程序');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const result = await confirmQRLogin({
|
||||
token,
|
||||
userId: user.userId,
|
||||
platform: 'wechat',
|
||||
wechatInfo: {
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setConfirmed(true);
|
||||
Taro.showToast({
|
||||
title: '登录确认成功',
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
|
||||
// 3秒后自动返回
|
||||
setTimeout(() => {
|
||||
Taro.navigateBack();
|
||||
}, 3000);
|
||||
} else {
|
||||
setError(result.message || '登录确认失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '登录确认失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消登录
|
||||
const handleCancel = () => {
|
||||
Taro.navigateBack();
|
||||
};
|
||||
|
||||
// 重试
|
||||
const handleRetry = () => {
|
||||
setError('');
|
||||
setConfirmed(false);
|
||||
handleConfirmLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='qr-confirm-page min-h-screen bg-gray-50'>
|
||||
<View className='p-4'>
|
||||
{/* 主要内容卡片 */}
|
||||
<Card className='bg-white rounded-lg shadow-sm'>
|
||||
<View className='p-6 text-center'>
|
||||
{/* 图标 */}
|
||||
<View className='mb-6'>
|
||||
{loading ? (
|
||||
<View className='w-16 h-16 mx-auto flex items-center justify-center'>
|
||||
<Loading className='text-blue-500' />
|
||||
</View>
|
||||
) : confirmed ? (
|
||||
<View className='w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center'>
|
||||
<Success className='text-green-500' />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className='w-16 h-16 mx-auto bg-red-100 rounded-full flex items-center justify-center'>
|
||||
<Failure className='text-red-500' />
|
||||
</View>
|
||||
) : (
|
||||
<View className='w-16 h-16 mx-auto bg-blue-100 rounded-full flex items-center justify-center'>
|
||||
<User size='32' className='text-blue-500' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text className='text-xl font-bold text-gray-800 mb-2 block'>
|
||||
{loading ? '正在确认登录...' :
|
||||
confirmed ? '登录确认成功' :
|
||||
error ? '登录确认失败' : '确认登录'}
|
||||
</Text>
|
||||
|
||||
{/* 描述 */}
|
||||
<Text className='text-gray-600 mb-6 block'>
|
||||
{loading ? '请稍候,正在为您确认登录' :
|
||||
confirmed ? '您已成功确认登录,网页端将自动登录' :
|
||||
error ? error :
|
||||
`确认使用 ${getDisplayName()} 登录网页端?`}
|
||||
</Text>
|
||||
|
||||
{/* 用户信息 */}
|
||||
{!loading && !confirmed && !error && user && (
|
||||
<View className='bg-gray-50 rounded-lg p-4 mb-6'>
|
||||
<View className='flex items-center justify-center'>
|
||||
<View className='w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-3'>
|
||||
<User className='text-blue-500' size='20' />
|
||||
</View>
|
||||
<View className='text-left'>
|
||||
<Text className='text-sm font-medium text-gray-800 block'>
|
||||
{user.nickname || user.username || '用户'}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-500 block'>
|
||||
ID: {user.userId}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex flex-col' style={{ gap: '12px' }}>
|
||||
{loading ? (
|
||||
<Button
|
||||
type='default'
|
||||
size='large'
|
||||
disabled
|
||||
className='w-full'
|
||||
>
|
||||
确认中...
|
||||
</Button>
|
||||
) : confirmed ? (
|
||||
<Button
|
||||
type='success'
|
||||
size='large'
|
||||
onClick={handleCancel}
|
||||
className='w-full'
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
) : error ? (
|
||||
<View className='flex flex-col' style={{ gap: '8px' }}>
|
||||
<Button
|
||||
type='primary'
|
||||
size='large'
|
||||
onClick={handleRetry}
|
||||
className='w-full'
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
<Button
|
||||
type='default'
|
||||
size='large'
|
||||
onClick={handleCancel}
|
||||
className='w-full'
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<View className='mt-3'>
|
||||
<Button
|
||||
type='primary'
|
||||
size='large'
|
||||
onClick={handleConfirmLogin}
|
||||
className='w-full mb-2'
|
||||
disabled={!token || !user?.userId}
|
||||
>
|
||||
确认登录
|
||||
</Button>
|
||||
<Button
|
||||
type='default'
|
||||
size='large'
|
||||
onClick={handleCancel}
|
||||
className='w-full'
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<Card className='bg-yellow-50 border border-yellow-200 rounded-lg mt-4'>
|
||||
<View className='p-4'>
|
||||
<View className='flex items-start'>
|
||||
<Tips className='text-yellow-600 mr-2 mt-1' size='16' />
|
||||
<View>
|
||||
<Text className='text-sm font-medium text-yellow-800 mb-1 block'>
|
||||
安全提示
|
||||
</Text>
|
||||
<Text className='text-xs text-yellow-700 block'>
|
||||
请确认这是您本人的登录操作。如果不是,请点击取消并检查账户安全。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRConfirmPage;
|
||||
5
src/passport/qr-login/index.config.ts
Normal file
5
src/passport/qr-login/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
navigationBarTitleText: '扫码登录',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
}
|
||||
191
src/passport/qr-login/index.tsx
Normal file
191
src/passport/qr-login/index.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { Card, Divider, Button } from '@nutui/nutui-react-taro';
|
||||
import { Scan, Success, Failure, Tips } from '@nutui/icons-react-taro';
|
||||
import Taro from '@tarojs/taro';
|
||||
import QRLoginScanner from '@/components/QRLoginScanner';
|
||||
import { useUser } from '@/hooks/useUser';
|
||||
|
||||
/**
|
||||
* 扫码登录页面
|
||||
*/
|
||||
const QRLoginPage: React.FC = () => {
|
||||
const [loginHistory, setLoginHistory] = useState<any[]>([]);
|
||||
const { getDisplayName } = useUser();
|
||||
|
||||
// 处理扫码成功
|
||||
const handleScanSuccess = (result: any) => {
|
||||
// 添加到登录历史
|
||||
const newRecord = {
|
||||
id: Date.now(),
|
||||
time: new Date().toLocaleString(),
|
||||
userInfo: result.userInfo,
|
||||
success: true
|
||||
};
|
||||
setLoginHistory(prev => [newRecord, ...prev.slice(0, 4)]); // 只保留最近5条记录
|
||||
|
||||
// 显示成功提示
|
||||
Taro.showToast({
|
||||
title: '登录确认成功',
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
};
|
||||
|
||||
// 处理扫码失败
|
||||
const handleScanError = (error: string) => {
|
||||
console.error('扫码登录失败:', error);
|
||||
|
||||
// 添加到登录历史
|
||||
const newRecord = {
|
||||
id: Date.now(),
|
||||
time: new Date().toLocaleString(),
|
||||
error,
|
||||
success: false
|
||||
};
|
||||
setLoginHistory(prev => [newRecord, ...prev.slice(0, 4)]);
|
||||
};
|
||||
|
||||
// 返回上一页
|
||||
// const handleBack = () => {
|
||||
// Taro.navigateBack();
|
||||
// };
|
||||
|
||||
// 清除历史记录
|
||||
const clearHistory = () => {
|
||||
setLoginHistory([]);
|
||||
Taro.showToast({
|
||||
title: '已清除历史记录',
|
||||
icon: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='qr-login-page min-h-screen bg-gray-50'>
|
||||
{/* 导航栏 */}
|
||||
{/*<NavBar*/}
|
||||
{/* title="扫码登录"*/}
|
||||
{/* leftShow*/}
|
||||
{/* onBackClick={handleBack}*/}
|
||||
{/* leftText={<ArrowLeft />}*/}
|
||||
{/* className="bg-white"*/}
|
||||
{/*/>*/}
|
||||
|
||||
{/* 主要内容 */}
|
||||
<View className='p-4'>
|
||||
{/* 用户信息卡片 */}
|
||||
<Card className='bg-white rounded-lg shadow-sm'>
|
||||
<View className='p-4'>
|
||||
<View className='flex items-center mb-4'>
|
||||
<View className='w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-3'>
|
||||
<Scan className='text-blue-500' size='24' />
|
||||
</View>
|
||||
<View>
|
||||
<Text className='text-lg font-bold text-gray-800'>
|
||||
{getDisplayName()}
|
||||
</Text>
|
||||
<Text className='text-sm text-gray-500'>
|
||||
使用小程序扫码快速登录网页端
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 扫码登录组件 */}
|
||||
<QRLoginScanner
|
||||
onSuccess={handleScanSuccess}
|
||||
onError={handleScanError}
|
||||
buttonText='开始扫码登录'
|
||||
showStatus
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<Card className='bg-white rounded-lg shadow-sm'>
|
||||
<View className='p-4'>
|
||||
<View className='flex items-center mb-3'>
|
||||
<Tips className='text-orange-500 mr-2' />
|
||||
<Text className='font-medium text-gray-800'>使用说明</Text>
|
||||
</View>
|
||||
<View className='text-sm text-gray-600'>
|
||||
<Text className='block'>1. 在电脑或其他设备上打开网页端登录页面</Text>
|
||||
<Text className='block'>2. 点击"扫码登录"按钮,显示登录二维码</Text>
|
||||
<Text className='block'>3. 使用此功能扫描二维码即可快速登录</Text>
|
||||
<Text className='block'>4. 扫码成功后,网页端将自动完成登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 登录历史 */}
|
||||
{loginHistory.length > 0 && (
|
||||
<Card className='bg-white rounded-lg shadow-sm'>
|
||||
<View className='p-4'>
|
||||
<View className='flex items-center justify-between mb-3'>
|
||||
<Text className='font-medium text-gray-800'>最近登录记录</Text>
|
||||
<Button
|
||||
size='small'
|
||||
type='default'
|
||||
onClick={clearHistory}
|
||||
className='text-xs'
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
{loginHistory.map((record, index) => (
|
||||
<View key={record.id}>
|
||||
<View className='flex items-center justify-between'>
|
||||
<View className='flex items-center'>
|
||||
{record.success ? (
|
||||
<Success className='text-green-500 mr-2' size='16' />
|
||||
) : (
|
||||
<Failure className='text-red-500 mr-2' size='16' />
|
||||
)}
|
||||
<View>
|
||||
<Text className='text-sm text-gray-800'>
|
||||
{record.success ? '登录成功' : '登录失败'}
|
||||
</Text>
|
||||
{record.error && (
|
||||
<Text className='text-xs text-red-500'>
|
||||
{record.error}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-500'>
|
||||
{record.time}
|
||||
</Text>
|
||||
</View>
|
||||
{index < loginHistory.length - 1 && (
|
||||
<Divider className='my-2' />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 安全提示 */}
|
||||
<Card className='bg-yellow-50 border border-yellow-200 rounded-lg'>
|
||||
<View className='p-4'>
|
||||
<View className='flex items-start'>
|
||||
<Tips className='text-yellow-600 mr-2 mt-1' size='16' />
|
||||
<View>
|
||||
<Text className='text-sm font-medium text-yellow-800 mb-1'>
|
||||
安全提示
|
||||
</Text>
|
||||
<Text className='text-xs text-yellow-700'>
|
||||
请确保只扫描来自官方网站的登录二维码,避免扫描来源不明的二维码,保护账户安全。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRLoginPage;
|
||||
5
src/passport/register.config.ts
Normal file
5
src/passport/register.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '注册/登录',
|
||||
navigationBarTextStyle: 'black'
|
||||
})
|
||||
|
||||
295
src/passport/register.tsx
Normal file
295
src/passport/register.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Button, Checkbox } 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<string | undefined> {
|
||||
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(() => {
|
||||
// register 页面不是 tabBar 页面,无需调用 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://shop-api.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<string, any> = {}
|
||||
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 (
|
||||
<>
|
||||
<View className='flex flex-col justify-center px-5 pt-5'>
|
||||
<View className='text-3xl text-center py-5 font-normal my-6'>注册/登录</View>
|
||||
|
||||
<View className='flex flex-col gap-3 bg-green-600 py-1' style={{ borderRadius: '100px' }}>
|
||||
<Button
|
||||
type='primary'
|
||||
fill='solid'
|
||||
color='#07c160'
|
||||
block
|
||||
loading={loading}
|
||||
disabled={!isAgree || loading}
|
||||
open-type='getPhoneNumber'
|
||||
onGetPhoneNumber={handleGetPhoneNumber}
|
||||
>
|
||||
手机号一键注册/登录
|
||||
</Button>
|
||||
|
||||
{!isWeapp && (
|
||||
<Button type='default' block disabled={!isAgree || loading} onClick={goSmsLogin}>
|
||||
短信验证码注册/登录
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='mt-6 flex text-sm items-center px-1'>
|
||||
<Checkbox checked={isAgree} onChange={(val) => setIsAgree(val)} />
|
||||
<Text className='text-gray-400' onClick={() => setIsAgree(!isAgree)}>
|
||||
勾选表示您已阅读并同意
|
||||
</Text>
|
||||
<Text
|
||||
onClick={() => Taro.navigateTo({ url: '/passport/agreement' })}
|
||||
className='text-blue-600'
|
||||
>
|
||||
《服务协议及隐私政策》
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
||||
4
src/passport/setting.config.ts
Normal file
4
src/passport/setting.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '服务配置',
|
||||
navigationBarTextStyle: 'black'
|
||||
})
|
||||
82
src/passport/setting.tsx
Normal file
82
src/passport/setting.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View } from '@tarojs/components'
|
||||
import {Input, Button,Form} from '@nutui/nutui-react-taro'
|
||||
|
||||
const Setting = () => {
|
||||
const [FormData, setFormData] = useState<any>(
|
||||
{
|
||||
domain: undefined
|
||||
}
|
||||
)
|
||||
|
||||
// 提交表单
|
||||
const submitSucceed = (values: any) => {
|
||||
if(values.domain){
|
||||
Taro.setStorageSync('ServerUrl',values.domain)
|
||||
setFormData({
|
||||
domain: values.domain
|
||||
})
|
||||
Taro.showToast({
|
||||
title: '保存成功',
|
||||
icon: 'success'
|
||||
});
|
||||
setTimeout(() => {
|
||||
Taro.navigateBack()
|
||||
},500)
|
||||
}
|
||||
}
|
||||
|
||||
const submitFailed = (error: any) => {
|
||||
// Taro.showToast({ title: error[0].message, icon: 'error' })
|
||||
}
|
||||
const reload = () => {
|
||||
// setting 页面不是 tabBar 页面,无需调用 hideTabBar
|
||||
if (Taro.getStorageSync('ServerUrl')) {
|
||||
setFormData({
|
||||
domain: Taro.getStorageSync('ServerUrl')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
divider
|
||||
initialValues={FormData}
|
||||
labelPosition='left'
|
||||
onFinish={(values) => submitSucceed(values)}
|
||||
onFinishFailed={(errors) => submitFailed(errors)}
|
||||
footer={
|
||||
<View
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<Button nativeType='submit' block type='info' size='large'>
|
||||
保存
|
||||
</Button>
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col justify-center pt-3'>
|
||||
<View className='text-sm py-1 px-4'>服务域名</View>
|
||||
<Form.Item
|
||||
name='domain'
|
||||
initialValue={FormData.domain}
|
||||
rules={[{message: '请输入服务域名'}]}
|
||||
>
|
||||
<Input placeholder='https://domain.com/api' type='text' style={{backgroundColor: '#f5f5f5', borderRadius: '8px', padding: '5px 10px'}} />
|
||||
</Form.Item>
|
||||
</View>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default Setting
|
||||
4
src/passport/sms-login.config.ts
Normal file
4
src/passport/sms-login.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '验证码登录',
|
||||
navigationBarTextStyle: 'black'
|
||||
})
|
||||
269
src/passport/sms-login.tsx
Normal file
269
src/passport/sms-login.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View } from '@tarojs/components'
|
||||
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, parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite";
|
||||
|
||||
const SmsLogin = () => {
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [sendingCode, setSendingCode] = useState<boolean>(false)
|
||||
const [countdown, setCountdown] = useState<number>(0)
|
||||
const [formData, setFormData] = useState<LoginParam>({
|
||||
phone: '',
|
||||
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 = () => {
|
||||
// sms-login 页面不是 tabBar 页面,无需调用 hideTabBar
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
if (countdown > 0) {
|
||||
timer = setTimeout(() => {
|
||||
setCountdown(countdown - 1)
|
||||
}, 1000)
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
// 验证手机号格式
|
||||
const validatePhone = (phone: string): boolean => {
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
return phoneRegex.test(phone)
|
||||
}
|
||||
|
||||
// 发送短信验证码
|
||||
const handleSendCode = async () => {
|
||||
if (!formData.phone) {
|
||||
Taro.showToast({
|
||||
title: '请输入手机号码',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!validatePhone(formData.phone)) {
|
||||
Taro.showToast({
|
||||
title: '请输入正确的手机号码',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (sendingCode || countdown > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSendingCode(true)
|
||||
await sendSmsCaptcha({ phone: formData.phone })
|
||||
|
||||
Taro.showToast({
|
||||
title: '验证码已发送',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 开始60秒倒计时
|
||||
setCountdown(60)
|
||||
} catch (error: any) {
|
||||
Taro.showToast({
|
||||
title: error.message || '发送失败',
|
||||
icon: 'error'
|
||||
})
|
||||
} finally {
|
||||
setSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
// 防止重复提交
|
||||
if (loading) {
|
||||
return
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
if (!formData.phone) {
|
||||
Taro.showToast({
|
||||
title: '请输入手机号码',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!validatePhone(formData.phone)) {
|
||||
Taro.showToast({
|
||||
title: '请输入正确的手机号码',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.code) {
|
||||
Taro.showToast({
|
||||
title: '请输入验证码',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.code.length !== 6) {
|
||||
Taro.showToast({
|
||||
title: '请输入6位验证码',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
await loginBySms({
|
||||
phone: formData.phone,
|
||||
code: formData.code
|
||||
})
|
||||
|
||||
// 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定
|
||||
if (hasPendingInvite()) {
|
||||
try {
|
||||
await checkAndHandleInviteRelation()
|
||||
} catch (e) {
|
||||
console.error('短信登录后处理邀请关系失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
Taro.showToast({
|
||||
title: '登录成功',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 延迟跳转到首页
|
||||
setTimeout(() => {
|
||||
navigateAfterLogin().catch((e) => {
|
||||
console.error('短信登录后跳转失败:', e)
|
||||
Taro.reLaunch({ url: '/pages/index/index' })
|
||||
})
|
||||
}, 1500)
|
||||
|
||||
} catch (error: any) {
|
||||
Taro.showToast({
|
||||
title: error.message || '登录失败',
|
||||
icon: 'error'
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className='flex flex-col justify-center px-5 pt-3'>
|
||||
<View className='flex flex-col justify-between items-center my-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='请输入手机号码'
|
||||
maxLength={11}
|
||||
value={formData.phone}
|
||||
onChange={(value) => setFormData({...formData, phone: value})}
|
||||
style={{backgroundColor: '#ffffff', borderRadius: '8px'}}
|
||||
/>
|
||||
</View>
|
||||
<View className='flex justify-between items-center bg-white rounded-lg my-2 pr-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='请输入6位验证码'
|
||||
maxLength={6}
|
||||
value={formData.code}
|
||||
onChange={(value) => setFormData({...formData, code: value})}
|
||||
style={{ backgroundColor: '#ffffff', borderRadius: '8px'}}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
type={countdown > 0 ? "default" : "primary"}
|
||||
loading={sendingCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
onClick={handleSendCode}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : sendingCode ? '发送中...' : '获取验证码'}
|
||||
</Button>
|
||||
</View>
|
||||
<View className='flex justify-center my-5'>
|
||||
<Button
|
||||
type='info'
|
||||
size='large'
|
||||
className='w-full rounded-lg p-2'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default SmsLogin
|
||||
4
src/passport/unified-qr/index.config.ts
Normal file
4
src/passport/unified-qr/index.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
navigationBarTitleText: '统一扫码',
|
||||
navigationBarTextStyle: 'black'
|
||||
}
|
||||
177
src/passport/unified-qr/index.tsx
Normal file
177
src/passport/unified-qr/index.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Card, Button, Tag } from '@nutui/nutui-react-taro'
|
||||
import { Scan, Success, Failure, ArrowLeft } from '@nutui/icons-react-taro'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { confirmWechatQRLogin, parseQRContent } from '@/api/passport/qr-login'
|
||||
|
||||
enum ScanType {
|
||||
LOGIN = 'login',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
interface ScanRecord {
|
||||
id: number
|
||||
time: string
|
||||
success: boolean
|
||||
type: ScanType
|
||||
message: string
|
||||
raw?: string
|
||||
}
|
||||
|
||||
const UnifiedQRPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [lastRecord, setLastRecord] = useState<ScanRecord | null>(null)
|
||||
const [scanHistory, setScanHistory] = useState<ScanRecord[]>([])
|
||||
|
||||
const pushHistory = (record: ScanRecord) => {
|
||||
setLastRecord(record)
|
||||
setScanHistory((prev) => [record, ...prev.slice(0, 4)])
|
||||
}
|
||||
|
||||
const handleGoBack = () => {
|
||||
Taro.navigateBack()
|
||||
}
|
||||
|
||||
const buildRecord = (success: boolean, type: ScanType, message: string, raw?: string): ScanRecord => ({
|
||||
id: Date.now(),
|
||||
time: new Date().toLocaleString(),
|
||||
success,
|
||||
type,
|
||||
message,
|
||||
raw,
|
||||
})
|
||||
|
||||
const handleLoginScan = async (raw: string) => {
|
||||
const token = parseQRContent(raw)
|
||||
const userId = Number(Taro.getStorageSync('UserId') || 0)
|
||||
|
||||
if (!token) {
|
||||
throw new Error('未识别到可登录的二维码内容')
|
||||
}
|
||||
if (!userId) {
|
||||
throw new Error('请先登录小程序账号后再扫码')
|
||||
}
|
||||
|
||||
const result = await confirmWechatQRLogin(token, userId)
|
||||
if (result?.status !== 'confirmed') {
|
||||
throw new Error(result?.message || '二维码登录确认失败')
|
||||
}
|
||||
|
||||
return '登录确认成功'
|
||||
}
|
||||
|
||||
const handleStartScan = async () => {
|
||||
if (loading) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await Taro.scanCode({ scanType: ['qrCode'] })
|
||||
const raw = (res.result || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error('扫码结果为空')
|
||||
}
|
||||
|
||||
const token = parseQRContent(raw)
|
||||
if (token) {
|
||||
const message = await handleLoginScan(raw)
|
||||
pushHistory(buildRecord(true, ScanType.LOGIN, message, raw))
|
||||
Taro.showToast({ title: message, icon: 'success' })
|
||||
return
|
||||
}
|
||||
|
||||
const unsupportedMessage = '当前版本仅支持登录二维码,核销能力待补充'
|
||||
pushHistory(buildRecord(false, ScanType.UNKNOWN, unsupportedMessage, raw))
|
||||
Taro.showToast({ title: unsupportedMessage, icon: 'none' })
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error?.errMsg?.includes('cancel')
|
||||
? '已取消扫码'
|
||||
: error?.message || '扫码失败'
|
||||
|
||||
pushHistory(buildRecord(false, ScanType.UNKNOWN, message))
|
||||
if (message !== '已取消扫码') {
|
||||
Taro.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeTag = (type: ScanType) => {
|
||||
if (type === ScanType.LOGIN) {
|
||||
return <Tag type='success'>登录</Tag>
|
||||
}
|
||||
return <Tag type='primary'>其他</Tag>
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='unified-qr-page min-h-screen bg-gray-50'>
|
||||
<View className='bg-white px-4 py-3 border-b border-gray-100 flex items-center'>
|
||||
<ArrowLeft className='text-gray-600 mr-3' size='20' onClick={handleGoBack} />
|
||||
<View className='flex-1'>
|
||||
<Text className='text-lg font-bold'>统一扫码</Text>
|
||||
<Text className='text-sm text-gray-600 block'>先恢复登录扫码,后续再补齐核销流程</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Card className='m-4'>
|
||||
<View className='text-center py-6'>
|
||||
<Scan className='text-blue-500 mx-auto mb-4' size='48' />
|
||||
<Text className='text-lg font-medium text-gray-800 mb-2 block'>扫码入口</Text>
|
||||
<Text className='text-gray-600 mb-6 block'>当前可识别网页登录二维码,其他码先给出明确提示</Text>
|
||||
<Button
|
||||
type='primary'
|
||||
size='large'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleStartScan}
|
||||
className='w-full'
|
||||
>
|
||||
{loading ? '扫码中...' : '开始扫码'}
|
||||
</Button>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{lastRecord && (
|
||||
<Card className='mx-4 mb-4'>
|
||||
<View className='flex items-start'>
|
||||
<View className='mr-3 mt-1'>
|
||||
{lastRecord.success ? (
|
||||
<Success className='text-green-500' size='18' />
|
||||
) : (
|
||||
<Failure className='text-red-500' size='18' />
|
||||
)}
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<View className='flex items-center justify-between mb-2'>
|
||||
<Text className='font-medium'>最近一次结果</Text>
|
||||
{getTypeTag(lastRecord.type)}
|
||||
</View>
|
||||
<Text className='text-sm text-gray-800 block mb-1'>{lastRecord.message}</Text>
|
||||
<Text className='text-xs text-gray-500 block'>{lastRecord.time}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{scanHistory.length > 0 && (
|
||||
<Card className='mx-4 mb-4'>
|
||||
<Text className='font-medium block mb-3'>扫码记录</Text>
|
||||
{scanHistory.map((item) => (
|
||||
<View key={item.id} className='py-2 border-b border-gray-100' style={item === scanHistory[scanHistory.length - 1] ? { borderBottom: 'none' } : undefined}>
|
||||
<View className='flex items-center justify-between mb-1'>
|
||||
<Text className='text-sm text-gray-800'>{item.message}</Text>
|
||||
{getTypeTag(item.type)}
|
||||
</View>
|
||||
<Text className='text-xs text-gray-500 block'>{item.time}</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default UnifiedQRPage
|
||||
Reference in New Issue
Block a user