From 791e98a8ecbb33aa90fd6434e1f2750196989975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Thu, 25 Sep 2025 01:02:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(invite):=20=E6=96=B0=E5=A2=9E=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E5=8A=9F=E8=83=BD=E5=8F=8A=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加邀请记录、统计、来源统计、小程序码等数据模型 - 实现小程序码生成、邀请关系绑定、邀请场景处理等接口 - 新增扫码登录相关接口,支持生成二维码、检查状态、确认登录等操作 - 实现二维码内容解析和设备信息获取工具函数 - 添加礼品卡核销相关接口和解密工具函数 - 集成环境配置管理,支持开发、生产、测试环境切换 - 在过期时间页面集成登录二维码和核销二维码处理逻辑 - 添加邀请参数解析工具函数,支持从小程序启动参数中提取邀请信息 --- config/app.ts | 10 + config/env.ts | 42 +++ src/api/invite/index.ts | 277 +++++++++++++++ src/api/invite/model/index.ts | 279 +++++++++++++++ src/api/passport/qr-login/index.ts | 246 ++++++++++++++ src/api/shop/shopGift/index.ts | 260 ++++++++++++++ src/api/shop/shopGift/model/index.ts | 127 +++++++ src/components/UnifiedQRButton.tsx | 126 +++++++ src/hjm/query.tsx | 10 + src/hooks/useAdminMode.ts | 66 ++++ src/hooks/useCart.ts | 161 +++++++++ src/hooks/useDealerApply.ts | 81 +++++ src/hooks/useDealerUser.ts | 81 +++++ src/hooks/useOrderStats.ts | 120 +++++++ src/hooks/usePaymentCountdown.ts | 163 +++++++++ src/hooks/useQRLogin.ts | 228 +++++++++++++ src/hooks/useShopInfo.ts | 323 ++++++++++++++++++ src/hooks/useTheme.ts | 95 ++++++ src/hooks/useUnifiedQRScan.ts | 331 ++++++++++++++++++ src/hooks/useUser.ts | 334 ++++++++++++++++++ src/hooks/useUserData.ts | 136 ++++++++ src/pages/index/ExpirationTime.tsx | 164 ++++++++- src/pages/index/Login.tsx | 7 +- src/utils/invite.ts | 484 +++++++++++++++++++++++++++ src/utils/jsonUtils.ts | 31 ++ tsconfig.json | 3 +- 26 files changed, 4181 insertions(+), 4 deletions(-) create mode 100644 config/app.ts create mode 100644 config/env.ts create mode 100644 src/api/invite/index.ts create mode 100644 src/api/invite/model/index.ts create mode 100644 src/api/passport/qr-login/index.ts create mode 100644 src/api/shop/shopGift/index.ts create mode 100644 src/api/shop/shopGift/model/index.ts create mode 100644 src/components/UnifiedQRButton.tsx create mode 100644 src/hooks/useAdminMode.ts create mode 100644 src/hooks/useCart.ts create mode 100644 src/hooks/useDealerApply.ts create mode 100644 src/hooks/useDealerUser.ts create mode 100644 src/hooks/useOrderStats.ts create mode 100644 src/hooks/usePaymentCountdown.ts create mode 100644 src/hooks/useQRLogin.ts create mode 100644 src/hooks/useShopInfo.ts create mode 100644 src/hooks/useTheme.ts create mode 100644 src/hooks/useUnifiedQRScan.ts create mode 100644 src/hooks/useUser.ts create mode 100644 src/hooks/useUserData.ts create mode 100644 src/utils/invite.ts create mode 100644 src/utils/jsonUtils.ts diff --git a/config/app.ts b/config/app.ts new file mode 100644 index 0000000..d9325e5 --- /dev/null +++ b/config/app.ts @@ -0,0 +1,10 @@ +import { API_BASE_URL } from './env' + +// 租户ID - 请根据实际情况修改 +export const TenantId = '10519'; +// 接口地址 - 请根据实际情况修改 +export const BaseUrl = API_BASE_URL; +// 当前版本 +export const Version = 'v3.0.8'; +// 版权信息 +export const Copyright = 'WebSoft Inc.'; diff --git a/config/env.ts b/config/env.ts new file mode 100644 index 0000000..c92883b --- /dev/null +++ b/config/env.ts @@ -0,0 +1,42 @@ +// 环境变量配置 +export const ENV_CONFIG = { + // 开发环境 + development: { + API_BASE_URL: 'https://cms-api.websoft.top/api', + APP_NAME: '开发环境', + DEBUG: 'true', + }, + // 生产环境 + production: { + API_BASE_URL: 'https://cms-api.websoft.top/api', + APP_NAME: '时里院子市集', + DEBUG: 'false', + }, + // 测试环境 + test: { + API_BASE_URL: 'https://cms-api.s209.websoft.top/api', + APP_NAME: '测试环境', + DEBUG: 'true', + } +} + +// 获取当前环境配置 +export function getEnvConfig() { + const env = process.env.NODE_ENV || 'development' + if (env === 'production') { + return ENV_CONFIG.production + } else { // @ts-ignore + if (env === 'test') { + return ENV_CONFIG.test + } else { + return ENV_CONFIG.development + } + } +} + +// 导出环境变量 +export const { + API_BASE_URL, + APP_NAME, + DEBUG +} = getEnvConfig() diff --git a/src/api/invite/index.ts b/src/api/invite/index.ts new file mode 100644 index 0000000..6dc7f7a --- /dev/null +++ b/src/api/invite/index.ts @@ -0,0 +1,277 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api/index'; + +/** + * 小程序码生成参数 + */ +export interface MiniProgramCodeParam { + // 小程序页面路径 + page?: string; + // 场景值,最大32个可见字符 + scene: string; + // 二维码宽度,单位 px,最小 280px,最大 1280px + width?: number; + // 是否检查页面是否存在 + checkPath?: boolean; + // 环境版本 + envVersion?: 'release' | 'trial' | 'develop'; +} + +/** + * 邀请关系参数 + */ +export interface InviteRelationParam { + // 邀请人ID + inviterId: number; + // 被邀请人ID + inviteeId: number; + // 邀请来源 + source: string; + // 场景值 + scene?: string; + // 邀请时间 + inviteTime?: string; +} + +/** + * 绑定推荐关系参数 + */ +export interface BindRefereeParam { + // 推荐人ID + dealerId: number; + // 被推荐人ID (可选,如果不传则使用当前登录用户) + userId?: number; + // 推荐来源 + source?: string; + // 场景值 + scene?: string; +} + +/** + * 邀请统计数据 + */ +export interface InviteStats { + // 总邀请数 + totalInvites: number; + // 成功注册数 + successfulRegistrations: number; + // 转化率 + conversionRate: number; + // 今日邀请数 + todayInvites: number; + // 本月邀请数 + monthlyInvites: number; + // 邀请来源统计 + sourceStats: InviteSourceStat[]; +} + +/** + * 邀请来源统计 + */ +export interface InviteSourceStat { + source: string; + count: number; + successCount: number; + conversionRate: number; +} + +/** + * 邀请记录 + */ +export interface InviteRecord { + id?: number; + inviterId?: number; + inviteeId?: number; + inviterName?: string; + inviteeName?: string; + source?: string; + scene?: string; + status?: 'pending' | 'registered' | 'activated'; + inviteTime?: string; + registerTime?: string; + activateTime?: string; +} + +/** + * 邀请记录查询参数 + */ +export interface InviteRecordParam { + page?: number; + limit?: number; + inviterId?: number; + status?: string; + source?: string; + startTime?: string; + endTime?: string; +} + +/** + * 生成小程序码 + */ +export async function generateMiniProgramCode(data: MiniProgramCodeParam) { + try { + const url = 'https://server.websoft.top/api/wx-login/getOrderQRCodeUnlimited/' + data.scene; + // 由于接口直接返回图片buffer,我们直接构建完整的URL + return `${url}`; + } catch (error: any) { + throw new Error(error.message || '生成小程序码失败'); + } +} + +/** + * 生成邀请小程序码 + */ +export async function generateInviteCode(inviterId: number) { + const scene = `uid_${inviterId}`; + + return generateMiniProgramCode({ + page: 'pages/index/index', + scene: scene, + width: 180, + checkPath: true, + envVersion: 'trial' + }); +} + +/** + * 建立邀请关系 (旧接口,保留兼容性) + */ +export async function createInviteRelation(data: InviteRelationParam) { + const res = await request.post>( + '/invite/create-relation', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 绑定推荐关系 (新接口) + */ +export async function bindRefereeRelation(data: BindRefereeParam) { + try { + const res = await request.post>( + '/shop/shop-dealer-referee', + { + dealerId: data.dealerId, + userId: data.userId, + source: data.source || 'qrcode', + scene: data.scene + } + ); + + if (res.code === 0) { + return res.data; + } + + throw new Error(res.message || '绑定推荐关系失败'); + } catch (error: any) { + console.error('绑定推荐关系API调用失败:', error); + throw new Error(error.message || '绑定推荐关系失败'); + } +} + +/** + * 处理邀请场景值 + */ +export async function processInviteScene(scene: string, userId: number) { + const res = await request.post>( + '/invite/process-scene', + { scene, userId } + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 获取邀请统计数据 + */ +export async function getInviteStats(inviterId: number) { + const res = await request.get>( + `/invite/stats/${inviterId}` + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 分页查询邀请记录 + */ +export async function pageInviteRecords(params: InviteRecordParam) { + const res = await request.get>>( + '/invite/records/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 获取我的邀请记录 + */ +export async function getMyInviteRecords(params: InviteRecordParam) { + const res = await request.get>>( + '/invite/my-records', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 验证邀请码有效性 + */ +export async function validateInviteCode(scene: string) { + const res = await request.post>( + '/invite/validate-code', + { scene } + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 更新邀请状态 + */ +export async function updateInviteStatus(inviteId: number, status: 'registered' | 'activated') { + const res = await request.put>( + `/invite/update-status/${inviteId}`, + { status } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 获取邀请排行榜 + */ +export async function getInviteRanking(params?: { limit?: number; period?: 'day' | 'week' | 'month' }) { + const res = await request.get>>( + '/invite/ranking', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/invite/model/index.ts b/src/api/invite/model/index.ts new file mode 100644 index 0000000..11aada9 --- /dev/null +++ b/src/api/invite/model/index.ts @@ -0,0 +1,279 @@ +import type { PageParam } from '@/api/index'; + +/** + * 邀请记录表 + */ +export interface InviteRecord { + // 主键ID + id?: number; + // 邀请人ID + inviterId?: number; + // 被邀请人ID + inviteeId?: number; + // 邀请人姓名 + inviterName?: string; + // 被邀请人姓名 + inviteeName?: string; + // 邀请来源 (qrcode, link, share等) + source?: string; + // 场景值 + scene?: string; + // 邀请状态: pending-待注册, registered-已注册, activated-已激活 + status?: 'pending' | 'registered' | 'activated'; + // 邀请时间 + inviteTime?: string; + // 注册时间 + registerTime?: string; + // 激活时间 + activateTime?: string; + // 备注 + comments?: string; + // 是否删除, 0否, 1是 + deleted?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 邀请统计表 + */ +export interface InviteStats { + // 主键ID + id?: number; + // 邀请人ID + inviterId?: number; + // 统计日期 + statDate?: string; + // 总邀请数 + totalInvites?: number; + // 成功注册数 + successfulRegistrations?: number; + // 激活用户数 + activatedUsers?: number; + // 转化率 + conversionRate?: number; + // 今日邀请数 + todayInvites?: number; + // 本周邀请数 + weeklyInvites?: number; + // 本月邀请数 + monthlyInvites?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 邀请来源统计表 + */ +export interface InviteSourceStats { + // 主键ID + id?: number; + // 邀请人ID + inviterId?: number; + // 来源类型 + source?: string; + // 来源名称 + sourceName?: string; + // 邀请数量 + inviteCount?: number; + // 成功数量 + successCount?: number; + // 转化率 + conversionRate?: number; + // 统计日期 + statDate?: string; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 小程序码记录表 + */ +export interface MiniProgramCode { + // 主键ID + id?: number; + // 邀请人ID + inviterId?: number; + // 场景值 + scene?: string; + // 小程序码URL + codeUrl?: string; + // 页面路径 + pagePath?: string; + // 二维码宽度 + width?: number; + // 环境版本 + envVersion?: string; + // 过期时间 + expireTime?: string; + // 使用次数 + useCount?: number; + // 最后使用时间 + lastUseTime?: string; + // 状态: active-有效, expired-过期, disabled-禁用 + status?: 'active' | 'expired' | 'disabled'; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 邀请记录搜索条件 + */ +export interface InviteRecordParam extends PageParam { + // 邀请人ID + inviterId?: number; + // 被邀请人ID + inviteeId?: number; + // 邀请状态 + status?: string; + // 邀请来源 + source?: string; + // 开始时间 + startTime?: string; + // 结束时间 + endTime?: string; + // 关键词搜索 + keywords?: string; +} + +/** + * 邀请统计搜索条件 + */ +export interface InviteStatsParam extends PageParam { + // 邀请人ID + inviterId?: number; + // 统计开始日期 + startDate?: string; + // 统计结束日期 + endDate?: string; +} + +/** + * 邀请来源统计搜索条件 + */ +export interface InviteSourceStatsParam extends PageParam { + // 邀请人ID + inviterId?: number; + // 来源类型 + source?: string; + // 统计开始日期 + startDate?: string; + // 统计结束日期 + endDate?: string; +} + +/** + * 小程序码搜索条件 + */ +export interface MiniProgramCodeParam extends PageParam { + // 邀请人ID + inviterId?: number; + // 状态 + status?: string; + // 场景值 + scene?: string; +} + +/** + * 邀请排行榜数据 + */ +export interface InviteRanking { + // 邀请人ID + inviterId?: number; + // 邀请人姓名 + inviterName?: string; + // 邀请人头像 + inviterAvatar?: string; + // 邀请数量 + inviteCount?: number; + // 成功数量 + successCount?: number; + // 转化率 + conversionRate?: number; + // 排名 + rank?: number; + // 奖励金额 + rewardAmount?: number; +} + +/** + * 邀请奖励配置 + */ +export interface InviteRewardConfig { + // 主键ID + id?: number; + // 奖励类型: register-注册奖励, activate-激活奖励, order-订单奖励 + rewardType?: string; + // 奖励名称 + rewardName?: string; + // 奖励金额 + rewardAmount?: number; + // 奖励积分 + rewardPoints?: number; + // 奖励优惠券ID + couponId?: number; + // 是否启用 + enabled?: boolean; + // 生效时间 + effectTime?: string; + // 失效时间 + expireTime?: string; + // 备注 + comments?: string; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 邀请奖励记录 + */ +export interface InviteRewardRecord { + // 主键ID + id?: number; + // 邀请记录ID + inviteRecordId?: number; + // 邀请人ID + inviterId?: number; + // 被邀请人ID + inviteeId?: number; + // 奖励类型 + rewardType?: string; + // 奖励金额 + rewardAmount?: number; + // 奖励积分 + rewardPoints?: number; + // 优惠券ID + couponId?: number; + // 发放状态: pending-待发放, issued-已发放, failed-发放失败 + status?: 'pending' | 'issued' | 'failed'; + // 发放时间 + issueTime?: string; + // 失败原因 + failReason?: string; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} diff --git a/src/api/passport/qr-login/index.ts b/src/api/passport/qr-login/index.ts new file mode 100644 index 0000000..dab2d77 --- /dev/null +++ b/src/api/passport/qr-login/index.ts @@ -0,0 +1,246 @@ +import request from '@/utils/request'; +import type { ApiResult } from '@/api/index'; +import Taro from '@tarojs/taro'; +import {SERVER_API_URL} from "@/utils/server"; +import {getUserInfo} from "@/api/layout"; + +/** + * 扫码登录相关接口 + */ + +// 生成扫码token请求参数 +export interface GenerateQRTokenParam { + // 客户端类型:web, app, wechat + clientType?: string; + // 设备信息 + deviceInfo?: string; + // 过期时间(分钟) + expireMinutes?: number; +} + +// 生成扫码token响应 +export interface GenerateQRTokenResult { + // 扫码token + token: string; + // 二维码内容(通常是包含token的URL或JSON) + qrContent: string; + // 过期时间戳 + expireTime: number; + // 二维码图片URL(可选) + qrImageUrl?: string; +} + +// 扫码状态枚举 +export enum QRLoginStatus { + PENDING = 'pending', // 等待扫码 + SCANNED = 'scanned', // 已扫码,等待确认 + CONFIRMED = 'confirmed', // 已确认登录 + EXPIRED = 'expired', // 已过期 + CANCELLED = 'cancelled' // 已取消 +} + +// 检查扫码状态响应 +export interface QRLoginStatusResult { + // 当前状态 + status: QRLoginStatus; + // 状态描述 + message?: string; + // 如果已确认登录,返回JWT token + accessToken?: string; + // 用户信息 + userInfo?: { + userId: number; + nickname?: string; + avatar?: string; + phone?: string; + }; + // 剩余有效时间(秒) + remainingTime?: number; +} + +// 确认登录请求参数 +export interface ConfirmLoginParam { + // 扫码token + token: string; + // 用户ID + userId: number; + // 登录平台:web, app, wechat + platform?: string; + // 微信用户信息(当platform为wechat时) + wechatInfo?: { + openid?: string; + unionid?: string; + nickname?: string; + avatar?: string; + gender?: string; + }; + // 设备信息 + deviceInfo?: string; +} + +// 确认登录响应 +export interface ConfirmLoginResult { + // 是否成功 + success: boolean; + // 消息 + message: string; + // 登录用户信息 + userInfo?: { + userId: number; + nickname?: string; + avatar?: string; + phone?: string; + }; +} + +/** + * 生成扫码登录token + */ +export async function generateQRToken(data?: GenerateQRTokenParam) { + const res = await request.post>( + SERVER_API_URL + '/qr-login/generate', + { + clientType: 'wechat', + expireMinutes: 5, + ...data + } + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 检查扫码登录状态 + */ +export async function checkQRLoginStatus(token: string) { + const res = await request.get>( + SERVER_API_URL + `/qr-login/status/${token}` + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 确认扫码登录(通用接口) + */ +export async function confirmQRLogin(data: ConfirmLoginParam) { + const res = await request.post>( + SERVER_API_URL + '/qr-login/confirm', + data + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 微信小程序扫码登录确认(便捷接口) + */ +export async function confirmWechatQRLogin(token: string, userId: number) { + try { + // 获取微信用户信息 + const userInfo = await getUserInfo(); + + const data: ConfirmLoginParam = { + token, + userId, + platform: 'wechat', + wechatInfo: { + nickname: userInfo?.nickname, + avatar: userInfo?.avatar, + gender: userInfo?.sex + }, + deviceInfo: await getDeviceInfo() + }; + + const res = await request.post>( + SERVER_API_URL + '/qr-login/confirm', + data + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); + } catch (error: any) { + return Promise.reject(new Error(error.message || '确认登录失败')); + } +} + +/** + * 获取设备信息 + */ +async function getDeviceInfo() { + return new Promise((resolve) => { + Taro.getSystemInfo({ + success: (res) => { + const deviceInfo = { + platform: res.platform, + system: res.system, + version: res.version, + model: res.model, + brand: res.brand, + screenWidth: res.screenWidth, + screenHeight: res.screenHeight + }; + resolve(JSON.stringify(deviceInfo)); + }, + fail: () => { + resolve('unknown'); + } + }); + }); +} + +/** + * 解析二维码内容,提取token + */ +export function parseQRContent(qrContent: string): string | null { + try { + console.log('解析二维码内容1:', qrContent); + + // 尝试解析JSON格式 + if (qrContent.startsWith('{')) { + const parsed = JSON.parse(qrContent); + return parsed.token || parsed.qrCodeKey || null; + } + + // 尝试解析URL格式 + if (qrContent.includes('http')) { + const url = new URL(qrContent); + // 支持多种参数名 + return url.searchParams.get('token') || + url.searchParams.get('qrCodeKey') || + url.searchParams.get('qr_code_key') || + null; + } + + // 尝试解析简单的key=value格式 + if (qrContent.includes('=')) { + const params = new URLSearchParams(qrContent); + return params.get('token') || + params.get('qrCodeKey') || + params.get('qr_code_key') || + null; + } + + // 如果是以qr-login:开头的格式 + if (qrContent.startsWith('qr-login:')) { + return qrContent.replace('qr-login:', ''); + } + + // 直接返回内容作为token(如果是32位以上的字符串) + if (qrContent.length >= 32) { + return qrContent; + } + + return null; + } catch (error) { + console.error('解析二维码内容失败:', error); + return null; + } +} diff --git a/src/api/shop/shopGift/index.ts b/src/api/shop/shopGift/index.ts new file mode 100644 index 0000000..9afda93 --- /dev/null +++ b/src/api/shop/shopGift/index.ts @@ -0,0 +1,260 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api'; +import {ShopGift, ShopGiftParam, GiftRedeemParam, GiftUseParam, QRCodeParam} from './model'; + +/** + * 分页查询礼品卡 + */ +export async function pageShopGift(params: ShopGiftParam) { + const res = await request.get>>( + '/shop/shop-gift/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询礼品卡列表 + */ +export async function listShopGift(params?: ShopGiftParam) { + const res = await request.get>( + '/shop/shop-gift', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加礼品卡 + */ +export async function addShopGift(data: ShopGift) { + const res = await request.post>( + '/shop/shop-gift', + data + ); + if (res.code === 0) { + return res.message; + } +} + +/** + * 生成礼品卡 + */ +export async function makeShopGift(data: ShopGift) { + const res = await request.post>( + '/shop/shop-gift/make', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改礼品卡 + */ +export async function updateShopGift(data: ShopGift) { + const res = await request.put>( + '/shop/shop-gift', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除礼品卡 + */ +export async function removeShopGift(id?: number) { + const res = await request.del>( + '/shop/shop-gift/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除礼品卡 + */ +export async function removeBatchShopGift(data: (number | undefined)[]) { + const res = await request.del>( + '/shop/shop-gift/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询礼品卡 + */ +export async function getShopGift(id: number) { + const res = await request.get>( + '/shop/shop-gift/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据code查询礼品卡 + * @param code + */ +export async function getShopGiftByCode(code: string) { + const res = await request.get>( + '/shop/shop-gift/by-code/' + code + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + + + +/** + * 兑换礼品卡 + */ +export async function redeemGift(params: GiftRedeemParam) { + const res = await request.post>( + '/shop/shop-gift/redeem', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 使用礼品卡 + */ +export async function useGift(params: GiftUseParam) { + const res = await request.post>( + '/shop/shop-gift/use', + params + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 获取用户的礼品卡列表 + */ +export async function getUserGifts(params: ShopGiftParam) { + const res = await request.get>>( + '/shop/shop-gift/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 验证礼品卡兑换码 + */ +export async function validateGiftCode(code: string) { + const res = await request.get>( + `/shop/shop-gift/validate/${code}` + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +export async function exportShopGift(ids?: number[]) { + const res = await request.post>( + '/shop/shop-gift/export', + ids + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 生成礼品卡核销码(可用) + */ +export async function generateVerificationCode(data: QRCodeParam) { + const res = await request.post>( + '/qr-code/create-encrypted-qr-code', + data + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 验证核销码 + */ +export async function verifyGiftCard(params: { verificationCode?: string; giftCode?: string }) { + const res = await request.post>( + '/shop/shop-gift/verify', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 完成礼品卡核销 + */ +export async function completeVerification(params: { + giftId: number; + verificationCode: string; + storeId?: number; + storeName?: string; + operatorId?: number; + operatorName?: string; +}) { + const res = await request.post>( + '/shop/shop-gift/complete-verification', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 解密二维码数据 + */ +export async function decryptQrData(params: { token: string; encryptedData: string }) { + const res = await request.post>( + '/qr-code/decrypt-qr-data', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + diff --git a/src/api/shop/shopGift/model/index.ts b/src/api/shop/shopGift/model/index.ts new file mode 100644 index 0000000..93713a6 --- /dev/null +++ b/src/api/shop/shopGift/model/index.ts @@ -0,0 +1,127 @@ +import type { PageParam } from '@/api'; + +/** + * 礼品卡 + */ +export interface ShopGift { + // 礼品卡ID + id?: number; + // 礼品卡名称 + name?: string; + // 礼品卡描述 + description?: string; + // 礼品卡兑换码 + code?: string; + // 关联商品ID + goodsId?: number; + // 商品名称 + goodsName?: string; + // 商品图片 + goodsImage?: string; + // 礼品卡面值 + faceValue?: string; + // 礼品卡类型 (10实物礼品卡 20虚拟礼品卡 30服务礼品卡) + type?: number; + // 领取时间 + takeTime?: string; + // 过期时间 + expireTime?: string; + // 有效期天数 + validDays?: number; + // 操作人 + operatorUserId?: number; + // 操作人名称 + operatorUserName?: string; + // 是否展示 + isShow?: string; + // 状态 (0未使用 1已使用 2已过期 3已失效) + status?: number; + // 备注 + comments?: string; + // 使用说明 + instructions?: string; + // 排序号 + sortNumber?: number; + // 拥有者用户ID + userId?: number; + // 发放者用户ID + issuerUserId?: number; + // 是否删除, 0否, 1是 + deleted?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; + // 数量 + num?: number; + // 已发放数量 + issuedCount?: number; + // 总发放数量 + totalCount?: number; + // 使用门店/地址 + useLocation?: string; + // 客服联系方式 + contactInfo?: string; + // 核销时间 + verificationTime?: string; +} + +/** + * 礼品卡搜索条件 + */ +export interface ShopGiftParam extends PageParam { + id?: number; + keywords?: string; + code?: string; + // 礼品卡类型筛选 + type?: number; + // 状态筛选 (0未使用 1已使用 2失效) + status?: number; + // 用户ID筛选 + userId?: number; + // 商品ID筛选 + goodsId?: number; + // 是否过期筛选 + isExpired?: boolean; + // 排序字段 + sortBy?: 'createTime' | 'expireTime' | 'faceValue' | 'takeTime'; + // 排序方向 + sortOrder?: 'asc' | 'desc'; +} + +/** + * 礼品卡兑换参数 + */ +export interface GiftRedeemParam { + // 兑换码 + code: string; + // 用户ID + userId?: number; +} + +/** + * 礼品卡使用参数 + */ +export interface GiftUseParam { + // 礼品卡ID + giftId?: number; + // 使用地址/门店 + useLocation?: string; + // 使用备注 + useNote?: string; +} + +export interface QRCodeParam { + // 二维码数据 + data?: string; + // 二维码尺寸 + width?: number; + // 二维码高度 + height?: number; + // 二维码过期时间 + expireMinutes?: number; + // 业务类型 + businessType?: string; +} diff --git a/src/components/UnifiedQRButton.tsx b/src/components/UnifiedQRButton.tsx new file mode 100644 index 0000000..18fee99 --- /dev/null +++ b/src/components/UnifiedQRButton.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Button } from '@nutui/nutui-react-taro'; +import { View } from '@tarojs/components'; +import { Scan } from '@nutui/icons-react-taro'; +import Taro from '@tarojs/taro'; +import { useUnifiedQRScan, ScanType, type UnifiedScanResult } from '@/hooks/useUnifiedQRScan'; + +export interface UnifiedQRButtonProps { + /** 按钮类型 */ + type?: 'primary' | 'success' | 'warning' | 'danger' | 'default'; + /** 按钮大小 */ + size?: 'large' | 'normal' | 'small'; + /** 按钮文本 */ + text?: string; + /** 是否显示图标 */ + showIcon?: boolean; + /** 自定义样式类名 */ + className?: string; + /** 扫码成功回调 */ + onSuccess?: (result: UnifiedScanResult) => void; + /** 扫码失败回调 */ + onError?: (error: string) => void; + /** 是否使用页面模式(跳转到专门页面) */ + usePageMode?: boolean; +} + +/** + * 统一扫码按钮组件 + * 支持登录和核销两种类型的二维码扫描 + */ +const UnifiedQRButton: React.FC = ({ + type = 'default', + size = 'small', + text = '扫码', + showIcon = true, + onSuccess, + onError, + usePageMode = false +}) => { + const { startScan, isLoading, canScan, state, result } = useUnifiedQRScan(); + console.log(result,'useUnifiedQRScan>>result') + // 处理点击事件 + const handleClick = async () => { + if (usePageMode) { + // 跳转到专门的统一扫码页面 + if (canScan()) { + Taro.navigateTo({ + url: '/passport/unified-qr/index' + }); + } else { + Taro.showToast({ + title: '请先登录小程序', + icon: 'error' + }); + } + return; + } + + // 直接执行扫码 + try { + const scanResult = await startScan(); + if (scanResult) { + onSuccess?.(scanResult); + + // 根据扫码类型给出不同的后续提示 + if (scanResult.type === ScanType.VERIFICATION) { + // 核销成功后可以继续扫码 + setTimeout(() => { + Taro.showModal({ + title: '核销成功', + content: '是否继续扫码核销其他礼品卡?', + success: (res) => { + if (res.confirm) { + handleClick(); // 递归调用继续扫码 + } + } + }); + }, 2000); + } + } + } catch (error: any) { + onError?.(error.message || '扫码失败'); + } + }; + + const disabled = !canScan() || isLoading; + + // 根据当前状态动态显示文本 + const getButtonText = () => { + if (isLoading) { + switch (state) { + case 'scanning': + return '扫码中...'; + case 'processing': + return '处理中...'; + default: + return '扫码中...'; + } + } + + if (disabled && !canScan()) { + return '请先登录'; + } + + return text; + }; + + return ( + + ); +}; + +export default UnifiedQRButton; diff --git a/src/hjm/query.tsx b/src/hjm/query.tsx index 4f8cf5b..f0c1a2e 100644 --- a/src/hjm/query.tsx +++ b/src/hjm/query.tsx @@ -126,6 +126,13 @@ const Query = () => { }); return false } + if (!FormData.vinCode) { + Taro.showToast({ + title: '请填入车架号', + icon: 'error' + }); + return false + } // 安装车辆 updateHjmCar({ @@ -760,6 +767,9 @@ const Query = () => { GPS编号:{FormData?.gpsNo} + + 车架号:{FormData?.vinCode} + 电子围栏:{FormData.fenceName} diff --git a/src/hooks/useAdminMode.ts b/src/hooks/useAdminMode.ts new file mode 100644 index 0000000..f20b96b --- /dev/null +++ b/src/hooks/useAdminMode.ts @@ -0,0 +1,66 @@ +import { useState, useCallback, useEffect } from 'react'; +import Taro from '@tarojs/taro'; + +/** + * 管理员模式Hook + * 用于管理管理员用户的模式切换(普通用户模式 vs 管理员模式) + */ +export function useAdminMode() { + const [isAdminMode, setIsAdminMode] = useState(false); + + // 从本地存储加载管理员模式状态 + useEffect(() => { + try { + const savedMode = Taro.getStorageSync('admin_mode'); + if (savedMode !== undefined) { + setIsAdminMode(savedMode === 'true' || savedMode === true); + } + } catch (error) { + console.warn('Failed to load admin mode from storage:', error); + } + }, []); + + // 切换管理员模式 + const toggleAdminMode = useCallback(() => { + const newMode = !isAdminMode; + setIsAdminMode(newMode); + + try { + // 保存到本地存储 + Taro.setStorageSync('admin_mode', newMode); + + // 显示切换提示 + Taro.showToast({ + title: newMode ? '已切换到管理员模式' : '已切换到普通用户模式', + icon: 'success', + duration: 1500 + }); + } catch (error) { + console.error('Failed to save admin mode to storage:', error); + } + }, [isAdminMode]); + + // 设置管理员模式 + const setAdminMode = useCallback((mode: boolean) => { + if (mode !== isAdminMode) { + setIsAdminMode(mode); + try { + Taro.setStorageSync('admin_mode', mode); + } catch (error) { + console.error('Failed to save admin mode to storage:', error); + } + } + }, [isAdminMode]); + + // 重置为普通用户模式 + const resetToUserMode = useCallback(() => { + setAdminMode(false); + }, [setAdminMode]); + + return { + isAdminMode, + toggleAdminMode, + setAdminMode, + resetToUserMode + }; +} diff --git a/src/hooks/useCart.ts b/src/hooks/useCart.ts new file mode 100644 index 0000000..e1f1d7f --- /dev/null +++ b/src/hooks/useCart.ts @@ -0,0 +1,161 @@ +import { useState, useEffect } from 'react'; +import Taro from '@tarojs/taro'; + +// 购物车商品接口 +export interface CartItem { + goodsId: number; + name: string; + price: string; + image: string; + quantity: number; + addTime: number; + skuId?: number; + specInfo?: string; +} + +// 购物车Hook +export const useCart = () => { + const [cartItems, setCartItems] = useState([]); + const [cartCount, setCartCount] = useState(0); + + // 从本地存储加载购物车数据 + const loadCartFromStorage = () => { + try { + const cartData = Taro.getStorageSync('cart_items'); + if (cartData) { + const items = JSON.parse(cartData) as CartItem[]; + setCartItems(items); + updateCartCount(items); + } + } catch (error) { + console.error('加载购物车数据失败:', error); + } + }; + + // 保存购物车数据到本地存储 + const saveCartToStorage = (items: CartItem[]) => { + try { + Taro.setStorageSync('cart_items', JSON.stringify(items)); + } catch (error) { + console.error('保存购物车数据失败:', error); + } + }; + + // 更新购物车数量 + const updateCartCount = (items: CartItem[]) => { + const count = items.reduce((total, item) => total + item.quantity, 0); + setCartCount(count); + }; + + // 添加商品到购物车 + const addToCart = (goods: { + goodsId: number; + name: string; + price: string; + image: string; + skuId?: number; + specInfo?: string; + }, quantity: number = 1) => { + const newItems = [...cartItems]; + // 如果有SKU,需要根据goodsId和skuId来判断是否为同一商品 + const existingItemIndex = newItems.findIndex(item => + item.goodsId === goods.goodsId && + (goods.skuId ? item.skuId === goods.skuId : !item.skuId) + ); + + if (existingItemIndex >= 0) { + // 如果商品已存在,增加数量 + newItems[existingItemIndex].quantity += quantity; + } else { + // 如果商品不存在,添加新商品 + const newItem: CartItem = { + goodsId: goods.goodsId, + name: goods.name, + price: goods.price, + image: goods.image, + quantity, + addTime: Date.now(), + skuId: goods.skuId, + specInfo: goods.specInfo + }; + newItems.push(newItem); + } + + setCartItems(newItems); + updateCartCount(newItems); + saveCartToStorage(newItems); + + // 显示成功提示 + Taro.showToast({ + title: '加入购物车成功', + icon: 'success', + duration: 1500 + }); + }; + + // 从购物车移除商品 + const removeFromCart = (goodsId: number) => { + const newItems = cartItems.filter(item => item.goodsId !== goodsId); + setCartItems(newItems); + updateCartCount(newItems); + saveCartToStorage(newItems); + }; + + // 更新商品数量 + const updateQuantity = (goodsId: number, quantity: number) => { + if (quantity <= 0) { + removeFromCart(goodsId); + return; + } + + const newItems = cartItems.map(item => + item.goodsId === goodsId ? { ...item, quantity } : item + ); + setCartItems(newItems); + updateCartCount(newItems); + saveCartToStorage(newItems); + }; + + // 清空购物车 + const clearCart = () => { + setCartItems([]); + setCartCount(0); + Taro.removeStorageSync('cart_items'); + }; + + // 获取购物车总价 + const getTotalPrice = () => { + return cartItems.reduce((total, item) => { + return total + (parseFloat(item.price) * item.quantity); + }, 0).toFixed(2); + }; + + // 检查商品是否在购物车中 + const isInCart = (goodsId: number) => { + return cartItems.some(item => item.goodsId === goodsId); + }; + + // 获取商品在购物车中的数量 + const getItemQuantity = (goodsId: number) => { + const item = cartItems.find(item => item.goodsId === goodsId); + return item ? item.quantity : 0; + }; + + // 初始化时加载购物车数据 + useEffect(() => { + loadCartFromStorage(); + }, []); + + return { + cartItems, + cartCount, + addToCart, + removeFromCart, + updateQuantity, + clearCart, + getTotalPrice, + isInCart, + getItemQuantity, + loadCartFromStorage + }; +}; diff --git a/src/hooks/useDealerApply.ts b/src/hooks/useDealerApply.ts new file mode 100644 index 0000000..be0c7c5 --- /dev/null +++ b/src/hooks/useDealerApply.ts @@ -0,0 +1,81 @@ +import {useState, useEffect, useCallback} from 'react' +import Taro from '@tarojs/taro' +import {getShopDealerApply} from '@/api/shop/shopDealerApply' +import type {ShopDealerApply} from '@/api/shop/shopDealerApply/model' + +// Hook 返回值接口 +export interface UseDealerApplyReturn { + // 经销商用户信息 + dealerApply: ShopDealerApply | null + // 加载状态 + loading: boolean + // 错误信息 + error: string | null + // 刷新数据 + refresh: () => Promise +} + + +/** + * 经销商用户 Hook - 简化版本 + * 只查询经销商用户信息和判断是否存在 + */ +export const useDealerApply = (): UseDealerApplyReturn => { + const [dealerApply, setDealerApply] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const userId = Taro.getStorageSync('UserId'); + + // 获取经销商用户数据 + const fetchDealerData = useCallback(async () => { + + if (!userId) { + console.log('🔍 用户未登录,提前返回') + setDealerApply(null) + return + } + + try { + setLoading(true) + setError(null) + + // 查询当前用户的经销商信息 + const dealer = await getShopDealerApply(userId) + + if (dealer) { + setDealerApply(dealer) + } else { + setDealerApply(null) + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '获取经销商信息失败' + setError(errorMessage) + setDealerApply(null) + } finally { + setLoading(false) + } + }, [userId]) + + // 刷新数据 + const refresh = useCallback(async () => { + await fetchDealerData() + }, [fetchDealerData]) + + // 初始化加载数据 + useEffect(() => { + if (userId) { + console.log('🔍 调用 fetchDealerData') + fetchDealerData() + } else { + console.log('🔍 用户ID不存在,不调用 fetchDealerData') + } + }, [fetchDealerData, userId]) + + return { + dealerApply, + loading, + error, + refresh + } +} diff --git a/src/hooks/useDealerUser.ts b/src/hooks/useDealerUser.ts new file mode 100644 index 0000000..062777d --- /dev/null +++ b/src/hooks/useDealerUser.ts @@ -0,0 +1,81 @@ +import {useState, useEffect, useCallback} from 'react' +import Taro from '@tarojs/taro' +import {getShopDealerUser} from '@/api/shop/shopDealerUser' +import type {ShopDealerUser} from '@/api/shop/shopDealerUser/model' + +// Hook 返回值接口 +export interface UseDealerUserReturn { + // 经销商用户信息 + dealerUser: ShopDealerUser | null + // 加载状态 + loading: boolean + // 错误信息 + error: string | null + // 刷新数据 + refresh: () => Promise +} + + +/** + * 经销商用户 Hook - 简化版本 + * 只查询经销商用户信息和判断是否存在 + */ +export const useDealerUser = (): UseDealerUserReturn => { + const [dealerUser, setDealerUser] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const userId = Taro.getStorageSync('UserId'); + + // 获取经销商用户数据 + const fetchDealerData = useCallback(async () => { + + if (!userId) { + console.log('🔍 用户未登录,提前返回') + setDealerUser(null) + return + } + + try { + setLoading(true) + setError(null) + + // 查询当前用户的经销商信息 + const dealer = await getShopDealerUser(userId) + + if (dealer) { + setDealerUser(dealer) + } else { + setDealerUser(null) + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '获取经销商信息失败' + setError(errorMessage) + setDealerUser(null) + } finally { + setLoading(false) + } + }, [userId]) + + // 刷新数据 + const refresh = useCallback(async () => { + await fetchDealerData() + }, [fetchDealerData]) + + // 初始化加载数据 + useEffect(() => { + if (userId) { + console.log('🔍 调用 fetchDealerData') + fetchDealerData() + } else { + console.log('🔍 用户ID不存在,不调用 fetchDealerData') + } + }, [fetchDealerData, userId]) + + return { + dealerUser, + loading, + error, + refresh + } +} diff --git a/src/hooks/useOrderStats.ts b/src/hooks/useOrderStats.ts new file mode 100644 index 0000000..a26c29e --- /dev/null +++ b/src/hooks/useOrderStats.ts @@ -0,0 +1,120 @@ +import { useState, useEffect, useCallback } from 'react'; +import { UserOrderStats } from '@/api/user'; +import Taro from '@tarojs/taro'; +import {pageShopOrder} from "@/api/shop/shopOrder"; + +/** + * 订单统计Hook + * 用于管理用户订单各状态的数量统计 + */ +export const useOrderStats = () => { + const [orderStats, setOrderStats] = useState({ + pending: 0, // 待付款 + paid: 0, // 待发货 + shipped: 0, // 待收货 + completed: 0, // 已完成 + refund: 0, // 退货/售后 + total: 0 // 总订单数 + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + /** + * 获取订单统计数据 + */ + const fetchOrderStats = useCallback(async (showToast = false) => { + try { + setLoading(true); + setError(null); + + if(!Taro.getStorageSync('UserId')){ + return false; + } + // TODO 读取订单数量 + const pending = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 0}) + const paid = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 1}) + const shipped = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 3}) + const completed = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 5}) + const refund = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 6}) + const total = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId')}) + setOrderStats({ + pending: pending?.count || 0, + paid: paid?.count || 0, + shipped: shipped?.count || 0, + completed: completed?.count || 0, + refund: refund?.count || 0, + total: total?.count || 0 + }) + + if (showToast) { + Taro.showToast({ + title: '数据已更新', + icon: 'success', + duration: 1500 + }); + } + } catch (err: any) { + const errorMessage = err.message || '获取订单统计失败'; + setError(errorMessage); + + console.error('获取订单统计失败:', err); + + if (showToast) { + Taro.showToast({ + title: errorMessage, + icon: 'error', + duration: 2000 + }); + } + } finally { + setLoading(false); + } + }, []); + + /** + * 刷新订单统计数据 + */ + const refreshOrderStats = useCallback(() => { + return fetchOrderStats(true); + }, [fetchOrderStats]); + + /** + * 获取指定状态的订单数量 + */ + const getOrderCount = useCallback((status: keyof UserOrderStats) => { + return orderStats[status] || 0; + }, [orderStats]); + + /** + * 检查是否有待处理的订单 + */ + const hasPendingOrders = useCallback(() => { + return orderStats.pending > 0 || orderStats.paid > 0 || orderStats.shipped > 0; + }, [orderStats]); + + /** + * 获取总的待处理订单数量 + */ + const getTotalPendingCount = useCallback(() => { + return orderStats.pending + orderStats.paid + orderStats.shipped; + }, [orderStats]); + + // 组件挂载时自动获取数据 + useEffect(() => { + fetchOrderStats(); + }, [fetchOrderStats]); + + return { + orderStats, + loading, + error, + fetchOrderStats, + refreshOrderStats, + getOrderCount, + hasPendingOrders, + getTotalPendingCount + }; +}; + +export default useOrderStats; diff --git a/src/hooks/usePaymentCountdown.ts b/src/hooks/usePaymentCountdown.ts new file mode 100644 index 0000000..a4caa5f --- /dev/null +++ b/src/hooks/usePaymentCountdown.ts @@ -0,0 +1,163 @@ +import { useState, useEffect, useMemo } from 'react'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; + +// 扩展dayjs支持duration +dayjs.extend(duration); + +export interface CountdownTime { + hours: number; + minutes: number; + seconds: number; + isExpired: boolean; + totalMinutes: number; // 总剩余分钟数 +} + +/** + * 支付倒计时Hook + * @param createTime 订单创建时间 + * @param payStatus 支付状态 + * @param realTime 是否实时更新(详情页用true,列表页用false) + * @param timeoutHours 超时小时数,默认24小时 + */ +export const usePaymentCountdown = ( + createTime?: string, + payStatus?: boolean, + realTime: boolean = false, + timeoutHours: number = 24 +): CountdownTime => { + const [timeLeft, setTimeLeft] = useState({ + hours: 0, + minutes: 0, + seconds: 0, + isExpired: false, + totalMinutes: 0 + }); + + // 计算剩余时间的函数 + const calculateTimeLeft = useMemo(() => { + return (): CountdownTime => { + if (!createTime || payStatus) { + return { + hours: 0, + minutes: 0, + seconds: 0, + isExpired: false, + totalMinutes: 0 + }; + } + + const createTimeObj = dayjs(createTime); + const expireTime = createTimeObj.add(timeoutHours, 'hour'); + const now = dayjs(); + const diff = expireTime.diff(now); + + if (diff <= 0) { + return { + hours: 0, + minutes: 0, + seconds: 0, + isExpired: true, + totalMinutes: 0 + }; + } + + const durationObj = dayjs.duration(diff); + const hours = Math.floor(durationObj.asHours()); + const minutes = durationObj.minutes(); + const seconds = durationObj.seconds(); + const totalMinutes = Math.floor(durationObj.asMinutes()); + + return { + hours, + minutes, + seconds, + isExpired: false, + totalMinutes + }; + }; + }, [createTime, payStatus, timeoutHours]); + + useEffect(() => { + if (!createTime || payStatus) { + setTimeLeft({ + hours: 0, + minutes: 0, + seconds: 0, + isExpired: false, + totalMinutes: 0 + }); + return; + } + + // 立即计算一次 + const initialTime = calculateTimeLeft(); + setTimeLeft(initialTime); + + // 如果不需要实时更新,直接返回 + if (!realTime) { + return; + } + + // 如果需要实时更新,设置定时器 + const timer = setInterval(() => { + const newTimeLeft = calculateTimeLeft(); + setTimeLeft(newTimeLeft); + + // 如果已过期,清除定时器 + if (newTimeLeft.isExpired) { + clearInterval(timer); + } + }, 1000); + + return () => clearInterval(timer); + }, [createTime, payStatus, realTime, calculateTimeLeft]); + + return timeLeft; +}; + +/** + * 格式化倒计时文本 + * @param timeLeft 倒计时时间对象 + * @param showSeconds 是否显示秒数 + */ +export const formatCountdownText = ( + timeLeft: CountdownTime, + showSeconds: boolean = false +): string => { + if (timeLeft.isExpired) { + return '已过期'; + } + + if (timeLeft.hours > 0) { + if (showSeconds) { + return `${timeLeft.hours}小时${timeLeft.minutes}分${timeLeft.seconds}秒`; + } else { + return `${timeLeft.hours}小时${timeLeft.minutes}分钟`; + } + } else if (timeLeft.minutes > 0) { + if (showSeconds) { + return `${timeLeft.minutes}分${timeLeft.seconds}秒`; + } else { + return `${timeLeft.minutes}分钟`; + } + } else { + return `${timeLeft.seconds}秒`; + } +}; + +/** + * 判断是否为紧急状态(剩余时间少于1小时) + */ +export const isUrgentCountdown = (timeLeft: CountdownTime): boolean => { + return !timeLeft.isExpired && timeLeft.totalMinutes < 60; +}; + +/** + * 判断是否为非常紧急状态(剩余时间少于10分钟) + */ +export const isCriticalCountdown = (timeLeft: CountdownTime): boolean => { + return !timeLeft.isExpired && timeLeft.totalMinutes < 10; +}; + +export default usePaymentCountdown; diff --git a/src/hooks/useQRLogin.ts b/src/hooks/useQRLogin.ts new file mode 100644 index 0000000..717a968 --- /dev/null +++ b/src/hooks/useQRLogin.ts @@ -0,0 +1,228 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import Taro from '@tarojs/taro'; +import { + confirmWechatQRLogin, + parseQRContent, + type ConfirmLoginResult +} from '@/api/passport/qr-login'; + +/** + * 扫码登录状态 + */ +export enum ScanLoginState { + IDLE = 'idle', // 空闲状态 + SCANNING = 'scanning', // 正在扫码 + CONFIRMING = 'confirming', // 正在确认登录 + SUCCESS = 'success', // 登录成功 + ERROR = 'error' // 登录失败 +} + +/** + * 扫码登录Hook + */ +export function useQRLogin() { + const [state, setState] = useState(ScanLoginState.IDLE); + const [error, setError] = useState(''); + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // 用于取消操作的引用 + const cancelRef = useRef(false); + + /** + * 重置状态 + */ + const reset = useCallback(() => { + setState(ScanLoginState.IDLE); + setError(''); + setResult(null); + setIsLoading(false); + cancelRef.current = false; + }, []); + + /** + * 开始扫码登录 + */ + const startScan = useCallback(async () => { + try { + reset(); + setState(ScanLoginState.SCANNING); + + // 检查用户是否已登录 + const userId = Taro.getStorageSync('UserId'); + if (!userId) { + throw new Error('请先登录小程序'); + } + + // 调用扫码API + const scanResult = await new Promise((resolve, reject) => { + Taro.scanCode({ + onlyFromCamera: true, + scanType: ['qrCode'], + success: (res) => { + if (res.result) { + resolve(res.result); + } else { + reject(new Error('扫码结果为空')); + } + }, + fail: (err) => { + reject(new Error(err.errMsg || '扫码失败')); + } + }); + }); + + // 检查是否被取消 + if (cancelRef.current) { + return; + } + + // 解析二维码内容 + const token = parseQRContent(scanResult); + console.log('解析二维码内容2:',token) + if (!token) { + throw new Error('无效的登录二维码'); + } + + // 确认登录 + setState(ScanLoginState.CONFIRMING); + setIsLoading(true); + + const confirmResult = await confirmWechatQRLogin(token, parseInt(userId)); + console.log(confirmResult,'confirmResult>>>>') + if (cancelRef.current) { + return; + } + + if (confirmResult.success) { + setState(ScanLoginState.SUCCESS); + setResult(confirmResult); + + // 显示成功提示 + Taro.showToast({ + title: '登录确认成功', + icon: 'success', + duration: 2000 + }); + } else { + throw new Error(confirmResult.message || '登录确认失败'); + } + + } catch (err: any) { + if (!cancelRef.current) { + setState(ScanLoginState.ERROR); + const errorMessage = err.message || '扫码登录失败'; + setError(errorMessage); + + // 显示错误提示 + Taro.showToast({ + title: errorMessage, + icon: 'error', + duration: 3000 + }); + } + } finally { + setIsLoading(false); + } + }, [reset]); + + /** + * 取消扫码登录 + */ + const cancel = useCallback(() => { + cancelRef.current = true; + reset(); + }, [reset]); + + /** + * 处理扫码结果(用于已有扫码结果的情况) + */ + const handleScanResult = useCallback(async (qrContent: string) => { + try { + reset(); + setState(ScanLoginState.CONFIRMING); + setIsLoading(true); + + // 检查用户是否已登录 + const userId = Taro.getStorageSync('UserId'); + if (!userId) { + throw new Error('请先登录小程序'); + } + + // 解析二维码内容 + const token = parseQRContent(qrContent); + if (!token) { + throw new Error('无效的登录二维码'); + } + + // 确认登录 + const confirmResult = await confirmWechatQRLogin(token, parseInt(userId)); + + if (confirmResult.success) { + setState(ScanLoginState.SUCCESS); + setResult(confirmResult); + + // 显示成功提示 + Taro.showToast({ + title: '登录确认成功', + icon: 'success', + duration: 2000 + }); + } else { + throw new Error(confirmResult.message || '登录确认失败'); + } + + } catch (err: any) { + setState(ScanLoginState.ERROR); + const errorMessage = err.message || '登录确认失败'; + setError(errorMessage); + + // 显示错误提示 + Taro.showToast({ + title: errorMessage, + icon: 'error', + duration: 3000 + }); + } finally { + setIsLoading(false); + } + }, [reset]); + + /** + * 检查是否可以进行扫码登录 + */ + const canScan = useCallback(() => { + const userId = Taro.getStorageSync('UserId'); + const accessToken = Taro.getStorageSync('access_token'); + return !!(userId && accessToken); + }, []); + + // 组件卸载时取消操作 + useEffect(() => { + return () => { + cancelRef.current = true; + }; + }, []); + + return { + // 状态 + state, + error, + result, + isLoading, + + // 方法 + startScan, + cancel, + reset, + handleScanResult, + canScan, + + // 便捷状态判断 + isIdle: state === ScanLoginState.IDLE, + isScanning: state === ScanLoginState.SCANNING, + isConfirming: state === ScanLoginState.CONFIRMING, + isSuccess: state === ScanLoginState.SUCCESS, + isError: state === ScanLoginState.ERROR + }; +} diff --git a/src/hooks/useShopInfo.ts b/src/hooks/useShopInfo.ts new file mode 100644 index 0000000..f16ccbe --- /dev/null +++ b/src/hooks/useShopInfo.ts @@ -0,0 +1,323 @@ +import {useState, useEffect, useCallback} from 'react'; +import Taro from '@tarojs/taro'; +import {AppInfo} from '@/api/cms/cmsWebsite/model'; +import {getShopInfo} from '@/api/layout'; + +// 本地存储键名 +const SHOP_INFO_STORAGE_KEY = 'shop_info'; +const SHOP_INFO_CACHE_TIME_KEY = 'shop_info_cache_time'; + +// 缓存有效期(毫秒)- 默认30分钟 +const CACHE_DURATION = 30 * 60 * 1000; + +/** + * 商店信息Hook + * 提供商店信息的获取、缓存和管理功能 + */ +export const useShopInfo = () => { + const [shopInfo, setShopInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 从本地存储加载商店信息 + const loadShopInfoFromStorage = useCallback(() => { + try { + const cachedData = Taro.getStorageSync(SHOP_INFO_STORAGE_KEY); + const cacheTime = Taro.getStorageSync(SHOP_INFO_CACHE_TIME_KEY); + + if (cachedData && cacheTime) { + const now = Date.now(); + const timeDiff = now - cacheTime; + + // 检查缓存是否过期 + if (timeDiff < CACHE_DURATION) { + const shopData = typeof cachedData === 'string' ? JSON.parse(cachedData) : cachedData; + setShopInfo(shopData); + setLoading(false); + return true; // 返回true表示使用了缓存 + } else { + // 缓存过期,清除旧数据 + Taro.removeStorageSync(SHOP_INFO_STORAGE_KEY); + Taro.removeStorageSync(SHOP_INFO_CACHE_TIME_KEY); + } + } + } catch (error) { + console.error('加载商店信息缓存失败:', error); + } + return false; // 返回false表示没有使用缓存 + }, []); + + // 保存商店信息到本地存储 + const saveShopInfoToStorage = useCallback((data: AppInfo) => { + try { + Taro.setStorageSync(SHOP_INFO_STORAGE_KEY, data); + Taro.setStorageSync(SHOP_INFO_CACHE_TIME_KEY, Date.now()); + } catch (error) { + console.error('保存商店信息缓存失败:', error); + } + }, []); + + // 从服务器获取商店信息 + const fetchShopInfo = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const data = await getShopInfo(); + setShopInfo(data); + + // 保存到本地存储 + saveShopInfoToStorage(data); + + return data; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('获取商店信息失败:', error); + setError(errorMessage); + + // 如果网络请求失败,尝试使用缓存数据(即使过期) + const cachedData = Taro.getStorageSync(SHOP_INFO_STORAGE_KEY); + if (cachedData) { + const shopData = typeof cachedData === 'string' ? JSON.parse(cachedData) : cachedData; + setShopInfo(shopData); + console.warn('网络请求失败,使用缓存数据'); + } + + return null; + } finally { + setLoading(false); + } + }, [saveShopInfoToStorage]); + + // 刷新商店信息 + const refreshShopInfo = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const data = await getShopInfo(); + setShopInfo(data); + + // 保存到本地存储 + saveShopInfoToStorage(data); + + return data; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('刷新商店信息失败:', error); + setError(errorMessage); + return null; + } finally { + setLoading(false); + } + }, [saveShopInfoToStorage]); + + // 清除缓存 + const clearCache = useCallback(() => { + try { + Taro.removeStorageSync(SHOP_INFO_STORAGE_KEY); + Taro.removeStorageSync(SHOP_INFO_CACHE_TIME_KEY); + setShopInfo(null); + setError(null); + } catch (error) { + console.error('清除商店信息缓存失败:', error); + } + }, []); + + // 获取应用名称 + const getAppName = useCallback(() => { + return shopInfo?.appName || '商城'; + }, [shopInfo]); + + // 获取网站名称(兼容旧方法名) + const getWebsiteName = useCallback(() => { + return shopInfo?.appName || '商城'; + }, [shopInfo]); + + // 获取应用Logo + const getAppLogo = useCallback(() => { + return shopInfo?.logo || shopInfo?.icon || ''; + }, [shopInfo]); + + // 获取网站Logo(兼容旧方法名) + const getWebsiteLogo = useCallback(() => { + return shopInfo?.logo || shopInfo?.icon || ''; + }, [shopInfo]); + + // 获取应用图标 + const getAppIcon = useCallback(() => { + return shopInfo?.icon || shopInfo?.logo || ''; + }, [shopInfo]); + + // 获取深色模式Logo(AppInfo中无此字段,使用普通Logo) + const getDarkLogo = useCallback(() => { + return shopInfo?.logo || shopInfo?.icon || ''; + }, [shopInfo]); + + // 获取应用域名 + const getDomain = useCallback(() => { + return shopInfo?.domain || ''; + }, [shopInfo]); + + // 获取应用描述 + const getDescription = useCallback(() => { + return shopInfo?.description || ''; + }, [shopInfo]); + + // 获取应用关键词 + const getKeywords = useCallback(() => { + return shopInfo?.keywords || ''; + }, [shopInfo]); + + // 获取应用标题 + const getTitle = useCallback(() => { + return shopInfo?.title || shopInfo?.appName || ''; + }, [shopInfo]); + + // 获取小程序二维码 + const getMpQrCode = useCallback(() => { + return shopInfo?.mpQrCode || ''; + }, [shopInfo]); + + // 获取联系电话(AppInfo中无此字段,从config中获取) + const getPhone = useCallback(() => { + return (shopInfo?.config as any)?.phone || ''; + }, [shopInfo]); + + // 获取邮箱(AppInfo中无此字段,从config中获取) + const getEmail = useCallback(() => { + return (shopInfo?.config as any)?.email || ''; + }, [shopInfo]); + + // 获取地址(AppInfo中无此字段,从config中获取) + const getAddress = useCallback(() => { + return (shopInfo?.config as any)?.address || ''; + }, [shopInfo]); + + // 获取ICP备案号(AppInfo中无此字段,从config中获取) + const getIcpNo = useCallback(() => { + return (shopInfo?.config as any)?.icpNo || ''; + }, [shopInfo]); + + // 获取应用状态 + const getStatus = useCallback(() => { + return { + running: shopInfo?.running || 0, + statusText: shopInfo?.statusText || '', + statusIcon: shopInfo?.statusIcon || '', + expired: shopInfo?.expired || false, + expiredDays: shopInfo?.expiredDays || 0, + soon: shopInfo?.soon || 0 + }; + }, [shopInfo]); + + // 获取应用配置 + const getConfig = useCallback(() => { + return shopInfo?.config || {}; + }, [shopInfo]); + + // 获取应用设置 + const getSetting = useCallback(() => { + return shopInfo?.setting || {}; + }, [shopInfo]); + + // 获取服务器时间 + const getServerTime = useCallback(() => { + return shopInfo?.serverTime || {}; + }, [shopInfo]); + + // 获取导航菜单 + const getNavigation = useCallback(() => { + return { + topNavs: shopInfo?.topNavs || [], + bottomNavs: shopInfo?.bottomNavs || [] + }; + }, [shopInfo]); + + // 检查是否支持搜索(从config中获取) + const isSearchEnabled = useCallback(() => { + return (shopInfo?.config as any)?.search === true; + }, [shopInfo]); + + // 获取应用版本信息 + const getVersionInfo = useCallback(() => { + return { + version: shopInfo?.version || 10, + expirationTime: shopInfo?.expirationTime || '', + expired: shopInfo?.expired || false, + expiredDays: shopInfo?.expiredDays || 0, + soon: shopInfo?.soon || 0 + }; + }, [shopInfo]); + + // 检查应用是否过期 + const isExpired = useCallback(() => { + return shopInfo?.expired === true; + }, [shopInfo]); + + // 获取过期天数 + const getExpiredDays = useCallback(() => { + return shopInfo?.expiredDays || 0; + }, [shopInfo]); + + // 检查是否即将过期 + const isSoonExpired = useCallback(() => { + return (shopInfo?.soon || 0) > 0; + }, [shopInfo]); + + // 初始化时加载商店信息 + useEffect(() => { + const initShopInfo = async () => { + // 先尝试从缓存加载 + const hasCache = loadShopInfoFromStorage(); + + // 如果没有缓存或需要刷新,则从服务器获取 + if (!hasCache) { + await fetchShopInfo(); + } + }; + + initShopInfo(); + }, []); // 空依赖数组,只在组件挂载时执行一次 + + return { + // 状态 + shopInfo, + loading, + error, + + // 方法 + fetchShopInfo, + refreshShopInfo, + clearCache, + + // 新的工具方法(基于AppInfo字段) + getAppName, + getAppLogo, + getAppIcon, + getDescription, + getKeywords, + getTitle, + getMpQrCode, + getDomain, + getConfig, + getSetting, + getServerTime, + getNavigation, + getStatus, + getVersionInfo, + isExpired, + getExpiredDays, + isSoonExpired, + + // 兼容旧方法名 + getWebsiteName, + getWebsiteLogo, + getDarkLogo, + getPhone, + getEmail, + getAddress, + getIcpNo, + isSearchEnabled + }; +}; diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000..f6684da --- /dev/null +++ b/src/hooks/useTheme.ts @@ -0,0 +1,95 @@ +import { useState, useEffect } from 'react' +import { gradientThemes, GradientTheme, gradientUtils } from '@/styles/gradients' +import Taro from '@tarojs/taro' + +export interface UseThemeReturn { + currentTheme: GradientTheme + setTheme: (themeName: string) => void + isAutoTheme: boolean + refreshTheme: () => void +} + +/** + * 主题管理Hook + * 提供主题切换和状态管理功能 + */ +export const useTheme = (): UseThemeReturn => { + const [currentTheme, setCurrentTheme] = useState(gradientThemes[0]) + const [isAutoTheme, setIsAutoTheme] = useState(true) + + // 获取当前主题 + const getCurrentTheme = (): GradientTheme => { + const savedTheme = Taro.getStorageSync('user_theme') || 'auto' + + if (savedTheme === 'auto') { + // 自动主题:根据用户ID生成 + const userId = Taro.getStorageSync('userId') || '1' + return gradientUtils.getThemeByUserId(userId) + } else { + // 手动选择的主题 + return gradientThemes.find(t => t.name === savedTheme) || gradientThemes[0] + } + } + + // 初始化主题 + useEffect(() => { + const savedTheme = Taro.getStorageSync('user_theme') || 'auto' + setIsAutoTheme(savedTheme === 'auto') + setCurrentTheme(getCurrentTheme()) + }, []) + + // 设置主题 + const setTheme = (themeName: string) => { + try { + Taro.setStorageSync('user_theme', themeName) + setIsAutoTheme(themeName === 'auto') + setCurrentTheme(getCurrentTheme()) + } catch (error) { + console.error('保存主题失败:', error) + } + } + + // 刷新主题(用于自动主题模式下用户信息变更时) + const refreshTheme = () => { + setCurrentTheme(getCurrentTheme()) + } + + return { + currentTheme, + setTheme, + isAutoTheme, + refreshTheme + } +} + +/** + * 获取当前主题的样式对象 + * 用于直接应用到组件样式中 + */ +export const useThemeStyles = () => { + const { currentTheme } = useTheme() + + return { + // 主要背景样式 + primaryBackground: { + background: currentTheme.background, + color: currentTheme.textColor + }, + + // 按钮样式 + primaryButton: { + background: currentTheme.background, + border: 'none', + color: currentTheme.textColor + }, + + // 强调色 + accentColor: currentTheme.primary, + + // 文字颜色 + textColor: currentTheme.textColor, + + // 完整主题对象 + theme: currentTheme + } +} diff --git a/src/hooks/useUnifiedQRScan.ts b/src/hooks/useUnifiedQRScan.ts new file mode 100644 index 0000000..5468046 --- /dev/null +++ b/src/hooks/useUnifiedQRScan.ts @@ -0,0 +1,331 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import Taro from '@tarojs/taro'; +import { + confirmWechatQRLogin, + parseQRContent +} from '@/api/passport/qr-login'; +import { getShopGiftByCode, updateShopGift, decryptQrData } from "@/api/shop/shopGift"; +import { useUser } from "@/hooks/useUser"; +import { isValidJSON } from "@/utils/jsonUtils"; +import dayjs from 'dayjs'; + +/** + * 统一扫码状态 + */ +export enum UnifiedScanState { + IDLE = 'idle', // 空闲状态 + SCANNING = 'scanning', // 正在扫码 + PROCESSING = 'processing', // 正在处理 + SUCCESS = 'success', // 处理成功 + ERROR = 'error' // 处理失败 +} + +/** + * 扫码类型 + */ +export enum ScanType { + LOGIN = 'login', // 登录二维码 + VERIFICATION = 'verification', // 核销二维码 + UNKNOWN = 'unknown' // 未知类型 +} + +/** + * 统一扫码结果 + */ +export interface UnifiedScanResult { + type: ScanType; + data: any; + message: string; +} + +/** + * 统一扫码Hook + * 可以处理登录和核销两种类型的二维码 + */ +export function useUnifiedQRScan() { + const { isAdmin } = useUser(); + const [state, setState] = useState(UnifiedScanState.IDLE); + const [error, setError] = useState(''); + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [scanType, setScanType] = useState(ScanType.UNKNOWN); + + // 用于取消操作的引用 + const cancelRef = useRef(false); + + /** + * 重置状态 + */ + const reset = useCallback(() => { + setState(UnifiedScanState.IDLE); + setError(''); + setResult(null); + setIsLoading(false); + setScanType(ScanType.UNKNOWN); + cancelRef.current = false; + }, []); + + /** + * 检测二维码类型 + */ + const detectScanType = useCallback((scanResult: string): ScanType => { + try { + // 1. 检查是否为JSON格式(核销二维码) + if (isValidJSON(scanResult)) { + const json = JSON.parse(scanResult); + if (json.businessType === 'gift' && json.token && json.data) { + return ScanType.VERIFICATION; + } + } + + // 2. 检查是否为登录二维码 + const loginToken = parseQRContent(scanResult); + if (loginToken) { + return ScanType.LOGIN; + } + + // 3. 检查是否为纯文本核销码(6位数字) + if (/^\d{6}$/.test(scanResult.trim())) { + return ScanType.VERIFICATION; + } + + return ScanType.UNKNOWN; + } catch (error) { + console.error('检测二维码类型失败:', error); + return ScanType.UNKNOWN; + } + }, []); + + /** + * 处理登录二维码 + */ + const handleLoginQR = useCallback(async (scanResult: string): Promise => { + const userId = Taro.getStorageSync('UserId'); + if (!userId) { + throw new Error('请先登录小程序'); + } + + const token = parseQRContent(scanResult); + if (!token) { + throw new Error('无效的登录二维码'); + } + + const confirmResult = await confirmWechatQRLogin(token, parseInt(userId)); + if (confirmResult.status === 'confirmed') { + return { + type: ScanType.LOGIN, + data: confirmResult, + message: '登录成功' + }; + } else { + throw new Error(confirmResult.message || '登录确认失败'); + } + }, []); + + /** + * 处理核销二维码 + */ + const handleVerificationQR = useCallback(async (scanResult: string): Promise => { + if (!isAdmin()) { + throw new Error('您没有核销权限'); + } + + let code = ''; + + // 判断是否为加密的JSON格式 + if (isValidJSON(scanResult)) { + const json = JSON.parse(scanResult); + if (json.businessType === 'gift' && json.token && json.data) { + // 解密获取核销码 + const decryptedData = await decryptQrData({ + token: json.token, + encryptedData: json.data + }); + + if (decryptedData) { + code = decryptedData.toString(); + } else { + throw new Error('解密失败'); + } + } + } else { + // 直接使用扫码结果作为核销码 + code = scanResult.trim(); + } + + if (!code) { + throw new Error('无法获取有效的核销码'); + } + + // 验证核销码 + const gift = await getShopGiftByCode(code); + + if (!gift) { + throw new Error('核销码无效'); + } + + if (gift.status === 1) { + throw new Error('此礼品码已使用'); + } + + if (gift.status === 2) { + throw new Error('此礼品码已失效'); + } + + if (gift.userId === 0) { + throw new Error('此礼品码未认领'); + } + + // 执行核销 + await updateShopGift({ + ...gift, + status: 1, + operatorUserId: Number(Taro.getStorageSync('UserId')) || 0, + takeTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), + verificationTime: dayjs().format('YYYY-MM-DD HH:mm:ss') + }); + + return { + type: ScanType.VERIFICATION, + data: gift, + message: '核销成功' + }; + }, [isAdmin]); + + /** + * 开始扫码 + */ + const startScan = useCallback(async (): Promise => { + try { + reset(); + setState(UnifiedScanState.SCANNING); + + // 调用扫码API + const scanResult = await new Promise((resolve, reject) => { + Taro.scanCode({ + onlyFromCamera: true, + scanType: ['qrCode'], + success: (res) => { + if (res.result) { + resolve(res.result); + } else { + reject(new Error('扫码结果为空')); + } + }, + fail: (err) => { + reject(new Error(err.errMsg || '扫码失败')); + } + }); + }); + + // 检查是否被取消 + if (cancelRef.current) { + return null; + } + + // 检测二维码类型 + const type = detectScanType(scanResult); + setScanType(type); + + if (type === ScanType.UNKNOWN) { + throw new Error('不支持的二维码类型'); + } + + // 开始处理 + setState(UnifiedScanState.PROCESSING); + setIsLoading(true); + + let result: UnifiedScanResult; + + switch (type) { + case ScanType.LOGIN: + result = await handleLoginQR(scanResult); + break; + case ScanType.VERIFICATION: + result = await handleVerificationQR(scanResult); + break; + default: + throw new Error('未知的扫码类型'); + } + + if (cancelRef.current) { + return null; + } + + setState(UnifiedScanState.SUCCESS); + setResult(result); + + // 显示成功提示 + Taro.showToast({ + title: result.message, + icon: 'success', + duration: 2000 + }); + + return result; + + } catch (err: any) { + if (!cancelRef.current) { + setState(UnifiedScanState.ERROR); + const errorMessage = err.message || '处理失败'; + setError(errorMessage); + + // 显示错误提示 + Taro.showToast({ + title: errorMessage, + icon: 'error', + duration: 3000 + }); + } + return null; + } finally { + setIsLoading(false); + } + }, [reset, detectScanType, handleLoginQR, handleVerificationQR]); + + /** + * 取消扫码 + */ + const cancel = useCallback(() => { + cancelRef.current = true; + reset(); + }, [reset]); + + /** + * 检查是否可以进行扫码 + */ + const canScan = useCallback(() => { + const userId = Taro.getStorageSync('UserId'); + const accessToken = Taro.getStorageSync('access_token'); + return !!(userId && accessToken); + }, []); + + // 组件卸载时取消操作 + useEffect(() => { + return () => { + cancelRef.current = true; + }; + }, []); + + return { + // 状态 + state, + error, + result, + isLoading, + scanType, + + // 方法 + startScan, + cancel, + reset, + canScan, + + // 便捷状态判断 + isIdle: state === UnifiedScanState.IDLE, + isScanning: state === UnifiedScanState.SCANNING, + isProcessing: state === UnifiedScanState.PROCESSING, + isSuccess: state === UnifiedScanState.SUCCESS, + isError: state === UnifiedScanState.ERROR + }; +} diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts new file mode 100644 index 0000000..439562b --- /dev/null +++ b/src/hooks/useUser.ts @@ -0,0 +1,334 @@ +import { useState, useEffect } from 'react'; +import Taro from '@tarojs/taro'; +import { User } from '@/api/system/user/model'; +import { getUserInfo, updateUserInfo, loginByOpenId } from '@/api/layout'; +import {getStoredInviteParams, handleInviteRelation} from '@/utils/invite'; + +// 用户Hook +export const useUser = () => { + const [user, setUser] = useState(null); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [loading, setLoading] = useState(true); + + // 自动登录(通过OpenID) + const autoLoginByOpenId = async () => { + try { + const res = await new Promise((resolve, reject) => { + Taro.login({ + success: (loginRes) => { + loginByOpenId({ + code: loginRes.code, + tenantId: 10519 + }).then(async (data) => { + if (data) { + // 保存登录信息 + saveUserToStorage(data.access_token, data.user); + setUser(data.user); + setIsLoggedIn(true); + + // 处理邀请关系 + if (data.user?.userId) { + try { + const inviteSuccess = await handleInviteRelation(data.user.userId); + if (inviteSuccess) { + console.log('自动登录时邀请关系建立成功'); + } + } catch (error) { + console.error('自动登录时处理邀请关系失败:', error); + } + } + + resolve(data.user); + } else { + reject(new Error('自动登录失败')); + } + }).catch(_ => { + // 首次注册,跳转到邀请注册页面 + const pages = Taro.getCurrentPages(); + const currentPage = pages[pages.length - 1]; + const inviteParams = getStoredInviteParams() + if (currentPage?.route !== 'dealer/apply/add' && inviteParams?.inviter) { + return Taro.navigateTo({ + url: '/dealer/apply/add' + }); + } + }); + }, + fail: reject + }); + }); + return res; + } catch (error) { + console.error('自动登录失败:', error); + return null; + } + }; + + // 从本地存储加载用户数据 + const loadUserFromStorage = async () => { + try { + const token = Taro.getStorageSync('access_token'); + const userData = Taro.getStorageSync('User'); + const userId = Taro.getStorageSync('UserId'); + const tenantId = Taro.getStorageSync('TenantId'); + + if (token && userData) { + const userInfo = typeof userData === 'string' ? JSON.parse(userData) : userData; + setUser(userInfo); + setIsLoggedIn(true); + } else if (token && userId) { + // 如果有token和userId但没有完整用户信息,标记为已登录但需要获取用户信息 + setIsLoggedIn(true); + setUser({ userId, tenantId } as User); + } else { + // 没有本地登录信息,尝试自动登录 + console.log('没有本地登录信息,尝试自动登录...'); + const autoLoginResult = await autoLoginByOpenId(); + if (!autoLoginResult) { + setUser(null); + setIsLoggedIn(false); + } + } + } catch (error) { + console.error('加载用户数据失败:', error); + setUser(null); + setIsLoggedIn(false); + } finally { + setLoading(false); + } + }; + + // 保存用户数据到本地存储 + const saveUserToStorage = (token: string, userInfo: User) => { + try { + Taro.setStorageSync('access_token', token); + Taro.setStorageSync('User', userInfo); + + // 确保关键字段不为空时才保存,避免覆盖现有数据 + if (userInfo.userId) { + Taro.setStorageSync('UserId', userInfo.userId); + } + if (userInfo.tenantId) { + Taro.setStorageSync('TenantId', userInfo.tenantId); + } + if (userInfo.phone) { + Taro.setStorageSync('Phone', userInfo.phone); + } + // 保存头像和昵称信息 + if (userInfo.avatar) { + Taro.setStorageSync('Avatar', userInfo.avatar); + } + if (userInfo.nickname) { + Taro.setStorageSync('Nickname', userInfo.nickname); + } + } catch (error) { + console.error('保存用户数据失败:', error); + } + }; + + // 登录用户 + const loginUser = (token: string, userInfo: User) => { + setUser(userInfo); + setIsLoggedIn(true); + saveUserToStorage(token, userInfo); + }; + + // 退出登录 + const logoutUser = () => { + setUser(null); + setIsLoggedIn(false); + + // 清除本地存储 + try { + Taro.removeStorageSync('access_token'); + Taro.removeStorageSync('User'); + Taro.removeStorageSync('UserId'); + Taro.removeStorageSync('TenantId'); + Taro.removeStorageSync('Phone'); + Taro.removeStorageSync('userInfo'); + } catch (error) { + console.error('清除用户数据失败:', error); + } + }; + + // 从服务器获取最新用户信息 + const fetchUserInfo = async () => { + if (!isLoggedIn) { + return null; + } + + try { + setLoading(true); + const userInfo = await getUserInfo(); + setUser(userInfo); + + // 更新本地存储 + const token = Taro.getStorageSync('access_token'); + if (token) { + saveUserToStorage(token, userInfo); + } + + return userInfo; + } catch (error) { + console.error('获取用户信息失败:', error); + // 如果获取失败,可能是token过期,清除登录状态 + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage?.includes('401') || errorMessage?.includes('未授权')) { + logoutUser(); + } + return null; + } finally { + setLoading(false); + } + }; + + // 更新用户信息 + const updateUser = async (userData: Partial) => { + if (!user) { + throw new Error('用户未登录'); + } + + try { + // 先获取最新的用户信息,确保我们有完整的数据 + const latestUserInfo = await getUserInfo(); + + // 合并最新的用户信息和要更新的数据 + const updatedUser = { ...latestUserInfo, ...userData }; + + // 调用API更新用户信息 + await updateUserInfo(updatedUser); + + // 更新本地状态 + setUser(updatedUser); + + // 更新本地存储 + const token = Taro.getStorageSync('access_token'); + if (token) { + saveUserToStorage(token, updatedUser); + } + + Taro.showToast({ + title: '更新成功', + icon: 'success', + duration: 1500 + }); + + return updatedUser; + } catch (error) { + console.error('更新用户信息失败:', error); + Taro.showToast({ + title: '更新失败', + icon: 'error', + duration: 1500 + }); + throw error; + } + }; + + // 检查是否有特定权限 + const hasPermission = (permission: string) => { + if (!user || !user.authorities) { + return false; + } + return user.authorities.some(auth => auth.authority === permission); + }; + + // 检查是否有特定角色 + const hasRole = (roleCode: string) => { + if (!user || !user.roles) { + return false; + } + return user.roles.some(role => role.roleCode === roleCode); + }; + + // 获取用户头像URL + const getAvatarUrl = () => { + return user?.avatar || user?.avatarUrl || ''; + }; + + const getUserId = () => { + return user?.userId; + }; + + // 获取用户显示名称 + const getDisplayName = () => { + return user?.nickname || user?.realName || user?.username || '未登录'; + }; + + // 获取用户显示的角色(同步版本) + const getRoleName = () => { + if(hasRole('superAdmin')){ + return '超级管理员'; + } + if(hasRole('admin')){ + return '管理员'; + } + if(hasRole('staff')){ + return '员工'; + } + if(hasRole('vip')){ + return 'VIP会员'; + } + return '注册用户'; + } + + // 检查用户是否已实名认证 + const isCertified = () => { + return user?.certification === true; + }; + + // 检查用户是否是管理员 + const isAdmin = () => { + return user?.isAdmin === true; + }; + + const isSuperAdmin = () => { + return user?.isSuperAdmin === true; + }; + + // 获取用户余额 + const getBalance = () => { + return user?.balance || 0; + }; + + // 获取用户积分 + const getPoints = () => { + return user?.points || 0; + }; + + // 初始化时加载用户数据 + useEffect(() => { + loadUserFromStorage().catch(error => { + console.error('初始化用户数据失败:', error); + setLoading(false); + }); + }, []); + + return { + // 状态 + user, + isLoggedIn, + loading, + + // 方法 + loginUser, + logoutUser, + fetchUserInfo, + updateUser, + loadUserFromStorage, + autoLoginByOpenId, + + // 工具方法 + hasPermission, + hasRole, + getAvatarUrl, + getDisplayName, + getRoleName, + isCertified, + isAdmin, + getBalance, + getPoints, + getUserId, + isSuperAdmin + }; +}; diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts new file mode 100644 index 0000000..fcc0423 --- /dev/null +++ b/src/hooks/useUserData.ts @@ -0,0 +1,136 @@ +import { useState, useEffect, useCallback } from 'react' +import {pageShopUserCoupon} from "@/api/shop/shopUserCoupon"; +import {pageShopGift} from "@/api/shop/shopGift"; +import {useUser} from "@/hooks/useUser"; +import Taro from '@tarojs/taro' +import {getUserInfo} from "@/api/layout"; + +interface UserData { + balance: number + points: number + coupons: number + giftCards: number + orders: { + pending: number + paid: number + shipped: number + completed: number + refund: number + } +} + +interface UseUserDataReturn { + data: UserData | null + loading: boolean + error: string | null + refresh: () => Promise + updateBalance: (newBalance: number) => void + updatePoints: (newPoints: number) => void +} + +export const useUserData = (): UseUserDataReturn => { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // 获取用户数据 + const fetchUserData = useCallback(async () => { + try { + setLoading(true) + setError(null) + + if(!Taro.getStorageSync('UserId')){ + return; + } + + // 并发请求所有数据 + const [userDataRes, couponsRes, giftCardsRes] = await Promise.all([ + getUserInfo(), + pageShopUserCoupon({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), status: 0}), + pageShopGift({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), status: 0}) + ]) + + const newData: UserData = { + balance: userDataRes?.balance || 0.00, + points: userDataRes?.points || 0, + coupons: couponsRes?.count || 0, + giftCards: giftCardsRes?.count || 0, + orders: { + pending: 0, + paid: 0, + shipped: 0, + completed: 0, + refund: 0 + } + } + + setData(newData) + } catch (err) { + setError(err instanceof Error ? err.message : '获取用户数据失败') + } finally { + setLoading(false) + } + }, []) + + // 刷新数据 + const refresh = useCallback(async () => { + await fetchUserData() + }, [fetchUserData]) + + // 更新余额(本地更新,避免频繁请求) + const updateBalance = useCallback((newBalance: number) => { + setData(prev => prev ? { ...prev, balance: newBalance } : null) + }, []) + + // 更新积分 + const updatePoints = useCallback((newPoints: number) => { + setData(prev => prev ? { ...prev, points: newPoints } : null) + }, []) + + // 初始化加载 + useEffect(() => { + fetchUserData().then() + }, [fetchUserData]) + + return { + data, + loading, + error, + refresh, + updateBalance, + updatePoints + } +} + +// 轻量级版本 - 只获取基础数据 +export const useUserBasicData = () => { + const {user} = useUser() + const [balance, setBalance] = useState(0) + const [points, setPoints] = useState(0) + const [loading, setLoading] = useState(false) + + const fetchBasicData = useCallback(async () => { + setLoading(true) + try { + setBalance(user?.balance || 0) + setPoints(user?.points || 0) + } catch (error) { + console.error('获取基础数据失败:', error) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchBasicData().then() + }, [fetchBasicData]) + + return { + balance, + points, + loading, + refresh: fetchBasicData, + updateBalance: setBalance, + updatePoints: setPoints + } +} diff --git a/src/pages/index/ExpirationTime.tsx b/src/pages/index/ExpirationTime.tsx index 55e1398..a1138c1 100644 --- a/src/pages/index/ExpirationTime.tsx +++ b/src/pages/index/ExpirationTime.tsx @@ -5,17 +5,177 @@ import {Target, Scan, Truck} from '@nutui/icons-react-taro' import {getUserInfo} from "@/api/layout"; import navTo from "@/utils/common"; import {pageHjmCar} from "@/api/hjm/hjmCar"; +import { ScanType } from '@/hooks/useUnifiedQRScan'; +import { isValidJSON } from '@/utils/jsonUtils'; +import { parseQRContent, confirmWechatQRLogin } from '@/api/passport/qr-login'; +import { getShopGiftByCode, updateShopGift, decryptQrData } from "@/api/shop/shopGift"; +import { useUser } from '@/hooks/useUser'; +import dayjs from 'dayjs'; const ExpirationTime = () => { const [isAdmin, setIsAdmin] = useState(false) const [roleName, setRoleName] = useState() + const { isAdmin: isUserAdmin } = useUser(); + + // 检测二维码类型 + const detectScanType = (scanResult: string): ScanType => { + try { + // 1. 检查是否为JSON格式(核销二维码) + if (isValidJSON(scanResult)) { + const json = JSON.parse(scanResult); + if (json.businessType === 'gift' && json.token && json.data) { + return ScanType.VERIFICATION; + } + } + + // 2. 检查是否为登录二维码 + const loginToken = parseQRContent(scanResult); + if (loginToken) { + return ScanType.LOGIN; + } + + // 3. 检查是否为纯文本核销码(6位数字) + if (/^\d{6}$/.test(scanResult.trim())) { + return ScanType.VERIFICATION; + } + + return ScanType.UNKNOWN; + } catch (error) { + console.error('检测二维码类型失败:', error); + return ScanType.UNKNOWN; + } + }; + + // 处理登录二维码 + const handleLoginQR = async (scanResult: string) => { + const userId = Taro.getStorageSync('UserId'); + if (!userId) { + throw new Error('请先登录小程序'); + } + + const token = parseQRContent(scanResult); + if (!token) { + throw new Error('无效的登录二维码'); + } + + const confirmResult = await confirmWechatQRLogin(token, parseInt(userId)); + if (confirmResult.success || confirmResult.status === 'confirmed') { + Taro.showToast({ + title: '登录确认成功', + icon: 'success', + duration: 2000 + }); + return true; + } else { + throw new Error(confirmResult.message || '登录确认失败'); + } + }; + + // 处理核销二维码 + const handleVerificationQR = async (scanResult: string) => { + if (!isUserAdmin()) { + throw new Error('您没有核销权限'); + } + + let code = ''; + + // 判断是否为加密的JSON格式 + if (isValidJSON(scanResult)) { + const json = JSON.parse(scanResult); + if (json.businessType === 'gift' && json.token && json.data) { + // 解密获取核销码 + const decryptedData = await decryptQrData({ + token: json.token, + encryptedData: json.data + }); + + if (decryptedData) { + code = decryptedData.toString(); + } else { + throw new Error('解密失败'); + } + } + } else { + // 直接使用扫码结果作为核销码 + code = scanResult.trim(); + } + + if (!code) { + throw new Error('无法获取有效的核销码'); + } + + // 验证核销码 + const gift = await getShopGiftByCode(code); + + if (!gift) { + throw new Error('核销码无效'); + } + + if (gift.status === 1) { + throw new Error('此礼品码已使用'); + } + + if (gift.status === 2) { + throw new Error('此礼品码已失效'); + } + + if (gift.userId === 0) { + throw new Error('此礼品码未认领'); + } + + // 执行核销 + await updateShopGift({ + ...gift, + status: 1, + operatorUserId: Number(Taro.getStorageSync('UserId')) || 0, + takeTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), + verificationTime: dayjs().format('YYYY-MM-DD HH:mm:ss') + }); + + Taro.showToast({ + title: '核销成功', + icon: 'success', + duration: 2000 + }); + return true; + }; + const onScanCode = () => { Taro.scanCode({ onlyFromCamera: true, scanType: ['qrCode'], - success: (res) => { + success: async (res) => { console.log(res, 'qrcode...') - Taro.navigateTo({url: '/hjm/query?id=' + res.result}) + const scanContent = res.result; + + // 检测二维码类型 + const scanType = detectScanType(scanContent); + + try { + if (scanType === ScanType.LOGIN) { + // 处理登录二维码 + await handleLoginQR(scanContent); + console.log('登录二维码处理成功'); + return; + } else if (scanType === ScanType.VERIFICATION) { + // 处理核销二维码 + await handleVerificationQR(scanContent); + console.log('核销二维码处理成功'); + return; + } + } catch (error: any) { + console.log('特殊二维码处理失败:', error.message); + Taro.showToast({ + title: error.message, + icon: 'error', + duration: 2000 + }); + return; + } + + // 如果不是特殊二维码,作为车辆查询处理 + console.log('作为车辆查询二维码处理:', scanContent); + Taro.navigateTo({url: '/hjm/query?id=' + scanContent}); }, fail: (res) => { console.log(res, '扫码失败') diff --git a/src/pages/index/Login.tsx b/src/pages/index/Login.tsx index 03d6df1..a299192 100644 --- a/src/pages/index/Login.tsx +++ b/src/pages/index/Login.tsx @@ -2,8 +2,13 @@ import {useEffect, useState} from "react"; import Taro from '@tarojs/taro' import {Input, Radio, Button} from '@nutui/nutui-react-taro' import './login.scss'; +import {User} from "@/api/system/user/model"; -const Login = () => { +interface LoginProps { + done?: (data: User) => void; +} + +const Login: React.FC = ({ done }) => { const [isAgree, setIsAgree] = useState(false) const [env, setEnv] = useState() diff --git a/src/utils/invite.ts b/src/utils/invite.ts new file mode 100644 index 0000000..634b34c --- /dev/null +++ b/src/utils/invite.ts @@ -0,0 +1,484 @@ +import Taro from '@tarojs/taro' +import { bindRefereeRelation } from '@/api/invite' + +/** + * 邀请参数接口 + */ +export interface InviteParams { + inviter?: string; + source?: string; + t?: string; +} + +/** + * 解析小程序启动参数中的邀请信息 + */ +export function parseInviteParams(options: any): InviteParams | null { + try { + // 优先从 query.scene 参数中解析邀请信息 + let sceneStr: string | null = null + if (options.query && options.query.scene) { + sceneStr = typeof options.query.scene === 'string' ? options.query.scene : String(options.query.scene) + } else if (options.scene) { + // 兼容直接从 scene 参数解析 + sceneStr = typeof options.scene === 'string' ? options.scene : String(options.scene) + } + + // 从 scene 参数中解析邀请信息 + if (sceneStr) { + // 处理 uid_xxx 格式的邀请码 + if (sceneStr.startsWith('uid_')) { + const inviterId = sceneStr.replace('uid_', '') + + if (inviterId && !isNaN(parseInt(inviterId))) { + return { + inviter: inviterId, + source: 'qrcode', + t: Date.now().toString() + } + } + } + + // 处理传统的 key=value&key=value 格式 + const params: InviteParams = {} + const pairs = sceneStr.split('&') + + pairs.forEach((pair: string) => { + const [key, value] = pair.split('=') + if (key && value) { + switch (key) { + case 'inviter': + params.inviter = decodeURIComponent(value) + break + case 'source': + params.source = decodeURIComponent(value) + break + case 't': + params.t = decodeURIComponent(value) + break + } + } + }) + + if (params.inviter) { + return params + } + } + + // 从 query 参数中解析邀请信息(处理首页分享链接) + if (options.query) { + const query = options.query + if (query.inviter) { + return { + inviter: query.inviter, + source: query.source || 'share', + t: query.t + } + } + + // 兼容旧版本 + if (query.referrer) { + return { + inviter: query.referrer, + source: 'link' + } + } + } + + return null + } catch (error) { + console.error('解析邀请参数失败:', error) + return null + } +} + +/** + * 保存邀请信息到本地存储 + */ +export function saveInviteParams(params: InviteParams) { + try { + const saveData = { + ...params, + timestamp: Date.now() + } + + Taro.setStorageSync('invite_params', saveData) + } catch (error) { + console.error('保存邀请参数失败:', error) + } +} + +/** + * 获取本地存储的邀请信息 + */ +export function getStoredInviteParams(): InviteParams | null { + try { + const stored = Taro.getStorageSync('invite_params') + + if (stored && stored.inviter) { + // 检查是否过期(24小时) + const now = Date.now() + const expireTime = 24 * 60 * 60 * 1000 // 24小时 + + if (now - stored.timestamp < expireTime) { + return { + inviter: stored.inviter, + source: stored.source || 'unknown', + t: stored.t + } + } else { + // 过期则清除 + clearInviteParams() + } + } + return null + } catch (error) { + console.error('获取邀请参数失败:', error) + return null + } +} + +/** + * 清除本地存储的邀请信息 + */ +export function clearInviteParams() { + try { + Taro.removeStorageSync('invite_params') + } catch (error) { + console.error('清除邀请参数失败:', error) + } +} + +/** + * 处理邀请关系建立 + */ +export async function handleInviteRelation(userId: number): Promise { + try { + const inviteParams = getStoredInviteParams() + if (!inviteParams || !inviteParams.inviter) { + return false + } + + const inviterId = parseInt(inviteParams.inviter) + if (isNaN(inviterId) || inviterId === userId) { + // 邀请人ID无效或自己邀请自己 + clearInviteParams() + return false + } + + // 防重复检查:检查是否已经处理过这个邀请关系 + const relationKey = `invite_relation_${inviterId}_${userId}` + const existingRelation = Taro.getStorageSync(relationKey) + + if (existingRelation) { + clearInviteParams() // 清除邀请参数 + return true // 返回true表示关系已存在 + } + + // 设置API调用超时 + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('API调用超时')), 5000) + ); + + // 使用新的绑定推荐关系接口 + const apiPromise = bindRefereeRelation({ + dealerId: inviterId, + userId: userId, + source: inviteParams.source || 'qrcode', + scene: inviteParams.source === 'qrcode' ? `uid_${inviterId}` : `inviter=${inviterId}&source=${inviteParams.source}&t=${inviteParams.t}` + }); + + // 等待API调用完成或超时 + await Promise.race([apiPromise, timeoutPromise]); + + // 标记邀请关系已处理(设置过期时间为7天) + Taro.setStorageSync(relationKey, { + inviterId, + userId, + timestamp: Date.now(), + source: inviteParams.source || 'qrcode' + }) + + // 清除本地存储的邀请参数 + clearInviteParams() + + return true + } catch (error) { + console.error('建立邀请关系失败:', error) + + // 如果是网络错误或超时,不清除邀请参数,允许稍后重试 + const errorMessage = error instanceof Error ? error.message : String(error) + if (errorMessage.includes('超时') || errorMessage.includes('网络')) { + console.log('网络问题,保留邀请参数供稍后重试') + return false + } + + // 其他错误(如业务逻辑错误),清除邀请参数 + clearInviteParams() + return false + } +} + +/** + * 检查是否有待处理的邀请 + */ +export function hasPendingInvite(): boolean { + const params = getStoredInviteParams() + return !!(params && params.inviter) +} + +/** + * 获取邀请来源的显示名称 + */ +export function getSourceDisplayName(source: string): string { + const sourceMap: Record = { + 'qrcode': '小程序码', + 'link': '分享链接', + 'share': '好友分享', + 'poster': '海报分享', + 'unknown': '未知来源' + } + + return sourceMap[source] || source +} + +/** + * 验证邀请码格式 + */ +export function validateInviteCode(scene: string): boolean { + try { + if (!scene) return false + + // 检查是否包含必要的参数 + const hasInviter = scene.includes('inviter=') + const hasSource = scene.includes('source=') + + return hasInviter && hasSource + } catch (error) { + return false + } +} + +/** + * 生成邀请场景值 + */ +export function generateInviteScene(inviterId: number, source: string): string { + const timestamp = Date.now() + return `inviter=${inviterId}&source=${source}&t=${timestamp}` +} + +/** + * 统计邀请来源 + */ +export function trackInviteSource(source: string, inviterId?: number) { + try { + // 记录邀请来源统计 + const trackData = { + source, + inviterId, + timestamp: Date.now(), + userAgent: Taro.getSystemInfoSync() + } + + // 可以发送到统计服务 + console.log('邀请来源统计:', trackData) + + // 暂存到本地,后续可批量上报 + const existingTracks = Taro.getStorageSync('invite_tracks') || [] + existingTracks.push(trackData) + + // 只保留最近100条记录 + if (existingTracks.length > 100) { + existingTracks.splice(0, existingTracks.length - 100) + } + + Taro.setStorageSync('invite_tracks', existingTracks) + } catch (error) { + console.error('统计邀请来源失败:', error) + } +} + +/** + * 调试工具:打印所有邀请相关的存储信息 + */ +export function debugInviteInfo() { + try { + console.log('=== 邀请参数调试信息 ===') + + // 获取启动参数 + const launchOptions = Taro.getLaunchOptionsSync() + console.log('启动参数:', JSON.stringify(launchOptions, null, 2)) + + // 获取存储的邀请参数 + const storedParams = Taro.getStorageSync('invite_params') + console.log('存储的邀请参数:', JSON.stringify(storedParams, null, 2)) + + // 获取用户信息 + const userId = Taro.getStorageSync('UserId') + const userInfo = Taro.getStorageSync('userInfo') + console.log('用户ID:', userId) + console.log('用户信息:', JSON.stringify(userInfo, null, 2)) + + // 获取邀请统计 + const inviteTracks = Taro.getStorageSync('invite_tracks') + console.log('邀请统计:', JSON.stringify(inviteTracks, null, 2)) + + console.log('=== 调试信息结束 ===') + + return { + launchOptions, + storedParams, + userId, + userInfo, + inviteTracks + } + } catch (error) { + console.error('获取调试信息失败:', error) + return null + } +} + +/** + * 检查并处理当前用户的邀请关系 + * 用于在用户登录后立即检查是否需要建立邀请关系 + */ +export async function checkAndHandleInviteRelation(): Promise { + try { + // 清理过期的防重记录 + cleanExpiredInviteRelations() + + // 获取当前用户信息 + const userInfo = Taro.getStorageSync('userInfo') + const userId = Taro.getStorageSync('UserId') + + const finalUserId = userId || userInfo?.userId + + if (!finalUserId) { + console.log('用户未登录,无法处理邀请关系') + return false + } + + console.log('使用用户ID处理邀请关系:', finalUserId) + + // 设置整体超时保护 + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('邀请关系处理整体超时')), 6000) + ); + + const handlePromise = handleInviteRelation(parseInt(finalUserId)); + + return await Promise.race([handlePromise, timeoutPromise]); + } catch (error) { + console.error('检查邀请关系失败:', error) + + // 记录失败次数,避免无限重试 + const failKey = 'invite_handle_fail_count' + const failCount = Taro.getStorageSync(failKey) || 0 + + if (failCount >= 3) { + console.log('邀请关系处理失败次数过多,清除邀请参数') + clearInviteParams() + Taro.removeStorageSync(failKey) + } else { + Taro.setStorageSync(failKey, failCount + 1) + } + + return false + } +} + +/** + * 手动触发邀请关系建立 + * 用于在特定页面或时机手动建立邀请关系 + */ +export async function manualHandleInviteRelation(userId: number): Promise { + try { + console.log('手动触发邀请关系建立,用户ID:', userId) + + const inviteParams = getStoredInviteParams() + if (!inviteParams || !inviteParams.inviter) { + console.log('没有待处理的邀请参数') + return false + } + + const result = await handleInviteRelation(userId) + + if (result) { + // 显示成功提示 + Taro.showModal({ + title: '邀请成功', + content: '您已成功加入邀请人的团队!', + showCancel: false, + confirmText: '知道了' + }) + } + + return result + } catch (error) { + console.error('手动处理邀请关系失败:', error) + return false + } +} + +/** + * 清理过期的邀请关系防重记录 + */ +export function cleanExpiredInviteRelations() { + try { + const keys = Taro.getStorageInfoSync().keys + const expireTime = 7 * 24 * 60 * 60 * 1000 // 7天 + const now = Date.now() + + keys.forEach(key => { + if (key.startsWith('invite_relation_')) { + try { + const data = Taro.getStorageSync(key) + if (data && data.timestamp && (now - data.timestamp > expireTime)) { + Taro.removeStorageSync(key) + } + } catch (error) { + // 如果读取失败,直接删除 + Taro.removeStorageSync(key) + } + } + }) + } catch (error) { + console.error('清理过期邀请关系记录失败:', error) + } +} + +/** + * 直接绑定推荐关系 + * 用于直接调用绑定推荐关系接口 + */ +export async function bindReferee(refereeId: number, userId?: number, source: string = 'qrcode'): Promise { + try { + // 如果没有传入userId,尝试从本地存储获取 + let targetUserId = userId + if (!targetUserId) { + const userInfo = Taro.getStorageSync('userInfo') + if (userInfo && userInfo.userId) { + targetUserId = userInfo.userId + } else { + throw new Error('无法获取用户ID') + } + } + + // 防止自己推荐自己 + if (refereeId === targetUserId) { + throw new Error('不能推荐自己') + } + + await bindRefereeRelation({ + dealerId: refereeId, + userId: targetUserId, + source: source, + scene: source === 'qrcode' ? `uid_${refereeId}` : undefined + }) + + return true + } catch (error: any) { + console.error('绑定推荐关系失败:', error) + return false + } +} diff --git a/src/utils/jsonUtils.ts b/src/utils/jsonUtils.ts new file mode 100644 index 0000000..8983ad0 --- /dev/null +++ b/src/utils/jsonUtils.ts @@ -0,0 +1,31 @@ +/** + * 判断字符串是否为有效的JSON格式 + * @param str 要检测的字符串 + * @returns boolean + */ +export function isValidJSON(str: string): boolean { + if (typeof str !== 'string' || str.trim() === '') { + return false; + } + + try { + JSON.parse(str); + return true; + } catch (error) { + return false; + } +} + +/** + * 安全解析JSON,失败时返回默认值 + * @param str JSON字符串 + * @param defaultValue 默认值 + * @returns 解析结果或默认值 + */ +export function safeJSONParse(str: string, defaultValue: T): T { + try { + return JSON.parse(str); + } catch (error) { + return defaultValue; + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 18a1b6a..a71efcd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,7 +25,8 @@ "@/components/*": ["./src/components/*"], "@/utils/*": ["./src/utils/*"], "@/assets/*": ["./src/assets/*"], - "@/api/*": ["./src/api/*"] + "@/api/*": ["./src/api/*"], + "@/hooks/*": ["./src/hooks/*"] } }, "include": ["./src", "./types"],