feat(user): 新增收货地址管理及售后申请页面

- 新增地址类型定义,增强前端地址数据结构
- 新增地址编辑页面,支持地址智能识别和定位选点功能
- 地址编辑支持省市区选择及默认地址设置
- 新增地址列表页面,支持地址展示、删除、编辑和选择功能
- 实现售后申请页面,支持选择售后类型和退款原因
- 售后申请支持商品选择、退款金额计算和凭证上传
- 新增售后详情页面,支持售后状态展示及申请取消
- 优化页面加载和用户交互体验,增加错误提示和权限处理
This commit is contained in:
2026-07-01 12:11:56 +08:00
parent bf6ed504cc
commit 1fa58040f3
636 changed files with 58878 additions and 716 deletions

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '协议详情',
navigationBarTextStyle: 'black'
})

View 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;
}
}

View 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

View 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 })
}

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '忘记密码',
navigationBarTextStyle: 'black'
})

View 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

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '登录',
navigationBarTextStyle: 'black'
})

377
src_bak/passport/login.scss Normal file
View 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;
}

331
src_bak/passport/login.tsx Normal file
View File

@@ -0,0 +1,331 @@
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 './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'>
<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

View File

@@ -0,0 +1,5 @@
export default {
navigationBarTitleText: '确认登录',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
}

View 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;

View File

@@ -0,0 +1,5 @@
export default {
navigationBarTitleText: '扫码登录',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
}

View 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;

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '注册/登录',
navigationBarTextStyle: 'black'
})

View 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)
// 注册/登录成功后,立即补齐 openidJSAPI 支付必需)
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

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '服务配置',
navigationBarTextStyle: 'black'
})

View 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

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '验证码登录',
navigationBarTextStyle: 'black'
})

View 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

View File

@@ -0,0 +1,4 @@
export default {
navigationBarTitleText: '统一扫码',
navigationBarTextStyle: 'black'
}

View 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