feat(user): 实现扫码登录和推荐人绑定功能- 添加扫码登录相关API接口,包括生成二维码、检查状态、确认登录等

- 在用户注册时支持从邀请参数中获取推荐人ID并绑定
- 修改管理员面板UI,添加统一扫码功能入口- 更新用户管理相关API地址,统一使用SERVER_API_URL
- 调整优惠券卡片样式,移除小程序不支持的CSS属性
- 添加聊天会话和消息管理相关API模块
- 新增分销商银行卡管理API接口
- 修改系统用户模型,增加推荐人ID字段
- 更新广告位查询接口,支持根据code获取广告位
- 调整邀请绑定接口参数,将refereeId改为dealerId
- 修改环境配置中的应用名称为"时里院子市集"
- 移除分享到朋友圈的相关代码
- 添加管理员面板组件,提供统一扫码等管理功能
-修复用户管理API请求参数传递问题
- 添加聊天消息和会话管理的完整CRUD接口
- 更新系统用户相关接口URL,确保正确调用后端服务
- 添加分销商银行卡管理的完整API接口实现
- 修改邀请绑定接口,使用dealerId替代refereeId参数
- 修复扫码登录相关API的URL拼接问题
- 添加二维码内容解析功能,支持多种格式的token提取
- 更新用户信息模型,增加推荐人ID字段
-优化管理员面板样式和交互逻辑- 调整优惠券组件样式,兼容小程序环境限制- 修复用户管理模块的API调用问题
- 添加聊天相关数据模型和接口定义
- 更新环境配置中的应用名称
-修复邀请绑定相关的参数传递问题- 添加扫码登录状态枚举和相关数据结构定义- 优化管理员功能面板的UI展示和交互体验- 修复系统用户管理接口的请求参数问题
- 添加分销商银行卡管理相关接口实现- 调整聊天消息和会话管理接口的数据结构定义
-修复用户管理模块中的API调用路径问题
- 添加扫码登录相关工具函数,如设备信息获取等
- 更新邀请绑定接口的数据模型定义
-优化管理员面板组件的样式和功能实现
-修复系统用户管理接口中的参数传递问题
- 添加聊天相关模块的完整API接口实现
- 调整分销商银行卡管理模块的数据结构定义- 修复扫码登录相关接口的URL拼接问题- 更新用户管理模块中的API调用方式
- 添加聊天消息批量发送等相关接口实现- 修复邀请绑定接口中的参数名称问题- 优化管理员面板组件的功能和交互逻辑
- 调整系统用户管理接口的请求参数传递方式
- 添加分销商银行卡管理模块的完整接口实现
-修复聊天相关接口中的数据结构问题
- 更新扫码登录相关接口的数据模型定义
- 优化管理员功能面板的展示效果和用户体验
This commit is contained in:
2025-09-24 19:23:56 +08:00
parent 3d5b77ae51
commit 87d6648989
131 changed files with 11406 additions and 1446 deletions

View File

@@ -9,7 +9,7 @@ export const ENV_CONFIG = {
// 生产环境
production: {
API_BASE_URL: 'https://cms-api.websoft.top/api',
APP_NAME: '通源堂健康生态平台',
APP_NAME: '时里院子市集',
DEBUG: 'false',
},
// 测试环境

View File

@@ -1,7 +1,7 @@
{
"miniprogramRoot": "./",
"projectname": "mp-react",
"description": "通源堂健康生态平台",
"description": "时里院子市集",
"appid": "touristappid",
"setting": {
"urlCheck": true,

View File

@@ -8,6 +8,7 @@ import navTo from "@/utils/common";
import {TenantId} from "@/config/app";
import {getMyAvailableCoupons} from "@/api/shop/shopUserCoupon";
import {useUser} from "@/hooks/useUser";
import {getStoredInviteParams} from "@/utils/invite";
function UserCard() {
const {getDisplayName, getRoleName} = useUser();
@@ -134,6 +135,11 @@ function UserCard() {
/* 获取用户手机号 */
const handleGetPhoneNumber = ({detail}: {detail: {code?: string, encryptedData?: string, iv?: string}}) => {
const {code, encryptedData, iv} = detail
// 获取存储的邀请参数
const inviteParams = getStoredInviteParams()
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0
Taro.login({
success: function () {
if (code) {
@@ -145,7 +151,7 @@ function UserCard() {
encryptedData,
iv,
notVerifyPhone: true,
refereeId: 0,
refereeId: refereeId, // 使用解析出的推荐人ID
sceneType: 'save_referee',
tenantId: TenantId
},

View File

@@ -1,5 +1,6 @@
import {useEffect} from 'react'
import {useUser} from "@/hooks/useUser";
import {Empty} from '@nutui/nutui-react-taro';
import {Text} from '@tarojs/components';
function Admin() {
@@ -12,7 +13,16 @@ function Admin() {
if (!isAdmin()) {
return (
<Text></Text>
<Empty
description="您不是管理员"
imageSize={80}
style={{
backgroundColor: 'transparent',
height: 'calc(100vh - 200px)'
}}
>
</Empty>
);
}
return (

View File

@@ -100,3 +100,16 @@ export async function getCmsAd(id: number) {
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询广告位
*/
export async function getCmsAdByCode(code: string) {
const res = await request.get<ApiResult<CmsAd>>(
'/cms/cms-ad/getByCode/' + code
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -1,5 +1,6 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import { BaseUrl } from '@/config/app';
/**
* 小程序码生成参数
@@ -38,7 +39,7 @@ export interface InviteRelationParam {
*/
export interface BindRefereeParam {
// 推荐人ID
refereeId: number;
dealerId: number;
// 被推荐人ID (可选,如果不传则使用当前登录用户)
userId?: number;
// 推荐来源
@@ -112,7 +113,7 @@ export async function generateMiniProgramCode(data: MiniProgramCodeParam) {
try {
const url = '/wx-login/getOrderQRCodeUnlimited/' + data.scene;
// 由于接口直接返回图片buffer我们直接构建完整的URL
return `${API_BASE_URL}${url}`;
return `${BaseUrl}${url}`;
} catch (error: any) {
throw new Error(error.message || '生成小程序码失败');
}
@@ -155,7 +156,7 @@ export async function bindRefereeRelation(data: BindRefereeParam) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-dealer-referee',
{
refereeId: data.refereeId,
dealerId: data.dealerId,
userId: data.userId,
source: data.source || 'qrcode',
scene: data.scene

View File

@@ -0,0 +1,246 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
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<ApiResult<GenerateQRTokenResult>>(
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<ApiResult<QRLoginStatusResult>>(
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<ApiResult<ConfirmLoginResult>>(
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<ApiResult<any>>(
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<string>((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;
}
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopChatConversation, ShopChatConversationParam } from './model';
/**
* 分页查询聊天会话表
*/
export async function pageShopChatConversation(params: ShopChatConversationParam) {
const res = await request.get<ApiResult<PageResult<ShopChatConversation>>>(
'/shop/shop-chat-conversation/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询聊天会话表列表
*/
export async function listShopChatConversation(params?: ShopChatConversationParam) {
const res = await request.get<ApiResult<ShopChatConversation[]>>(
'/shop/shop-chat-conversation',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加聊天会话表
*/
export async function addShopChatConversation(data: ShopChatConversation) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-chat-conversation',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改聊天会话表
*/
export async function updateShopChatConversation(data: ShopChatConversation) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-chat-conversation',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除聊天会话表
*/
export async function removeShopChatConversation(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-chat-conversation/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除聊天会话表
*/
export async function removeShopBatchChatConversation(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-chat-conversation/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询聊天会话表
*/
export async function getShopChatConversation(id: number) {
const res = await request.get<ApiResult<ShopChatConversation>>(
'/shop/shop-chat-conversation/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,37 @@
import type { PageParam } from '@/api';
/**
* 聊天消息表
*/
export interface ShopChatConversation {
// 自增ID
id?: number;
// 用户ID
userId?: number;
// 好友ID
friendId?: number;
// 消息类型
type?: number;
// 消息内容
content?: string;
// 未读消息
unRead?: number;
// 状态, 0未读, 1已读
status?: number;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
tenantId?: number;
// 注册时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 聊天消息表搜索条件
*/
export interface ShopChatConversationParam extends PageParam {
id?: number;
keywords?: string;
}

View File

@@ -0,0 +1,115 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopChatMessage, ShopChatMessageParam } from './model';
/**
* 分页查询聊天消息表
*/
export async function pageShopChatMessage(params: ShopChatMessageParam) {
const res = await request.get<ApiResult<PageResult<ShopChatMessage>>>(
'/shop/shop-chat-message/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询聊天消息表列表
*/
export async function listShopChatMessage(params?: ShopChatMessageParam) {
const res = await request.get<ApiResult<ShopChatMessage[]>>(
'/shop/shop-chat-message',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加聊天消息表
*/
export async function addShopChatMessage(data: ShopChatMessage) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-chat-message',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加聊天消息表
*/
export async function addShopBatchChatMessage(data: ShopChatMessage[]) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-chat-message/batch',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改聊天消息表
*/
export async function updateShopChatMessage(data: ShopChatMessage) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-chat-message',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除聊天消息表
*/
export async function removeShopChatMessage(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-chat-message/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除聊天消息表
*/
export async function removeShopBatchChatMessage(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-chat-message/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询聊天消息表
*/
export async function getShopChatMessage(id: number) {
const res = await request.get<ApiResult<ShopChatMessage>>(
'/shop/shop-chat-message/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,63 @@
import type { PageParam } from '@/api';
/**
* 聊天消息表
*/
export interface ShopChatMessage {
// 自增ID
id?: number;
// 发送人ID
formUserId?: number;
// 发送人名称
formUserName?: string;
// 发送人头像
formUserAvatar?: string;
// 发送人手机号
formUserPhone?: string;
// 发送人别名
formUserAlias?: string;
// 接收人ID
toUserId?: number;
// 接收人名称
toUserName?: string;
// 接收人头像
toUserAvatar?: string;
// 接收人手机号
toUserPhone?: string;
// 接收人别名
toUserAlias?: string;
// 消息类型
type?: string;
// 消息内容
content?: string;
// 屏蔽接收方
sideTo?: number;
// 屏蔽发送方
sideFrom?: number;
// 是否撤回
withdraw?: number;
// 文件信息
fileInfo?: string;
// 批量发送
toUserIds?: any[];
// 存在联系方式
hasContact?: number;
// 状态, 0未读, 1已读
status?: number;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
tenantId?: number;
// 注册时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 聊天消息表搜索条件
*/
export interface ShopChatMessageParam extends PageParam {
id?: number;
keywords?: string;
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopDealerBank, ShopDealerBankParam } from './model';
/**
* 分页查询分销商银行卡
*/
export async function pageShopDealerBank(params: ShopDealerBankParam) {
const res = await request.get<ApiResult<PageResult<ShopDealerBank>>>(
'/shop/shop-dealer-bank/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询分销商银行卡列表
*/
export async function listShopDealerBank(params?: ShopDealerBankParam) {
const res = await request.get<ApiResult<ShopDealerBank[]>>(
'/shop/shop-dealer-bank',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加分销商银行卡
*/
export async function addShopDealerBank(data: ShopDealerBank) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-dealer-bank',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改分销商银行卡
*/
export async function updateShopDealerBank(data: ShopDealerBank) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-dealer-bank',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除分销商银行卡
*/
export async function removeShopDealerBank(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-dealer-bank/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除分销商银行卡
*/
export async function removeBatchShopDealerBank(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-dealer-bank/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询分销商银行卡
*/
export async function getShopDealerBank(id: number) {
const res = await request.get<ApiResult<ShopDealerBank>>(
'/shop/shop-dealer-bank/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,45 @@
import type { PageParam } from '@/api';
/**
* 分销商提现银行卡
*/
export interface ShopDealerBank {
// 主键ID
id?: number;
// 分销商用户ID
userId?: number;
// 开户行名称
bankName?: string;
// 银行开户名
bankAccount?: string;
// 银行卡号
bankCard?: string;
// 申请状态 (10待审核 20审核通过 30驳回)
applyStatus?: number;
// 审核时间
auditTime?: number;
// 驳回原因
rejectReason?: string;
// 是否默认
isDefault?: boolean;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
// 类型
type?: string;
// 名称
name?: string;
}
/**
* 分销商提现银行卡搜索条件
*/
export interface ShopDealerBankParam extends PageParam {
id?: number;
userId?: number;
isDefault?: boolean;
keywords?: string;
}

View File

@@ -27,4 +27,5 @@ export interface ShopDealerRefereeParam extends PageParam {
id?: number;
dealerId?: number;
keywords?: string;
deleted?: number;
}

View File

@@ -1,5 +1,5 @@
import request from '@/utils/request';
import type {ApiResult, PageResult} from '@/api/index';
import type {ApiResult, PageResult} from '@/api';
import type {User, UserParam} from './model';
import {SERVER_API_URL} from "@/utils/server";
@@ -8,8 +8,8 @@ import {SERVER_API_URL} from "@/utils/server";
*/
export async function pageUsers(params: UserParam) {
const res = await request.get<ApiResult<PageResult<User>>>(
'/system/user/page',
{params}
SERVER_API_URL + '/system/user/page',
params
);
if (res.code === 0) {
return res.data;
@@ -22,10 +22,8 @@ export async function pageUsers(params: UserParam) {
*/
export async function listUsers(params?: UserParam) {
const res = await request.get<ApiResult<User[]>>(
'/system/user',
{
params
}
SERVER_API_URL + '/system/user',
params
);
if (res.code === 0 && res.data) {
return res.data;
@@ -38,7 +36,7 @@ export async function listUsers(params?: UserParam) {
*/
export async function getStaffs(params?: UserParam) {
const res = await request.get<ApiResult<User[]>>(
'/system/user',
SERVER_API_URL + '/system/user',
{
params
}
@@ -54,7 +52,7 @@ export async function getStaffs(params?: UserParam) {
*/
export async function getCompanyList(params?: UserParam) {
const res = await request.get<ApiResult<User[]>>(
'/system/user',
SERVER_API_URL + '/system/user',
{
params
}
@@ -70,7 +68,7 @@ export async function getCompanyList(params?: UserParam) {
*/
export async function getUser(id: number) {
const res = await request.get<ApiResult<User>>(
'/system/user/' + id,
SERVER_API_URL + '/system/user/' + id,
{}
);
if (res.code === 0 && res.data) {
@@ -84,7 +82,7 @@ export async function getUser(id: number) {
*/
export async function addUser(data: User) {
const res = await request.post<ApiResult<unknown>>(
'/system/user',
SERVER_API_URL + '/system/user',
data
);
if (res.code === 0) {
@@ -112,7 +110,7 @@ export async function updateUser(data: User) {
*/
export async function removeUser(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/system/user/' + id
SERVER_API_URL + '/system/user/' + id
);
if (res.code === 0) {
return res.message;
@@ -125,7 +123,7 @@ export async function removeUser(id?: number) {
*/
export async function removeUsers(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/system/user/batch',
SERVER_API_URL + '/system/user/batch',
{
data
}
@@ -141,7 +139,7 @@ export async function removeUsers(data: (number | undefined)[]) {
*/
export async function updateUserStatus(userId?: number, status?: number) {
const res = await request.put<ApiResult<unknown>>(
'/system/user/status',
SERVER_API_URL + '/system/user/status',
{
userId,
status
@@ -156,9 +154,9 @@ export async function updateUserStatus(userId?: number, status?: number) {
/**
* 修改推荐状态
*/
export async function updateUserRecommend(form) {
export async function updateUserRecommend(form:any) {
const res = await request.put<ApiResult<unknown>>(
'/system/user/recommend',
SERVER_API_URL + '/system/user/recommend',
form
);
if (res.code === 0) {
@@ -172,7 +170,7 @@ export async function updateUserRecommend(form) {
*/
export async function updateUserPassword(userId?: number, password = '123456') {
const res = await request.put<ApiResult<unknown>>(
'/system/user/password',
SERVER_API_URL + '/system/user/password',
{
userId,
password
@@ -191,7 +189,7 @@ export async function importUsers(file: File) {
const formData = new FormData();
formData.append('file', file);
const res = await request.post<ApiResult<unknown>>(
'/system/user/import',
SERVER_API_URL + '/system/user/import',
formData
);
if (res.code === 0) {
@@ -209,7 +207,7 @@ export async function checkExistence(
id?: number
) {
const res = await request.get<ApiResult<unknown>>(
'/system/user/existence',
SERVER_API_URL + '/system/user/existence',
{
params: {field, value, id}
}
@@ -225,7 +223,7 @@ export async function checkExistence(
*/
export async function countUserBalance(params?: UserParam) {
const res = await request.get<ApiResult<unknown>>(
'/system/user/countUserBalance',
SERVER_API_URL + '/system/user/countUserBalance',
{
params
}
@@ -243,7 +241,7 @@ export async function countUserBalance(params?: UserParam) {
*/
export async function listAdminsByPhoneAll(params?: UserParam) {
const res = await request.get<ApiResult<User[]>>(
'/system/user/listAdminsByPhoneAll',
SERVER_API_URL + '/system/user/listAdminsByPhoneAll',
params
);
if (res.code === 0 && res.data) {

View File

@@ -128,6 +128,8 @@ export interface User {
certification?: boolean;
// 实名认证类型
certificationType?: number;
// 推荐人ID
refereeId?: number;
}
/**

View File

@@ -1,9 +1,10 @@
export default defineAppConfig({
export default {
pages: [
'pages/index/index',
'pages/cart/cart',
'pages/find/find',
'pages/user/user'
'pages/user/user',
'pages/cms/category/index'
],
"subpackages": [
{
@@ -14,7 +15,10 @@ export default defineAppConfig({
"forget",
"setting",
"agreement",
"sms-login"
"sms-login",
'qr-login/index',
'qr-confirm/index',
'unified-qr/index'
]
},
{
@@ -30,12 +34,6 @@ export default defineAppConfig({
"index"
]
},
{
"root": "gift",
"pages": [
"index"
]
},
{
"root": "user",
"pages": [
@@ -60,7 +58,12 @@ export default defineAppConfig({
"gift/redeem",
"gift/detail",
"store/verification",
"theme/index"
"theme/index",
"poster/poster",
"chat/conversation/index",
"chat/message/index",
"chat/message/add",
"chat/message/detail"
]
},
{
@@ -112,12 +115,6 @@ export default defineAppConfig({
selectedIconPath: "assets/tabbar/home-active.png",
text: "首页",
},
// {
// pagePath: "pages/find/find",
// iconPath: "assets/tabbar/find.png",
// selectedIconPath: "assets/tabbar/find-active.png",
// text: "发现",
// },
{
pagePath: "pages/cart/cart",
iconPath: "assets/tabbar/cart.png",
@@ -142,4 +139,4 @@ export default defineAppConfig({
"desc": "你的位置信息将用于小程序位置接口的效果展示"
}
}
})
}

View File

@@ -10,14 +10,14 @@ page{
background-position: bottom;
}
// 在全局样式文件中添加
/* 在全局样式文件中添加 */
button {
&::after {
border: none !important;
}
}
// 去掉 Grid 组件的边框
/* 去掉 Grid 组件的边框 */
.no-border-grid {
.nut-grid-item {
border: none !important;
@@ -38,7 +38,7 @@ button {
}
}
// 微信授权按钮的特殊样式
/* 微信授权按钮的特殊样式 */
button[open-type="getPhoneNumber"] {
background: none !important;
padding: 0 !important;
@@ -92,3 +92,12 @@ button[open-type="chooseAvatar"] {
image {
margin: 0; /* 全局设置图片的 margin */
}
/* 管理员面板功能项交互效果 */
.admin-feature-item {
transition: transform 0.15s ease-in-out;
}
.admin-feature-item:active {
transform: scale(0.95);
}

View File

@@ -6,7 +6,7 @@ import './app.scss'
import {loginByOpenId} from "@/api/layout";
import {TenantId} from "@/config/app";
import {saveStorageByLoginUser} from "@/utils/server";
import {parseInviteParams, saveInviteParams, trackInviteSource, handleInviteRelation} from "@/utils/invite";
import {parseInviteParams, saveInviteParams, trackInviteSource, handleInviteRelation, debugInviteInfo} from "@/utils/invite";
function App(props: { children: any; }) {
const reload = () => {
@@ -57,12 +57,13 @@ function App(props: { children: any; }) {
// 处理启动参数
const handleLaunchOptions = (options: any) => {
try {
console.log('小程序启动参数:', options)
console.log('=== 小程序启动参数处理开始 ===')
console.log('完整启动参数:', JSON.stringify(options, null, 2))
// 解析邀请参数
const inviteParams = parseInviteParams(options)
if (inviteParams) {
console.log('检测到邀请参数:', inviteParams)
console.log('✅ 成功检测到邀请参数:', inviteParams)
// 保存邀请参数到本地存储
saveInviteParams(inviteParams)
@@ -73,12 +74,21 @@ function App(props: { children: any; }) {
// 显示邀请提示
setTimeout(() => {
Taro.showToast({
title: '检测到邀请信息',
title: `检测到邀请信息 ID:${inviteParams.inviter}`,
icon: 'success',
duration: 2000
duration: 3000
})
}, 1000)
// 打印调试信息
setTimeout(() => {
debugInviteInfo()
}, 2000)
} else {
console.log('❌ 未检测到邀请参数')
}
console.log('=== 小程序启动参数处理结束 ===')
} catch (error) {
console.error('处理启动参数失败:', error)
}

BIN
src/assets/tabbar/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
src/assets/tabbar/tv.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,5 +1,5 @@
import Taro from '@tarojs/taro'
import {useShareAppMessage, useShareTimeline} from "@tarojs/taro"
import {useShareAppMessage} from "@tarojs/taro"
import {Loading} from '@nutui/nutui-react-taro'
import {useEffect, useState} from "react"
import {useRouter} from '@tarojs/taro'
@@ -42,22 +42,15 @@ function Category() {
})
}, []);
useShareTimeline(() => {
return {
title: `${nav?.categoryName}_通源堂健康生态平台`,
path: `/shop/category/index?id=${categoryId}`
};
});
useShareAppMessage(() => {
return {
title: `${nav?.categoryName}_通源堂健康生态平台`,
title: `${nav?.categoryName}_时里院子市集`,
path: `/shop/category/index?id=${categoryId}`,
success: function (res) {
console.log('分享成功', res);
success: function () {
console.log('分享成功');
},
fail: function (res) {
console.log('分享失败', res);
fail: function () {
console.log('分享失败');
}
};
});

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import { Button } from '@nutui/nutui-react-taro';
import { Scan, Setting, User, Shop } from '@nutui/icons-react-taro';
import navTo from '@/utils/common';
export interface AdminPanelProps {
/** 是否显示面板 */
visible: boolean;
/** 关闭面板回调 */
onClose?: () => void;
/** 自定义样式类名 */
className?: string;
}
/**
* 管理员功能面板组件
*/
const AdminPanel: React.FC<AdminPanelProps> = ({
visible,
onClose,
className = ''
}) => {
if (!visible) return null;
// 管理员功能列表
const adminFeatures = [
{
id: 'unified-qr',
title: '统一扫码',
description: '扫码登录和核销一体化功能',
icon: <Scan className="text-blue-500" size="24" />,
color: 'bg-blue-50 border-blue-200',
onClick: () => {
navTo('/passport/unified-qr/index', true);
onClose?.();
}
},
{
id: 'user-management',
title: '用户管理',
description: '管理系统用户信息',
icon: <User className="text-purple-500" size="24" />,
color: 'bg-purple-50 border-purple-200',
onClick: () => {
// TODO: 跳转到用户管理页面
console.log('跳转到用户管理');
onClose?.();
}
},
{
id: 'store-management',
title: '门店管理',
description: '管理门店信息和设置',
icon: <Shop className="text-orange-500" size="24" />,
color: 'bg-orange-50 border-orange-200',
onClick: () => {
// TODO: 跳转到门店管理页面
console.log('跳转到门店管理');
onClose?.();
}
},
{
id: 'system-settings',
title: '系统设置',
description: '系统配置和参数管理',
icon: <Setting className="text-gray-500" size="24" />,
color: 'bg-gray-50 border-gray-200',
onClick: () => {
// TODO: 跳转到系统设置页面
console.log('跳转到系统设置');
onClose?.();
}
}
];
return (
<View className={`admin-panel ${className}`}>
{/* 遮罩层 */}
<View
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={onClose}
/>
{/* 面板内容 */}
<View className="fixed bottom-0 left-0 right-0 bg-white rounded-t-3xl z-50 overflow-hidden">
{/* 面板头部 */}
<View className="flex items-center justify-between p-4 border-b border-gray-100">
<View className="flex items-center">
<Setting className="text-blue-500 mr-2" size="20" />
<Text className="text-lg font-bold text-gray-800"></Text>
</View>
<Button
size="small"
type="default"
onClick={onClose}
className="text-gray-500"
>
</Button>
</View>
{/* 功能网格 */}
<View className="p-4 pb-8">
<View className="grid grid-cols-2 gap-3">
{adminFeatures.map((feature) => (
<View
key={feature.id}
className={`${feature.color} border rounded-xl p-4 admin-feature-item`}
onClick={feature.onClick}
>
<View className="flex items-center mb-2">
{feature.icon}
<Text className="ml-2 font-medium text-gray-800">
{feature.title}
</Text>
</View>
<Text className="text-xs text-gray-600 leading-relaxed">
{feature.description}
</Text>
</View>
))}
</View>
</View>
{/* 底部提示 */}
<View className="px-4 pb-4">
<View className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<Text className="text-xs text-yellow-700 text-center">
💡
</Text>
</View>
</View>
</View>
</View>
);
};
export default AdminPanel;

View File

@@ -9,25 +9,25 @@
border: 2px solid #f0f0f0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// 更精美的阴影效果
//box-shadow:
// 0 4px 20px rgba(0, 0, 0, 0.08),
// 0 1px 3px rgba(0, 0, 0, 0.1);
/* 更精美的阴影效果 */
/*box-shadow:
0 4px 20px rgba(0, 0, 0, 0.08),
0 1px 3px rgba(0, 0, 0, 0.1);*/
// 边框光晕效果
//&::before {
// content: '';
// position: absolute;
// top: 0;
// left: 0;
// right: 0;
// bottom: 0;
// border-radius: 16px;
// padding: 1px;
// background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.1));
// mask-composite: exclude;
// pointer-events: none;
//}
/* 边框光晕效果 */
/*&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 16px;
padding: 1px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.1));
mask-composite: exclude;
pointer-events: none;
}*/
&:active {
transform: scale(0.98) translateY(1px);
@@ -38,7 +38,7 @@
&.disabled {
opacity: 0.6;
filter: grayscale(0.3);
/* filter: grayscale(0.3); 小程序不支持filter属性 */
}
.coupon-left {
@@ -52,7 +52,7 @@
position: relative;
overflow: hidden;
// 添加光泽效果
/* 添加光泽效果 */
&::after {
content: '';
position: absolute;
@@ -199,11 +199,7 @@
line-height: 1.3;
letter-spacing: -0.5px;
// 添加文字渐变效果
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
/* 文字渐变效果在小程序中不支持,使用纯色替代 */
}
.coupon-validity {
@@ -212,7 +208,7 @@
line-height: 1.2;
font-weight: 500;
// 添加图标前缀
/* 添加图标前缀 */
&::before {
content: '';
margin-right: 6px;
@@ -239,7 +235,7 @@
position: relative;
overflow: hidden;
// 添加按钮光泽效果
/* 添加按钮光泽效果 */
&::before {
content: '';
position: absolute;
@@ -309,7 +305,7 @@
align-items: center;
justify-content: center;
z-index: 10;
backdrop-filter: blur(2px);
/* backdrop-filter: blur(2px); 小程序不支持backdrop-filter属性 */
.status-badge {
background: rgba(0, 0, 0, 0.7);
@@ -324,7 +320,7 @@
}
}
// 动画效果
/* 动画效果 */
@keyframes shimmer {
0% {
transform: translateX(-100%) translateY(-100%) rotate(45deg);
@@ -334,7 +330,7 @@
}
}
// 响应式优化
/* 响应式优化 */
@media (max-width: 768px) {
.coupon-card {
height: 150px;

View File

@@ -2,7 +2,7 @@ import {NavBar} from '@nutui/nutui-react-taro'
import {ArrowLeft} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
function Header(props) {
function Header(props: any) {
return (
<>
<NavBar

View File

@@ -0,0 +1,89 @@
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 { useQRLogin } from '@/hooks/useQRLogin';
export interface QRLoginButtonProps {
/** 按钮类型 */
type?: 'primary' | 'success' | 'warning' | 'danger' | 'default';
/** 按钮大小 */
size?: 'large' | 'normal' | 'small';
/** 按钮文本 */
text?: string;
/** 是否显示图标 */
showIcon?: boolean;
/** 自定义样式类名 */
className?: string;
/** 点击成功回调 */
onSuccess?: (result: any) => void;
/** 点击失败回调 */
onError?: (error: string) => void;
/** 是否使用页面模式(跳转到专门页面) */
usePageMode?: boolean;
}
/**
* 扫码登录按钮组件
*/
const QRLoginButton: React.FC<QRLoginButtonProps> = ({
type = 'default',
size = 'small',
text = '扫码登录',
showIcon = true,
onSuccess,
onError,
usePageMode = false
}) => {
const { startScan, isLoading, canScan } = useQRLogin();
// 处理点击事件
const handleClick = async () => {
console.log('处理点击事件handleClick', usePageMode)
if (usePageMode) {
// 跳转到专门的扫码登录页面
if (canScan()) {
Taro.navigateTo({
url: '/passport/qr-login/index'
});
} else {
Taro.showToast({
title: '请先登录小程序',
icon: 'error'
});
}
return;
}
// 直接执行扫码登录
try {
await startScan();
// 成功回调会在Hook内部处理
} catch (error: any) {
onError?.(error.message || '扫码登录失败');
}
};
console.log(onSuccess,'onSuccess')
const disabled = !canScan() || isLoading;
return (
<Button
type={type}
size={size}
loading={isLoading}
disabled={disabled}
onClick={handleClick}
>
<View className="flex items-center justify-center">
{showIcon && !isLoading && (
<Scan className="mr-1" />
)}
{isLoading ? '扫码中...' : (disabled && !canScan() ? '请先登录' : text)}
</View>
</Button>
);
};
export default QRLoginButton;

View File

@@ -0,0 +1,182 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import { Button, Loading } from '@nutui/nutui-react-taro';
import { Scan, Success, Failure } from '@nutui/icons-react-taro';
import { useQRLogin, ScanLoginState } from '@/hooks/useQRLogin';
export interface QRLoginScannerProps {
/** 扫码成功回调 */
onSuccess?: (result: any) => void;
/** 扫码失败回调 */
onError?: (error: string) => void;
/** 自定义样式类名 */
className?: string;
/** 按钮文本 */
buttonText?: string;
/** 是否显示状态信息 */
showStatus?: boolean;
}
/**
* 扫码登录组件
*/
const QRLoginScanner: React.FC<QRLoginScannerProps> = ({
onSuccess,
onError,
className = '',
buttonText = '扫码登录',
showStatus = true
}) => {
const {
state,
error,
result,
isLoading,
startScan,
cancel,
reset,
canScan
} = useQRLogin();
// 处理扫码成功
React.useEffect(() => {
if (state === ScanLoginState.SUCCESS && result) {
onSuccess?.(result);
}
}, [state, result, onSuccess]);
// 处理扫码失败
React.useEffect(() => {
if (state === ScanLoginState.ERROR && error) {
onError?.(error);
}
}, [state, error, onError]);
// 获取状态显示内容
const getStatusContent = () => {
switch (state) {
case ScanLoginState.SCANNING:
return (
<View className="flex items-center justify-center text-blue-500">
<Loading className="mr-2" />
<Text>...</Text>
</View>
);
case ScanLoginState.CONFIRMING:
return (
<View className="flex items-center justify-center text-orange-500">
<Loading className="mr-2" />
<Text>...</Text>
</View>
);
case ScanLoginState.SUCCESS:
return (
<View className="flex items-center justify-center text-green-500">
<Success className="mr-2" />
<Text></Text>
</View>
);
case ScanLoginState.ERROR:
return (
<View className="flex items-center justify-center text-red-500">
<Failure className="mr-2" />
<Text>{error || '扫码登录失败'}</Text>
</View>
);
default:
return null;
}
};
// 获取按钮状态
const getButtonProps = () => {
const disabled = !canScan() || isLoading;
switch (state) {
case ScanLoginState.SCANNING:
case ScanLoginState.CONFIRMING:
return {
loading: true,
disabled: true,
text: state === ScanLoginState.SCANNING ? '扫码中...' : '确认中...',
onClick: cancel
};
case ScanLoginState.SUCCESS:
return {
loading: false,
disabled: false,
text: '重新扫码',
onClick: reset
};
case ScanLoginState.ERROR:
return {
loading: false,
disabled: false,
text: '重试',
onClick: startScan
};
default:
return {
loading: false,
disabled,
text: disabled ? '请先登录' : buttonText,
onClick: startScan
};
}
};
const buttonProps = getButtonProps();
return (
<View className={`qr-login-scanner ${className}`}>
{/* 扫码按钮 */}
<Button
type="primary"
size="large"
loading={buttonProps.loading}
disabled={buttonProps.disabled}
onClick={buttonProps.onClick}
className="w-full"
>
{!buttonProps.loading && (
<Scan className="mr-2" />
)}
{buttonProps.text}
</Button>
{/* 状态显示 */}
{showStatus && (
<View className="mt-4 text-center">
{getStatusContent()}
</View>
)}
{/* 成功结果显示 */}
{state === ScanLoginState.SUCCESS && result && (
<View className="mt-4 p-4 bg-green-50 rounded-lg">
<Text className="text-sm text-green-700">
{result.userInfo?.nickname || result.userInfo?.userId}
</Text>
</View>
)}
{/* 使用说明 */}
{state === ScanLoginState.IDLE && (
<View className="mt-4 text-center">
<Text className="text-xs text-gray-500">
</Text>
</View>
)}
</View>
);
};
export default QRLoginScanner;

View File

@@ -0,0 +1,272 @@
import React, { useState } from 'react';
import { View, Text } from '@tarojs/components';
import { Button, Popup, Loading } from '@nutui/nutui-react-taro';
import { Scan, Close, Success, Failure } from '@nutui/icons-react-taro';
import Taro from '@tarojs/taro';
import { parseQRContent, confirmQRLogin } from '@/api/passport/qr-login';
import { useUser } from '@/hooks/useUser';
export interface QRScanModalProps {
/** 是否显示弹窗 */
visible: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 扫码成功回调 */
onSuccess?: (result: any) => void;
/** 扫码失败回调 */
onError?: (error: string) => void;
/** 弹窗标题 */
title?: string;
/** 描述文本 */
description?: string;
/** 是否自动确认登录 */
autoConfirm?: boolean;
}
/**
* 二维码扫描弹窗组件(用于扫码登录)
*/
const QRScanModal: React.FC<QRScanModalProps> = ({
visible,
onClose,
onSuccess,
onError,
title = '扫描登录二维码',
description = '扫描网页端显示的登录二维码',
autoConfirm = true
}) => {
const { user } = useUser();
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<'idle' | 'scanning' | 'confirming' | 'success' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
// 开始扫码
const handleScan = async () => {
if (!user?.userId) {
onError?.('请先登录小程序');
return;
}
try {
setLoading(true);
setStatus('scanning');
setErrorMsg('');
// 扫码
const scanResult = await new Promise<string>((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 || '扫码失败'));
}
});
});
// 解析二维码内容
const token = parseQRContent(scanResult);
if (!token) {
throw new Error('无效的登录二维码');
}
if (autoConfirm) {
// 自动确认登录
setStatus('confirming');
const result = await confirmQRLogin({
token,
userId: user.userId,
platform: 'wechat',
wechatInfo: {
nickname: user.nickname,
avatar: user.avatar
}
});
if (result.success) {
setStatus('success');
onSuccess?.(result);
// 显示成功提示
Taro.showToast({
title: '登录确认成功',
icon: 'success'
});
// 延迟关闭
setTimeout(() => {
onClose();
setStatus('idle');
}, 1500);
} else {
throw new Error(result.message || '登录确认失败');
}
} else {
// 只返回扫码结果
onSuccess?.(scanResult);
onClose();
setStatus('idle');
}
} catch (error: any) {
setStatus('error');
const errorMessage = error.message || '操作失败';
setErrorMsg(errorMessage);
onError?.(errorMessage);
} finally {
setLoading(false);
}
};
// 重试
const handleRetry = () => {
setStatus('idle');
setErrorMsg('');
handleScan();
};
// 关闭弹窗
const handleClose = () => {
setStatus('idle');
setErrorMsg('');
setLoading(false);
onClose();
};
// 获取状态显示内容
const getStatusContent = () => {
switch (status) {
case 'scanning':
return {
icon: <Loading className="text-blue-500" />,
title: '正在扫码...',
description: '请将二维码对准摄像头'
};
case 'confirming':
return {
icon: <Loading className="text-orange-500" />,
title: '正在确认登录...',
description: '请稍候,正在为您确认登录'
};
case 'success':
return {
icon: <Success size="32" className="text-green-500" />,
title: '登录确认成功',
description: '网页端将自动完成登录'
};
case 'error':
return {
icon: <Failure size="32" className="text-red-500" />,
title: '操作失败',
description: errorMsg || '请重试'
};
default:
return {
icon: <Scan size="32" className="text-blue-500" />,
title,
description
};
}
};
const statusContent = getStatusContent();
return (
<Popup
visible={visible}
position="center"
closeable={false}
onClose={handleClose}
style={{ width: '85%', borderRadius: '12px' }}
>
<View className="p-6 text-center relative">
{/* 关闭按钮 */}
{status !== 'scanning' && status !== 'confirming' && (
<View className="absolute top-4 right-4">
<Button
size="small"
type="default"
onClick={handleClose}
className="w-8 h-8 p-0"
>
<Close size="16" />
</Button>
</View>
)}
{/* 图标 */}
<View className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
{statusContent.icon}
</View>
{/* 标题 */}
<Text className="text-lg font-bold text-gray-800 mb-2 block">
{statusContent.title}
</Text>
{/* 描述 */}
<Text className="text-gray-600 mb-6 block">
{statusContent.description}
</Text>
{/* 操作按钮 */}
{status === 'idle' && (
<Button
type="primary"
size="large"
onClick={handleScan}
className="w-full"
disabled={!user?.userId}
>
<Scan className="mr-2" />
{user?.userId ? '开始扫码' : '请先登录'}
</Button>
)}
{status === 'error' && (
<View className="space-y-2">
<Button
type="primary"
size="large"
onClick={handleRetry}
className="w-full"
>
</Button>
<Button
type="default"
size="normal"
onClick={handleClose}
className="w-full"
>
</Button>
</View>
)}
{(status === 'scanning' || status === 'confirming') && (
<Button
type="default"
size="large"
onClick={handleClose}
className="w-full"
>
</Button>
)}
</View>
{loading}
</Popup>
);
};
export default QRScanModal;

View File

@@ -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<UnifiedQRButtonProps> = ({
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 (
<Button
type={type}
size={size}
loading={isLoading}
disabled={disabled}
onClick={handleClick}
>
<View className="flex items-center justify-center">
{showIcon && !isLoading && (
<Scan className="mr-1" />
)}
{getButtonText()}
</View>
</Button>
);
};
export default UnifiedQRButton;

View File

@@ -1,4 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '医生入驻申请通道',
navigationBarTitleText: '邀请注册',
navigationBarTextStyle: 'black'
})

View File

@@ -1,96 +1,210 @@
import {useEffect, useState, useRef} from "react";
import {Loading, CellGroup, Cell, Input, Form} from '@nutui/nutui-react-taro'
import {Loading, CellGroup, Input, Form, Avatar, Button, Space} from '@nutui/nutui-react-taro'
import {Edit} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {View} from '@tarojs/components'
import FixedButton from "@/components/FixedButton";
import {useUser} from "@/hooks/useUser";
import {ShopDealerApply} from "@/api/shop/shopDealerApply/model";
import {
addShopDealerApply,
pageShopDealerApply,
updateShopDealerApply
} from "@/api/shop/shopDealerApply";
import {getShopDealerUser} from "@/api/shop/shopDealerUser";
import {TenantId} from "@/config/app";
import {updateUser} from "@/api/system/user";
import {User} from "@/api/system/user/model";
import {getStoredInviteParams, handleInviteRelation} from "@/utils/invite";
import {addShopDealerUser} from "@/api/shop/shopDealerUser";
import {listUserRole, updateUserRole} from "@/api/system/userRole";
// 类型定义
interface ChooseAvatarEvent {
detail: {
avatarUrl: string;
};
}
interface InputEvent {
detail: {
value: string;
};
}
const AddUserAddress = () => {
const {user} = useUser()
const {user, loginUser} = useUser()
const [loading, setLoading] = useState<boolean>(true)
const [FormData, setFormData] = useState<ShopDealerApply>()
const [FormData, setFormData] = useState<User>()
const formRef = useRef<any>(null)
const [isEditMode, setIsEditMode] = useState<boolean>(false)
const [existingApply, setExistingApply] = useState<ShopDealerApply | null>(null)
// 获取审核状态文字
const getApplyStatusText = (status?: number) => {
switch (status) {
case 10:
return '待审核'
case 20:
return '审核通过'
case 30:
return '驳回'
default:
return '未知状态'
const reload = async () => {
const inviteParams = getStoredInviteParams()
if (inviteParams?.inviter) {
setFormData({
...user,
refereeId: Number(inviteParams.inviter),
// 清空昵称,强制用户手动输入
nickname: '',
})
} else {
// 如果没有邀请参数,也要确保昵称为空
setFormData({
...user,
nickname: '',
})
}
}
const reload = async () => {
// 判断用户是否登录
if (!user?.userId) {
return false;
const uploadAvatar = ({detail}: ChooseAvatarEvent) => {
// 先更新本地显示的头像(临时显示)
const tempFormData = {
...FormData,
avatar: `${detail.avatarUrl}`,
}
// 查询当前用户ID是否已有申请记录
try {
const res = await pageShopDealerApply({userId: user?.userId});
if (res && res.count > 0) {
setIsEditMode(true);
setExistingApply(res.list[0]);
// 如果有记录,填充表单数据
setFormData(res.list[0]);
setLoading(false)
} else {
setIsEditMode(false);
setExistingApply(null);
setLoading(false)
setFormData(tempFormData)
Taro.uploadFile({
url: 'https://server.websoft.top/api/oss/upload',
filePath: detail.avatarUrl,
name: 'file',
header: {
'content-type': 'application/json',
TenantId
},
success: async (res) => {
const data = JSON.parse(res.data);
if (data.code === 0) {
const finalAvatarUrl = `${data.data.thumbnail}`
try {
// 使用 useUser hook 的 updateUser 方法更新头像
await updateUser({
avatar: finalAvatarUrl
})
Taro.showToast({
title: '头像上传成功',
icon: 'success',
duration: 1500
})
} catch (error) {
console.error('更新用户头像失败:', error)
}
// 无论用户信息更新是否成功都要更新本地FormData
const finalFormData = {
...tempFormData,
avatar: finalAvatarUrl
}
setFormData(finalFormData)
// 同步更新表单字段
if (formRef.current) {
formRef.current.setFieldsValue({
avatar: finalAvatarUrl
})
}
} else {
// 上传失败,恢复原来的头像
setFormData({
...FormData,
avatar: user?.avatar || ''
})
Taro.showToast({
title: '上传失败',
icon: 'error'
})
}
},
fail: (error) => {
console.error('上传头像失败:', error)
Taro.showToast({
title: '上传失败',
icon: 'error'
})
// 恢复原来的头像
setFormData({
...FormData,
avatar: user?.avatar || ''
})
}
} catch (error) {
setLoading(true)
console.error('查询申请记录失败:', error);
setIsEditMode(false);
setExistingApply(null);
}
})
}
// 提交表单
const submitSucceed = async (values: any) => {
try {
// 验证必填字段
if (!values.phone && !FormData?.phone) {
Taro.showToast({
title: '请先获取手机号',
icon: 'error'
});
return;
}
// 验证昵称:必须填写且不能是默认的微信昵称
const nickname = values.realName || FormData?.nickname || '';
if (!nickname || nickname.trim() === '') {
Taro.showToast({
title: '请填写昵称',
icon: 'error'
});
return;
}
// 检查是否为默认的微信昵称(常见的默认昵称)
const defaultNicknames = ['微信用户', 'WeChat User', '微信昵称'];
if (defaultNicknames.includes(nickname.trim())) {
Taro.showToast({
title: '请填写真实昵称,不能使用默认昵称',
icon: 'error'
});
return;
}
// 验证昵称长度
if (nickname.trim().length < 2) {
Taro.showToast({
title: '昵称至少需要2个字符',
icon: 'error'
});
return;
}
if (!values.avatar && !FormData?.avatar) {
Taro.showToast({
title: '请上传头像',
icon: 'error'
});
return;
}
console.log(values,FormData)
const roles = await listUserRole({userId: user?.userId})
console.log(roles, 'roles...')
// 准备提交的数据
const submitData = {
...values,
realName: values.realName || user?.nickname,
mobile: user?.phone,
refereeId: values.refereeId || FormData?.refereeId,
applyStatus: 10,
auditTime: undefined
};
await getShopDealerUser(submitData.refereeId);
await updateUser({
userId: user?.userId,
nickname: values.realName || FormData?.nickname,
phone: values.phone || FormData?.phone,
avatar: values.avatar || FormData?.avatar,
refereeId: values.refereeId || FormData?.refereeId
});
// 如果是编辑模式添加现有申请的id
if (isEditMode && existingApply?.applyId) {
submitData.applyId = existingApply.applyId;
await addShopDealerUser({
userId: user?.userId,
realName: values.realName || FormData?.nickname,
mobile: values.phone || FormData?.phone,
refereeId: values.refereeId || FormData?.refereeId
})
if (roles.length > 0) {
await updateUserRole({
...roles[0],
roleId: 1848
})
}
// 执行新增或更新操作
if (isEditMode) {
await updateShopDealerApply(submitData);
} else {
await addShopDealerApply(submitData);
}
Taro.showToast({
title: `${isEditMode ? '提交' : '提交'}成功`,
title: `注册成功`,
icon: 'success'
});
@@ -100,13 +214,130 @@ const AddUserAddress = () => {
} catch (error) {
console.error('验证邀请人失败:', error);
return Taro.showToast({
title: '邀请人ID不存在',
icon: 'error'
});
}
}
// 获取微信昵称
const getWxNickname = (nickname: string) => {
// 更新表单数据
const updatedFormData = {
...FormData,
nickname: nickname
}
setFormData(updatedFormData);
// 同步更新表单字段
if (formRef.current) {
formRef.current.setFieldsValue({
realName: nickname
})
}
}
/* 获取用户手机号 */
const handleGetPhoneNumber = ({detail}: { detail: { code?: string, encryptedData?: string, iv?: string } }) => {
const {code, encryptedData, iv} = detail
Taro.login({
success: (loginRes) => {
if (code) {
Taro.request({
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
authCode: loginRes.code,
code,
encryptedData,
iv,
notVerifyPhone: true,
refereeId: 0,
sceneType: 'save_referee',
tenantId: TenantId
},
header: {
'content-type': 'application/json',
TenantId
},
success: async function (res) {
if (res.data.code == 1) {
Taro.showToast({
title: res.data.message,
icon: 'error',
duration: 2000
})
return false;
}
// 登录成功
const token = res.data.data.access_token;
const userData = res.data.data.user;
console.log(userData, 'userData...')
// 使用useUser Hook的loginUser方法更新状态
loginUser(token, userData);
if (userData.phone) {
console.log('手机号已获取', userData.phone)
const updatedFormData = {
...FormData,
phone: userData.phone,
// 不自动填充微信昵称,保持用户已输入的昵称
nickname: FormData?.nickname || '',
// 只在没有头像时才使用微信头像
avatar: FormData?.avatar || userData.avatar
}
setFormData(updatedFormData)
// 更新表单字段值
if (formRef.current) {
formRef.current.setFieldsValue({
phone: userData.phone,
// 不覆盖用户已输入的昵称
realName: FormData?.nickname || '',
avatar: FormData?.avatar || userData.avatar
})
}
Taro.showToast({
title: '手机号获取成功',
icon: 'success',
duration: 1500
})
}
// 处理邀请关系
if (userData?.userId) {
try {
const inviteSuccess = await handleInviteRelation(userData.userId)
if (inviteSuccess) {
Taro.showToast({
title: '邀请关系建立成功',
icon: 'success',
duration: 2000
})
}
} catch (error) {
console.error('处理邀请关系失败:', error)
}
}
// 显示登录成功提示
// Taro.showToast({
// title: '注册成功',
// icon: 'success',
// duration: 1500
// })
// 不需要重新启动小程序状态已经通过useUser更新
// 可以选择性地刷新当前页面数据
// await reload();
}
})
} else {
console.log('登录失败!')
}
}
})
}
// 处理固定按钮点击事件
const handleFixedButtonClick = () => {
// 触发表单提交
@@ -123,6 +354,18 @@ const AddUserAddress = () => {
})
}, [user?.userId]); // 依赖用户ID当用户变化时重新加载
// 当FormData变化时同步更新表单字段值
useEffect(() => {
if (formRef.current && FormData) {
formRef.current.setFieldsValue({
refereeId: FormData.refereeId,
phone: FormData.phone,
avatar: FormData.avatar,
realName: FormData.nickname
});
}
}, [FormData]);
if (loading) {
return <Loading className={'px-2'}></Loading>
}
@@ -139,50 +382,49 @@ const AddUserAddress = () => {
>
<View className={'bg-gray-100 h-3'}></View>
<CellGroup style={{padding: '4px 0'}}>
<Form.Item name="realName" label="名称" initialValue={user?.nickname} required>
<Input placeholder="经销商名称" maxLength={10}/>
</Form.Item>
<Form.Item name="mobile" label="手机号" initialValue={user?.mobile} required>
<Input placeholder="请输入手机号" disabled={true} maxLength={11}/>
</Form.Item>
<Form.Item name="refereeId" label="邀请人ID" initialValue={FormData?.refereeId} required>
<Input placeholder="邀请人ID"/>
<Input placeholder="邀请人ID" disabled={true}/>
</Form.Item>
<Form.Item name="phone" label="手机号" initialValue={FormData?.phone} required>
<View className="flex items-center justify-between">
<Input
placeholder="请填写手机号"
disabled={true}
maxLength={11}
value={FormData?.phone || ''}
/>
<Button style={{color: '#ffffff'}} open-type="getPhoneNumber" onGetPhoneNumber={handleGetPhoneNumber}>
<Space>
<Button size="small"></Button>
</Space>
</Button>
</View>
</Form.Item>
{
FormData?.phone && <Form.Item name="avatar" label="头像" initialValue={FormData?.avatar} required>
<Button open-type="chooseAvatar" style={{height: '58px'}} onChooseAvatar={uploadAvatar}>
<Avatar src={FormData?.avatar || user?.avatar} size="54"/>
</Button>
</Form.Item>
}
<Form.Item name="realName" label="昵称" initialValue="" required>
<Input
type="nickname"
className="info-content__input"
placeholder="请获取微信昵称"
value={FormData?.nickname || ''}
onInput={(e: InputEvent) => getWxNickname(e.detail.value)}
/>
</Form.Item>
</CellGroup>
</Form>
{/* 审核状态显示(仅在编辑模式下显示) */}
{isEditMode && (
<CellGroup>
<Cell
title={'审核状态'}
extra={
<span style={{
color: FormData?.applyStatus === 20 ? '#52c41a' :
FormData?.applyStatus === 30 ? '#ff4d4f' : '#faad14'
}}>
{getApplyStatusText(FormData?.applyStatus)}
</span>
}
/>
{FormData?.applyStatus === 20 && (
<Cell title={'审核时间'} extra={FormData?.auditTime || '无'}/>
)}
{FormData?.applyStatus === 30 && (
<Cell title={'驳回原因'} extra={FormData?.rejectReason || '无'}/>
)}
</CellGroup>
)}
{/* 底部浮动按钮 */}
{(!isEditMode || FormData?.applyStatus === 10 || FormData?.applyStatus === 30) && (
<FixedButton
icon={<Edit/>}
text={isEditMode ? '保存修改' : '提交申请'}
disabled={FormData?.applyStatus === 10}
onClick={handleFixedButtonClick}
/>
)}
<FixedButton
icon={<Edit/>}
text={'立即注册'}
onClick={handleFixedButtonClick}
/>
</>
);

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '添加银行卡',
navigationBarTextStyle: 'black'
})

142
src/dealer/bank/add.tsx Normal file
View File

@@ -0,0 +1,142 @@
import {useEffect, useState, useRef} from "react";
import {useRouter} from '@tarojs/taro'
import {Loading, CellGroup, Input, Form} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import {
getShopDealerBank,
listShopDealerBank,
updateShopDealerBank,
addShopDealerBank
} from "@/api/shop/shopDealerBank";
import FixedButton from "@/components/FixedButton";
import {ShopDealerBank} from "@/api/shop/shopDealerBank/model";
const AddUserAddress = () => {
const {params} = useRouter();
const [loading, setLoading] = useState<boolean>(true)
const [FormData, setFormData] = useState<ShopDealerBank>()
const formRef = useRef<any>(null)
// 判断是编辑还是新增模式
const isEditMode = !!params.id
const bankId = params.id ? Number(params.id) : undefined
const reload = async () => {
// 如果是编辑模式,加载地址数据
if (isEditMode && bankId) {
try {
const bank = await getShopDealerBank(bankId)
setFormData(bank)
} catch (error) {
console.error('加载地址失败:', error)
Taro.showToast({
title: '加载地址失败',
icon: 'error'
});
}
}
}
// 提交表单
const submitSucceed = async (values: any) => {
console.log('.>>>>>>,....')
try {
// 准备提交的数据
const submitData = {
...values,
isDefault: true // 新增或编辑的地址都设为默认地址
};
console.log('提交数据:', submitData)
// 如果是编辑模式添加id
if (isEditMode && bankId) {
submitData.id = bankId;
}
// 先处理默认地址逻辑
const defaultAddress = await listShopDealerBank({isDefault: true});
if (defaultAddress && defaultAddress.length > 0) {
// 如果当前编辑的不是默认地址,或者是新增地址,需要取消其他默认地址
if (!isEditMode || (isEditMode && defaultAddress[0].id !== bankId)) {
await updateShopDealerBank({
...defaultAddress[0],
isDefault: false
});
}
}
// 执行新增或更新操作
if (isEditMode) {
await updateShopDealerBank(submitData);
} else {
await addShopDealerBank(submitData);
}
Taro.showToast({
title: `${isEditMode ? '更新' : '保存'}成功`,
icon: 'success'
});
setTimeout(() => {
Taro.navigateBack();
}, 1000);
} catch (error) {
console.error('保存失败:', error);
Taro.showToast({
title: `${isEditMode ? '更新' : '保存'}失败`,
icon: 'error'
});
}
}
const submitFailed = (error: any) => {
console.log(error, 'err...')
}
useEffect(() => {
// 动态设置页面标题
Taro.setNavigationBarTitle({
title: isEditMode ? '编辑银行卡' : '添加银行卡'
});
reload().then(() => {
setLoading(false)
})
}, [isEditMode]);
if (loading) {
return <Loading className={'px-2'}></Loading>
}
return (
<>
<Form
ref={formRef}
divider
initialValues={FormData}
labelPosition="left"
onFinish={(values) => submitSucceed(values)}
onFinishFailed={(errors) => submitFailed(errors)}
>
<CellGroup style={{padding: '4px 0'}}>
<Form.Item name="bankName" label="开户行名称" initialValue={FormData?.bankName} required>
<Input placeholder="开户行名称" maxLength={10}/>
</Form.Item>
<Form.Item name="bankAccount" label="银行开户名" initialValue={FormData?.bankAccount} required>
<Input placeholder="银行开户名" maxLength={10}/>
</Form.Item>
<Form.Item name="bankCard" label="银行卡号" initialValue={FormData?.bankCard} required>
<Input placeholder="银行卡号" maxLength={11}/>
</Form.Item>
</CellGroup>
</Form>
{/* 底部浮动按钮 */}
<FixedButton text={isEditMode ? '更新地址' : '保存并使用'} onClick={() => formRef.current?.submit()}/>
</>
);
};
export default AddUserAddress;

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '银行卡管理',
navigationBarTextStyle: 'black'
})

134
src/dealer/bank/index.tsx Normal file
View File

@@ -0,0 +1,134 @@
import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/taro'
import {Button, Cell, Space, Empty, ConfigProvider} from '@nutui/nutui-react-taro'
import {CheckNormal, Checked} from '@nutui/icons-react-taro'
import {View} from '@tarojs/components'
import {ShopDealerBank} from "@/api/shop/shopDealerBank/model";
import {listShopDealerBank, removeShopDealerBank, updateShopDealerBank} from "@/api/shop/shopDealerBank";
import FixedButton from "@/components/FixedButton";
const DealerBank = () => {
const [list, setList] = useState<ShopDealerBank[]>([])
const [bank, setAddress] = useState<ShopDealerBank>()
const reload = () => {
listShopDealerBank({})
.then(data => {
setList(data || [])
// 默认地址
setAddress(data.find(item => item.isDefault))
})
.catch(() => {
Taro.showToast({
title: '获取地址失败',
icon: 'error'
});
})
}
const onDefault = async (item: ShopDealerBank) => {
if (bank) {
await updateShopDealerBank({
...bank,
isDefault: false
})
}
await updateShopDealerBank({
id: item.id,
isDefault: true
})
Taro.showToast({
title: '设置成功',
icon: 'success'
});
reload();
}
const onDel = async (id?: number) => {
await removeShopDealerBank(id)
Taro.showToast({
title: '删除成功',
icon: 'success'
});
reload();
}
const selectAddress = async (item: ShopDealerBank) => {
if (bank) {
await updateShopDealerBank({
...bank,
isDefault: false
})
}
await updateShopDealerBank({
id: item.id,
isDefault: true
})
setTimeout(() => {
Taro.navigateBack()
}, 500)
}
useDidShow(() => {
reload()
});
if (list.length == 0) {
return (
<ConfigProvider>
<div className={'h-full flex flex-col justify-center items-center'} style={{
height: 'calc(100vh - 300px)',
}}>
<Empty
style={{
backgroundColor: 'transparent'
}}
description="您还没有地址哦"
/>
<Space>
<Button onClick={() => Taro.navigateTo({url: '/dealer/bank/add'})}></Button>
<Button type="success" fill="dashed"
onClick={() => Taro.navigateTo({url: '/dealer/bank/wxAddress'})}></Button>
</Space>
</div>
</ConfigProvider>
)
}
return (
<View className={'p-3'}>
{list.map((item, _) => (
<Cell.Group>
<Cell className={'flex flex-col gap-1'} extra={item.bankAccount} onClick={() => selectAddress(item)}>
<View>
<View className={'font-medium text-sm'}>{item.bankName}</View>
</View>
<View className={'text-xs'}>
{item.bankCard} {item.bankAccount}
</View>
</Cell>
<Cell
align="center"
title={
<View className={'flex items-center gap-1'} onClick={() => onDefault(item)}>
{item.isDefault ? <Checked className={'text-green-600'} size={16}/> : <CheckNormal size={16}/>}
<View className={'text-gray-400'}></View>
</View>
}
extra={
<>
<View className={'text-gray-400'} onClick={() => onDel(item.id)}>
</View>
</>
}
/>
</Cell.Group>
))}
{/* 底部浮动按钮 */}
<FixedButton text={'新增银行卡'} onClick={() => Taro.navigateTo({url: '/dealer/bank/add'})} />
</View>
);
};
export default DealerBank;

View File

@@ -0,0 +1,108 @@
# 客户管理页面
## 功能概述
这是一个完整的客户管理页面,支持客户数据的展示、筛选和搜索功能。
## 主要功能
### 1. 数据源
- 使用 `pageUsers` API 从 User 表读取客户数据
- 支持按状态筛选用户status: 0 表示正常状态)
### 2. 状态管理
客户状态包括:
- **全部** - 显示所有客户
- **跟进中** - 正在跟进的潜在客户
- **已签约** - 已经签约的客户
- **已取消** - 已取消合作的客户
### 3. 顶部Tabs筛选
- 支持按客户状态筛选
- 显示每个状态的客户数量统计
- 实时更新统计数据
### 4. 搜索功能
支持多字段搜索:
- 客户姓名realName
- 昵称nickname
- 用户名username
- 手机号phone
- 用户IDuserId
### 5. 客户信息展示
每个客户卡片显示:
- 客户姓名和状态标签
- 手机号码
- 注册时间
- 用户ID、余额、积分等统计信息
## 技术实现
### 组件结构
```
CustomerManagement
├── 搜索栏 (SearchBar)
├── 状态筛选Tabs
└── 客户列表
└── 客户卡片项
```
### 主要状态
- `list`: 客户数据列表
- `loading`: 加载状态
- `activeTab`: 当前选中的状态Tab
- `searchValue`: 搜索关键词
### 工具函数
使用 `@/utils/customerStatus` 工具函数管理客户状态:
- `getStatusText()`: 获取状态文本
- `getStatusTagType()`: 获取状态标签类型
- `getStatusOptions()`: 获取状态选项列表
## 使用的组件
### NutUI 组件
- `Tabs` / `TabPane`: 状态筛选标签页
- `SearchBar`: 搜索输入框
- `Tag`: 状态标签
- `Loading`: 加载指示器
- `Space`: 间距布局
### 图标
- `Phone`: 手机号图标
- `User`: 用户图标
## 数据流
1. 页面初始化时调用 `fetchCustomerData()` 获取用户数据
2. 为每个用户添加客户状态(目前使用随机状态,实际项目中应从数据库获取)
3. 根据当前Tab和搜索条件筛选数据
4. 渲染客户列表
## 注意事项
### 临时实现
- 当前使用 `getRandomStatus()` 生成随机客户状态
- 实际项目中应该:
1. 在数据库中添加客户状态字段
2. 修改后端API返回真实的客户状态
3. 删除随机状态生成函数
### 扩展建议
1. 添加客户详情页面
2. 支持客户状态的修改操作
3. 添加客户添加/编辑功能
4. 支持批量操作
5. 添加导出功能
6. 支持更多筛选条件(注册时间、地区等)
## 文件结构
```
src/dealer/customer/
├── index.tsx # 主页面组件
└── README.md # 说明文档
src/utils/
└── customerStatus.ts # 客户状态工具函数
```

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '客户报备',
navigationBarTextStyle: 'black'
})

400
src/dealer/customer/add.tsx Normal file
View File

@@ -0,0 +1,400 @@
import {useEffect, useState, useRef} from "react";
import {Loading, CellGroup, Cell, Input, Form, Calendar} from '@nutui/nutui-react-taro'
import {Edit, Calendar as CalendarIcon} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {useRouter} from '@tarojs/taro'
import {View, Text} from '@tarojs/components'
import FixedButton from "@/components/FixedButton";
import {useUser} from "@/hooks/useUser";
import {ShopDealerApply} from "@/api/shop/shopDealerApply/model";
import {
addShopDealerApply, getShopDealerApply, pageShopDealerApply,
updateShopDealerApply
} from "@/api/shop/shopDealerApply";
import {
formatDateForDatabase,
extractDateForCalendar, formatDateForDisplay
} from "@/utils/dateUtils";
const AddShopDealerApply = () => {
const {user} = useUser()
const {params} = useRouter();
const [loading, setLoading] = useState<boolean>(true)
const [FormData, setFormData] = useState<ShopDealerApply>()
const formRef = useRef<any>(null)
const [isEditMode, setIsEditMode] = useState<boolean>(false)
const [existingApply, setExistingApply] = useState<ShopDealerApply | null>(null)
// 日期选择器状态
const [showApplyTimePicker, setShowApplyTimePicker] = useState<boolean>(false)
const [showContractTimePicker, setShowContractTimePicker] = useState<boolean>(false)
const [applyTime, setApplyTime] = useState<string>('')
const [contractTime, setContractTime] = useState<string>('')
// 获取审核状态文字
const getApplyStatusText = (status?: number) => {
switch (status) {
case 10:
return '待审核'
case 20:
return '已签约'
case 30:
return '已取消'
default:
return '未知状态'
}
}
console.log(getApplyStatusText)
// 处理签约时间选择
const handleApplyTimeConfirm = (param: string) => {
const selectedDate = param[3] // 选中的日期字符串 (YYYY-M-D)
const formattedDate = formatDateForDatabase(selectedDate) // 转换为数据库格式
setApplyTime(selectedDate) // 保存原始格式用于显示
setShowApplyTimePicker(false)
// 更新表单数据(使用数据库格式)
if (formRef.current) {
formRef.current.setFieldsValue({
applyTime: formattedDate
})
}
}
// 处理合同日期选择
const handleContractTimeConfirm = (param: string) => {
const selectedDate = param[3] // 选中的日期字符串 (YYYY-M-D)
const formattedDate = formatDateForDatabase(selectedDate) // 转换为数据库格式
setContractTime(selectedDate) // 保存原始格式用于显示
setShowContractTimePicker(false)
// 更新表单数据(使用数据库格式)
if (formRef.current) {
formRef.current.setFieldsValue({
contractTime: formattedDate
})
}
}
const reload = async () => {
if (!params.id) {
return false;
}
// 查询当前用户ID是否已有申请记录
try {
const dealerApply = await getShopDealerApply(Number(params.id));
if (dealerApply) {
setFormData(dealerApply)
setIsEditMode(true);
setExistingApply(dealerApply)
// 初始化日期数据从数据库格式转换为Calendar组件格式
if (dealerApply.applyTime) {
setApplyTime(extractDateForCalendar(dealerApply.applyTime))
}
if (dealerApply.contractTime) {
setContractTime(extractDateForCalendar(dealerApply.contractTime))
}
Taro.setNavigationBarTitle({title: '签约'})
}
} catch (error) {
setLoading(true)
console.error('查询申请记录失败:', error);
setIsEditMode(false);
setExistingApply(null);
}
}
// 提交表单
// 计算保护期过期时间7天后
const calculateExpirationTime = (): string => {
const now = new Date();
const expirationDate = new Date(now);
expirationDate.setDate(now.getDate() + 7); // 7天后
// 格式化为数据库需要的格式YYYY-MM-DD HH:mm:ss
const year = expirationDate.getFullYear();
const month = String(expirationDate.getMonth() + 1).padStart(2, '0');
const day = String(expirationDate.getDate()).padStart(2, '0');
const hours = String(expirationDate.getHours()).padStart(2, '0');
const minutes = String(expirationDate.getMinutes()).padStart(2, '0');
const seconds = String(expirationDate.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const submitSucceed = async (values: any) => {
try {
// 验证必填字段
if (!values.mobile || values.mobile.trim() === '') {
Taro.showToast({
title: '请填写联系方式',
icon: 'error'
});
return;
}
// 验证手机号格式
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(values.mobile)) {
Taro.showToast({
title: '请填写正确的手机号',
icon: 'error'
});
return;
}
// 检查客户是否已存在
const res = await pageShopDealerApply({dealerName: values.dealerName, type: 4, applyStatus: 10});
if (res && res.count > 0) {
const existingCustomer = res.list[0];
// 检查是否在7天保护期内
if (!isEditMode && existingCustomer.applyTime) {
// 将申请时间字符串转换为时间戳进行比较
const applyTimeStamp = new Date(existingCustomer.applyTime).getTime();
const currentTimeStamp = new Date().getTime();
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000; // 7天的毫秒数
// 如果在7天保护期内不允许重复添加
if (currentTimeStamp - applyTimeStamp < sevenDaysInMs) {
const remainingDays = Math.ceil((sevenDaysInMs - (currentTimeStamp - applyTimeStamp)) / (24 * 60 * 60 * 1000));
Taro.showToast({
title: `该客户还在保护期,还需等待${remainingDays}天后才能重新添加`,
icon: 'none',
duration: 3000
});
return false;
} else {
// 超过7天保护期可以重新添加显示确认对话框
const modalResult = await new Promise<boolean>((resolve) => {
Taro.showModal({
title: '提示',
content: '该客户已超过7天保护期是否重新添加跟进',
showCancel: true,
cancelText: '取消',
confirmText: '确定',
success: (modalRes) => {
resolve(modalRes.confirm);
},
fail: () => {
resolve(false);
}
});
});
if (!modalResult) {
return false; // 用户取消,不继续执行
}
// 用户确认后继续执行添加逻辑
}
}
}
// 计算过期时间
const expirationTime = isEditMode ? existingApply?.expirationTime : calculateExpirationTime();
// 准备提交的数据
const submitData = {
...values,
type: 4,
realName: values.realName || user?.nickname,
mobile: values.mobile,
refereeId: 33534,
applyStatus: isEditMode ? 20 : 10,
auditTime: undefined,
// 设置保护期过期时间7天后
expirationTime: expirationTime,
// 确保日期数据正确提交(使用数据库格式)
applyTime: values.applyTime || (applyTime ? formatDateForDatabase(applyTime) : ''),
contractTime: values.contractTime || (contractTime ? formatDateForDatabase(contractTime) : '')
};
// 调试信息
console.log('=== 提交数据调试 ===');
console.log('是否编辑模式:', isEditMode);
console.log('计算的过期时间:', expirationTime);
console.log('提交的数据:', submitData);
console.log('==================');
// 如果是编辑模式添加现有申请的id
if (isEditMode && existingApply?.applyId) {
submitData.applyId = existingApply.applyId;
}
// 执行新增或更新操作
if (isEditMode) {
await updateShopDealerApply(submitData);
} else {
await addShopDealerApply(submitData);
}
Taro.showToast({
title: `${isEditMode ? '更新' : '提交'}成功`,
icon: 'success'
});
setTimeout(() => {
Taro.navigateBack();
}, 1000);
} catch (error) {
console.error('提交失败:', error);
Taro.showToast({
title: '提交失败,请重试',
icon: 'error'
});
}
}
// 处理固定按钮点击事件
const handleFixedButtonClick = () => {
// 触发表单提交
formRef.current?.submit();
};
const submitFailed = (error: any) => {
console.log(error, 'err...')
}
useEffect(() => {
reload().then(() => {
setLoading(false)
})
}, []); // 依赖用户ID当用户变化时重新加载
if (loading) {
return <Loading className={'px-2'}></Loading>
}
return (
<>
<Form
ref={formRef}
divider
initialValues={FormData}
labelPosition="left"
onFinish={(values) => submitSucceed(values)}
onFinishFailed={(errors) => submitFailed(errors)}
>
<View className={'bg-gray-100 h-3'}></View>
<CellGroup style={{padding: '4px 0'}}>
<Form.Item name="dealerName" label="公司名称" initialValue={FormData?.dealerName} required>
<Input placeholder="公司名称" maxLength={10} disabled={isEditMode}/>
</Form.Item>
<Form.Item name="realName" label="联系人" initialValue={FormData?.realName} required>
<Input placeholder="请输入联系人" disabled={isEditMode}/>
</Form.Item>
<Form.Item name="mobile" label="联系方式" initialValue={FormData?.mobile} required>
<Input placeholder="请输入手机号" disabled={isEditMode} maxLength={11}/>
</Form.Item>
<Form.Item name="address" label="公司地址" initialValue={FormData?.address} required>
<Input placeholder="请输入详细地址" disabled={isEditMode}/>
</Form.Item>
<Form.Item name="dealerCode" label="户号" initialValue={FormData?.dealerCode} required>
<Input placeholder="请填写户号" disabled={isEditMode}/>
</Form.Item>
<Form.Item name="comments" label="跟进情况" initialValue={FormData?.comments}>
<Input placeholder="请填写跟进情况" disabled={isEditMode}/>
</Form.Item>
{isEditMode && (
<>
<Form.Item name="money" label="签约价格" initialValue={FormData?.money} required>
<Input placeholder="(元/兆瓦时)" disabled={false}/>
</Form.Item>
<Form.Item name="applyTime" label="签约时间" initialValue={FormData?.applyTime}>
<View
className="flex items-center justify-between py-2"
onClick={() => setShowApplyTimePicker(true)}
>
<View className="flex items-center">
<CalendarIcon size={16} color="#999" className="mr-2"/>
<Text style={{color: applyTime ? '#333' : '#999'}}>
{applyTime ? formatDateForDisplay(applyTime) : '请选择签约时间'}
</Text>
</View>
</View>
</Form.Item>
<Form.Item name="contractTime" label="合同日期" initialValue={FormData?.contractTime}>
<View
className="flex items-center justify-between py-2"
onClick={() => setShowContractTimePicker(true)}
>
<View className="flex items-center">
<CalendarIcon size={16} color="#999" className="mr-2"/>
<Text style={{color: contractTime ? '#333' : '#999'}}>
{contractTime ? formatDateForDisplay(contractTime) : '请选择合同生效起止时间'}
</Text>
</View>
</View>
</Form.Item>
{/*<Form.Item name="refereeId" label="邀请人ID" initialValue={FormData?.refereeId} required>*/}
{/* <Input placeholder="邀请人ID"/>*/}
{/*</Form.Item>*/}
</>
)}
<Form.Item name="userId" label="报备人" initialValue={FormData?.userId} required>
</Form.Item>
</CellGroup>
</Form>
{/* 签约时间选择器 */}
<Calendar
visible={showApplyTimePicker}
defaultValue={applyTime}
onClose={() => setShowApplyTimePicker(false)}
onConfirm={handleApplyTimeConfirm}
/>
{/* 合同日期选择器 */}
<Calendar
visible={showContractTimePicker}
defaultValue={contractTime}
onClose={() => setShowContractTimePicker(false)}
onConfirm={handleContractTimeConfirm}
/>
{/* 审核状态显示(仅在编辑模式下显示) */}
{isEditMode && (
<CellGroup>
{/*<Cell*/}
{/* title={'审核状态'}*/}
{/* extra={*/}
{/* <span style={{*/}
{/* color: FormData?.applyStatus === 20 ? '#52c41a' :*/}
{/* FormData?.applyStatus === 30 ? '#ff4d4f' : '#faad14'*/}
{/* }}>*/}
{/* {getApplyStatusText(FormData?.applyStatus)}*/}
{/* </span>*/}
{/* }*/}
{/*/>*/}
{FormData?.applyStatus === 20 && (
<Cell title={'签约时间'} extra={FormData?.auditTime || '无'}/>
)}
{FormData?.applyStatus === 30 && (
<Cell title={'驳回原因'} extra={FormData?.rejectReason || '无'}/>
)}
</CellGroup>
)}
{/* 底部浮动按钮 */}
{(!isEditMode || FormData?.applyStatus === 10) && (
<FixedButton
icon={<Edit/>}
text={'立即提交'}
onClick={handleFixedButtonClick}
/>
)}
</>
);
};
export default AddShopDealerApply;

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '客户列表'
})

View File

@@ -0,0 +1,548 @@
import {useState, useEffect, useCallback} from 'react'
import {View, Text} from '@tarojs/components'
import Taro, {useDidShow} from '@tarojs/taro'
import {Loading, InfiniteLoading, Empty, Space, Tabs, TabPane, Tag, Button} from '@nutui/nutui-react-taro'
import {Phone, AngleDoubleLeft} from '@nutui/icons-react-taro'
import type {ShopDealerApply, ShopDealerApply as UserType} from "@/api/shop/shopDealerApply/model";
import {
CustomerStatus,
getStatusText,
getStatusTagType,
getStatusOptions,
mapApplyStatusToCustomerStatus,
mapCustomerStatusToApplyStatus
} from '@/utils/customerStatus';
import FixedButton from "@/components/FixedButton";
import navTo from "@/utils/common";
import {pageShopDealerApply, removeShopDealerApply, updateShopDealerApply} from "@/api/shop/shopDealerApply";
// 扩展User类型添加客户状态和保护天数
interface CustomerUser extends UserType {
customerStatus?: CustomerStatus;
protectDays?: number; // 剩余保护天数
}
const CustomerIndex = () => {
const [list, setList] = useState<CustomerUser[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [activeTab, setActiveTab] = useState<CustomerStatus>('all')
const [searchValue, _] = useState<string>('')
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
// Tab配置
const tabList = getStatusOptions();
// 复制手机号
const copyPhone = (phone: string) => {
Taro.setClipboardData({
data: phone,
success: () => {
Taro.showToast({
title: '手机号已复制',
icon: 'success',
duration: 1500
});
}
});
};
// 一键拨打
const makePhoneCall = (phone: string) => {
Taro.makePhoneCall({
phoneNumber: phone,
fail: () => {
Taro.showToast({
title: '拨打取消',
icon: 'error'
});
}
});
};
// 编辑跟进情况
const editComments = (customer: CustomerUser) => {
Taro.showModal({
title: '编辑跟进情况',
// @ts-ignore
editable: true,
placeholderText: '请输入跟进情况',
content: customer.comments || '',
success: async (res) => {
// @ts-ignore
if (res.confirm && res.content !== undefined) {
try {
// 更新跟进情况
await updateShopDealerApply({
...customer,
// @ts-ignore
comments: res.content.trim()
});
Taro.showToast({
title: '更新成功',
icon: 'success'
});
// 刷新列表
setList([]);
setPage(1);
setHasMore(true);
fetchCustomerData(activeTab, true);
} catch (error) {
console.error('更新跟进情况失败:', error);
Taro.showToast({
title: '更新失败,请重试',
icon: 'error'
});
}
}
}
});
};
// 计算剩余保护天数(基于过期时间)
const calculateProtectDays = (expirationTime?: string, applyTime?: string): number => {
try {
// 优先使用过期时间字段
if (expirationTime) {
const expDate = new Date(expirationTime.replace(' ', 'T'));
const now = new Date();
// 计算剩余毫秒数
const remainingMs = expDate.getTime() - now.getTime();
// 转换为天数,向上取整
const remainingDays = Math.ceil(remainingMs / (1000 * 60 * 60 * 24));
console.log('=== 基于过期时间计算 ===');
console.log('过期时间:', expirationTime);
console.log('当前时间:', now.toLocaleString());
console.log('剩余天数:', remainingDays);
console.log('======================');
return Math.max(0, remainingDays);
}
// 如果没有过期时间,回退到基于申请时间计算
if (!applyTime) return 0;
const protectionPeriod = 7; // 保护期7天
// 解析申请时间
let applyDate: Date;
if (applyTime.includes('T')) {
applyDate = new Date(applyTime);
} else {
applyDate = new Date(applyTime.replace(' ', 'T'));
}
// 获取当前时间
const now = new Date();
// 只比较日期部分,忽略时间
const applyDateOnly = new Date(applyDate.getFullYear(), applyDate.getMonth(), applyDate.getDate());
const currentDateOnly = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// 计算已经过去的天数
const timeDiff = currentDateOnly.getTime() - applyDateOnly.getTime();
const daysPassed = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
// 计算剩余保护天数
const remainingDays = protectionPeriod - daysPassed;
console.log('=== 基于申请时间计算 ===');
console.log('申请时间:', applyTime);
console.log('已过去天数:', daysPassed);
console.log('剩余保护天数:', remainingDays);
console.log('======================');
return Math.max(0, remainingDays);
} catch (error) {
console.error('日期计算错误:', error);
return 0;
}
};
// 获取客户数据
const fetchCustomerData = useCallback(async (statusFilter?: CustomerStatus, resetPage = false, targetPage?: number) => {
setLoading(true);
try {
const currentPage = resetPage ? 1 : (targetPage || page);
// 构建API参数根据状态筛选
const params: any = {
type: 4,
page: currentPage
};
const applyStatus = mapCustomerStatusToApplyStatus(statusFilter || activeTab);
if (applyStatus !== undefined) {
params.applyStatus = applyStatus;
}
const res = await pageShopDealerApply(params);
if (res?.list && res.list.length > 0) {
// 正确映射状态并计算保护天数
const mappedList = res.list.map(customer => ({
...customer,
customerStatus: mapApplyStatusToCustomerStatus(customer.applyStatus || 10),
protectDays: calculateProtectDays(customer.expirationTime, customer.applyTime || customer.createTime || '')
}));
// 如果是重置页面或第一页,直接设置新数据;否则追加数据
if (resetPage || currentPage === 1) {
setList(mappedList);
} else {
setList(prevList => prevList.concat(mappedList));
}
// 正确判断是否还有更多数据
const hasMoreData = res.list.length >= 10; // 假设每页10条数据
setHasMore(hasMoreData);
} else {
if (resetPage || currentPage === 1) {
setList([]);
}
setHasMore(false);
}
setPage(currentPage);
} catch (error) {
console.error('获取客户数据失败:', error);
Taro.showToast({
title: '加载失败,请重试',
icon: 'none'
});
} finally {
setLoading(false);
}
}, [activeTab, page]);
const reloadMore = async () => {
if (loading || !hasMore) return; // 防止重复加载
const nextPage = page + 1;
await fetchCustomerData(activeTab, false, nextPage);
}
// 根据搜索条件筛选数据状态筛选已在API层面处理
const getFilteredList = () => {
let filteredList = list;
// 按搜索关键词筛选
if (searchValue.trim()) {
const keyword = searchValue.trim().toLowerCase();
filteredList = filteredList.filter(customer =>
(customer.realName && customer.realName.toLowerCase().includes(keyword)) ||
(customer.dealerName && customer.dealerName.toLowerCase().includes(keyword)) ||
(customer.dealerCode && customer.dealerCode.toLowerCase().includes(keyword)) ||
(customer.mobile && customer.mobile.includes(keyword)) ||
(customer.userId && customer.userId.toString().includes(keyword))
);
}
return filteredList;
};
// 获取各状态的统计数量
const [statusCounts, setStatusCounts] = useState({
all: 0,
pending: 0,
signed: 0,
cancelled: 0
});
// 获取所有状态的统计数量
const fetchStatusCounts = useCallback(async () => {
try {
// 并行获取各状态的数量
const [allRes, pendingRes, signedRes, cancelledRes] = await Promise.all([
pageShopDealerApply({type: 4}), // 全部
pageShopDealerApply({applyStatus: 10, type: 4}), // 跟进中
pageShopDealerApply({applyStatus: 20, type: 4}), // 已签约
pageShopDealerApply({applyStatus: 30, type: 4}) // 已取消
]);
setStatusCounts({
all: allRes?.count || 0,
pending: pendingRes?.count || 0,
signed: signedRes?.count || 0,
cancelled: cancelledRes?.count || 0
});
} catch (error) {
console.error('获取状态统计失败:', error);
}
}, []);
const getStatusCounts = () => statusCounts;
// 取消操作
const handleCancel = (customer: ShopDealerApply) => {
updateShopDealerApply({
...customer,
applyStatus: 30
}).then(() => {
Taro.showToast({
title: '取消成功',
icon: 'success'
});
// 重新加载当前tab的数据
setList([]);
setPage(1);
setHasMore(true);
fetchCustomerData(activeTab, true).then();
fetchStatusCounts().then();
})
};
// 删除
const handleDelete = (customer: ShopDealerApply) => {
removeShopDealerApply(customer.applyId).then(() => {
Taro.showToast({
title: '删除成功',
icon: 'success'
});
// 刷新当前tab的数据
setList([]);
setPage(1);
setHasMore(true);
fetchCustomerData(activeTab, true).then();
fetchStatusCounts().then();
})
}
// 初始化数据
useEffect(() => {
fetchCustomerData(activeTab, true).then();
fetchStatusCounts().then();
}, []);
// 当activeTab变化时重新获取数据
useEffect(() => {
setList([]); // 清空列表
setPage(1); // 重置页码
setHasMore(true); // 重置加载状态
fetchCustomerData(activeTab, true);
}, [activeTab]);
// 监听页面显示,当从其他页面返回时刷新数据
useDidShow(() => {
// 刷新当前tab的数据和统计信息
setList([]);
setPage(1);
setHasMore(true);
fetchCustomerData(activeTab, true);
fetchStatusCounts();
});
// 渲染客户项
const renderCustomerItem = (customer: CustomerUser) => (
<View key={customer.userId} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex items-center mb-3">
<View className="flex-1">
<View className="flex items-center justify-between mb-1">
<Text className="font-semibold text-gray-800 mr-2">
{customer.dealerName}
</Text>
{customer.customerStatus && (
<Tag type={getStatusTagType(customer.customerStatus)}>
{getStatusText(customer.customerStatus)}
</Tag>
)}
</View>
<View className="flex items-center mb-1">
<Space direction="vertical">
<Text className="text-xs text-gray-500">{customer.realName}</Text>
<View className="flex items-center">
<Text className="text-xs text-gray-500" onClick={(e) => {
e.stopPropagation();
makePhoneCall(customer.mobile || '');
}}>{customer.mobile}</Text>
<View className="flex items-center ml-2">
<Phone
size={12}
className="text-green-500 mr-2"
onClick={(e) => {
e.stopPropagation();
makePhoneCall(customer.mobile || '');
}}
/>
<Text
className="text-xs text-blue-500 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
copyPhone(customer.mobile || '');
}}
>
</Text>
</View>
</View>
<Text className="text-xs text-gray-500">
{customer.createTime}
</Text>
</Space>
</View>
{/* 保护天数显示 */}
{customer.applyStatus === 10 && (
<View className="flex items-center my-1">
<Text className="text-xs text-gray-500 mr-2"></Text>
{customer.protectDays && customer.protectDays > 0 ? (
<Text className={`text-xs px-2 py-1 rounded ${
customer.protectDays <= 2
? 'bg-red-100 text-red-600'
: customer.protectDays <= 4
? 'bg-orange-100 text-orange-600'
: 'bg-green-100 text-green-600'
}`}>
{customer.protectDays}
</Text>
) : (
<Text className="text-xs px-2 py-1 rounded bg-gray-100 text-gray-500">
</Text>
)}
</View>
)}
<View className={'flex items-center gap-2'}>
<Text className="text-xs text-gray-500">{customer?.nickName}</Text>
<AngleDoubleLeft size={12} className={'text-blue-500'} />
<Text className={'text-xs text-gray-500'}>{customer?.refereeName}</Text>
</View>
{/* 显示 comments 字段 */}
<Space className="flex items-center">
<Text className="text-xs text-gray-500">{customer.comments || '暂无'}</Text>
<Text
className="text-xs text-blue-500 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
editComments(customer);
}}
>
</Text>
</Space>
</View>
</View>
{/* 跟进中状态显示操作按钮 */}
{(customer.applyStatus === 10 && customer.userId == Taro.getStorageSync('UserId')) && (
<Space className="flex justify-end">
<Button
size="small"
onClick={() => navTo(`/dealer/customer/add?id=${customer.applyId}`, true)}
style={{marginRight: '8px', backgroundColor: '#52c41a', color: 'white'}}
>
</Button>
<Button
size="small"
onClick={() => handleCancel(customer)}
style={{backgroundColor: '#ff4d4f', color: 'white'}}
>
</Button>
</Space>
)}
{(customer.applyStatus === 30 && customer.userId == Taro.getStorageSync('UserId')) && (
<Space className="flex justify-end">
<Button
size="small"
onClick={() => handleDelete(customer)}
style={{backgroundColor: '#ff4d4f', color: 'white'}}
>
</Button>
</Space>
)}
</View>
);
// 渲染客户列表
const renderCustomerList = () => {
const filteredList = getFilteredList();
return (
<View className="p-4" style={{
height: '90vh',
overflowY: 'auto',
overflowX: 'hidden'
}}>
<InfiniteLoading
target="scroll"
hasMore={hasMore}
onLoadMore={reloadMore}
onScroll={() => {
// 滚动事件处理
}}
onScrollToUpper={() => {
// 滚动到顶部事件处理
}}
loadingText={
<>
...
</>
}
loadMoreText={
filteredList.length === 0 ? (
<Empty
style={{backgroundColor: 'transparent'}}
description={loading ? "加载中..." : "暂无客户数据"}
/>
) : (
<View className={'h-3 flex items-center justify-center'}>
<Text className="text-gray-500 text-sm"></Text>
</View>
)
}
>
{loading && filteredList.length === 0 ? (
<View className="flex items-center justify-center py-8">
<Loading/>
<Text className="text-gray-500 mt-2 ml-2">...</Text>
</View>
) : (
filteredList.map(renderCustomerItem)
)}
</InfiniteLoading>
</View>
);
};
return (
<View className="min-h-screen bg-gray-50">
{/* 顶部Tabs */}
<View className="bg-white">
<Tabs
value={activeTab}
onChange={(value) => setActiveTab(value as CustomerStatus)}
>
{tabList.map(tab => {
const counts = getStatusCounts();
const count = counts[tab.value as keyof typeof counts] || 0;
return (
<TabPane
key={tab.value}
title={`${tab.label}${count > 0 ? `(${count})` : ''}`}
value={tab.value}
/>
);
})}
</Tabs>
</View>
{/* 客户列表 */}
{renderCustomerList()}
<FixedButton text={'客户报备'} onClick={() => Taro.navigateTo({url: '/dealer/customer/add'})}/>
</View>
);
};
export default CustomerIndex;

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '入市查询'
})

View File

@@ -0,0 +1,207 @@
import {useState, useCallback} from 'react'
import {View, Text} from '@tarojs/components'
import Taro from '@tarojs/taro'
import {Loading, InfiniteLoading, Empty, Space, SearchBar} from '@nutui/nutui-react-taro'
import type {ShopDealerApply as UserType} from "@/api/shop/shopDealerApply/model";
import {
CustomerStatus,
mapApplyStatusToCustomerStatus,
} from '@/utils/customerStatus';
import {pageShopDealerApply} from "@/api/shop/shopDealerApply";
// 扩展User类型添加客户状态
interface CustomerUser extends UserType {
customerStatus?: CustomerStatus;
}
const CustomerTrading = () => {
const [list, setList] = useState<CustomerUser[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [searchValue, setSearchValue] = useState<string>('')
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
// 获取客户数据
const fetchCustomerData = useCallback(async (resetPage = false, targetPage?: number, searchKeyword?: string) => {
setLoading(true);
try {
const currentPage = resetPage ? 1 : (targetPage || page);
// 构建API参数根据状态筛选
const params: any = {
type: 3,
page: currentPage
};
// 添加搜索关键词
if (searchKeyword && searchKeyword.trim()) {
params.keywords = searchKeyword.trim();
}
const res = await pageShopDealerApply(params);
if (res?.list && res.list.length > 0) {
// 正确映射状态
const mappedList = res.list.map(customer => ({
...customer,
customerStatus: mapApplyStatusToCustomerStatus(customer.applyStatus || 10)
}));
// 如果是重置页面或第一页,直接设置新数据;否则追加数据
if (resetPage || currentPage === 1) {
setList(mappedList);
} else {
setList(prevList => prevList.concat(mappedList));
}
// 正确判断是否还有更多数据
const hasMoreData = res.list.length >= 10; // 假设每页10条数据
setHasMore(hasMoreData);
} else {
if (resetPage || currentPage === 1) {
setList([]);
}
setHasMore(false);
}
setPage(currentPage);
} catch (error) {
console.error('获取客户数据失败:', error);
Taro.showToast({
title: '加载失败,请重试',
icon: 'none'
});
} finally {
setLoading(false);
}
}, [page]);
const reloadMore = async () => {
if (loading || !hasMore) return; // 防止重复加载
const nextPage = page + 1;
await fetchCustomerData(false, nextPage, searchValue);
}
// 获取列表数据(现在使用服务端搜索,不需要客户端过滤)
const getFilteredList = () => {
return list;
};
// 搜索处理函数
const handleSearch = (keyword: string) => {
if(keyword.length < 4){
Taro.showToast({
title: '请输入至少4个字符',
icon: 'none'
});
return;
}
setSearchValue(keyword);
setList([]);
setPage(1);
setHasMore(true);
fetchCustomerData(true, 1, keyword);
};
// 清空搜索
const handleClearSearch = () => {
setSearchValue('');
setList([]);
setPage(1);
setHasMore(true);
fetchCustomerData(true, 1, '');
};
// 渲染客户项
const renderCustomerItem = (customer: CustomerUser) => (
<View key={customer.userId} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex items-center">
<View className="flex-1">
<View className="flex items-center justify-between mb-1">
<Text className="font-semibold text-gray-800 mr-2">
{customer.dealerName}
</Text>
</View>
<Space direction={'vertical'}>
{/*<Text className="text-xs text-gray-500">统一代码:{customer.dealerCode}</Text>*/}
<Text className="text-xs text-gray-500">
{customer.createTime}
</Text>
</Space>
</View>
</View>
</View>
);
// 渲染客户列表
const renderCustomerList = () => {
const filteredList = getFilteredList();
return (
<View className="p-4" style={{
height: '90vh',
overflowY: 'auto',
overflowX: 'hidden'
}}>
<InfiniteLoading
target="scroll"
hasMore={hasMore}
onLoadMore={reloadMore}
onScroll={() => {
// 滚动事件处理
}}
onScrollToUpper={() => {
// 滚动到顶部事件处理
}}
loadingText={
<>
...
</>
}
loadMoreText={
filteredList.length === 0 ? (
<Empty
style={{backgroundColor: 'transparent'}}
description={loading ? "加载中..." : "暂无客户数据"}
/>
) : (
<View className={'h-12 flex items-center justify-center'}>
<Text className="text-gray-500 text-sm"></Text>
</View>
)
}
>
{loading && filteredList.length === 0 ? (
<View className="flex items-center justify-center py-8">
<Loading/>
<Text className="text-gray-500 mt-2 ml-2">...</Text>
</View>
) : (
filteredList.map(renderCustomerItem)
)}
</InfiniteLoading>
</View>
);
};
return (
<View className="min-h-screen bg-gray-50">
{/* 搜索栏 */}
<View className="bg-white shadow-sm">
<SearchBar
placeholder="请输入搜索关键词"
value={searchValue}
onSearch={(value) => handleSearch(value)}
onClear={() => handleClearSearch()}
/>
</View>
{/* 客户列表 */}
{renderCustomerList()}
</View>
);
};
export default CustomerTrading;

View File

@@ -1,3 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '医生版'
navigationBarTitleText: '分销中心'
})

View File

@@ -131,28 +131,28 @@ const DealerIndex: React.FC = () => {
<View className="mb-4">
<Text className="font-semibold text-gray-800"></Text>
</View>
<View className="grid grid-cols-3 gap-4">
<View className="grid grid-cols-3 gap-3">
<View className="text-center p-3 rounded-lg" style={{
background: businessGradients.money.available
}}>
<Text className="text-2xl font-bold mb-1 text-white">
¥{formatMoney(dealerUser.money)}
<Text className="text-lg font-bold mb-1 text-white">
{formatMoney(dealerUser.money)}
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View>
<View className="text-center p-3 rounded-lg" style={{
background: businessGradients.money.frozen
}}>
<Text className="text-2xl font-bold mb-1 text-white">
¥{formatMoney(dealerUser.freezeMoney)}
<Text className="text-lg font-bold mb-1 text-white">
{formatMoney(dealerUser.freezeMoney)}
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View>
<View className="text-center p-3 rounded-lg" style={{
background: businessGradients.money.total
}}>
<Text className="text-2xl font-bold mb-1 text-white">
¥{formatMoney(dealerUser.totalMoney)}
<Text className="text-lg font-bold mb-1 text-white">
{formatMoney(dealerUser.totalMoney)}
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View>

View File

@@ -1,161 +1,63 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import { Empty, Tabs, Tag, PullToRefresh, Loading } from '@nutui/nutui-react-taro'
import React, {useState, useEffect, useCallback} from 'react'
import {View, Text, ScrollView} from '@tarojs/components'
import {Empty, Tag, PullToRefresh, Loading} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import { pageShopDealerOrder } from '@/api/shop/shopDealerOrder'
import { useDealerUser } from '@/hooks/useDealerUser'
import type { ShopDealerOrder } from '@/api/shop/shopDealerOrder/model'
import {pageShopDealerOrder} from '@/api/shop/shopDealerOrder'
import {useDealerUser} from '@/hooks/useDealerUser'
import type {ShopDealerOrder} from '@/api/shop/shopDealerOrder/model'
interface OrderWithDetails extends ShopDealerOrder {
orderNo?: string
customerName?: string
totalCommission?: string
// 当前用户在此订单中的层级和佣金
userLevel?: 1 | 2 | 3
userCommission?: string
}
const DealerOrders: React.FC = () => {
const [activeTab, setActiveTab] = useState<string>('0')
const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const [loadingMore, setLoadingMore] = useState<boolean>(false)
const [orders, setOrders] = useState<OrderWithDetails[]>([])
const [statistics, setStatistics] = useState({
totalOrders: 0,
totalCommission: '0.00',
pendingCommission: '0.00',
// 分层统计
level1: { orders: 0, commission: '0.00' },
level2: { orders: 0, commission: '0.00' },
level3: { orders: 0, commission: '0.00' }
})
const [currentPage, setCurrentPage] = useState<number>(1)
const [hasMore, setHasMore] = useState<boolean>(true)
const { dealerUser } = useDealerUser()
const {dealerUser} = useDealerUser()
// 获取订单数据 - 查询当前用户作为各层级分销商的所有订单
const fetchOrders = useCallback(async () => {
// 获取订单数据
const fetchOrders = useCallback(async (page: number = 1, isRefresh: boolean = false) => {
if (!dealerUser?.userId) return
try {
setLoading(true)
// 并行查询三个层级的订单
const [level1Result, level2Result, level3Result] = await Promise.all([
// 一级分销商订单
pageShopDealerOrder({
page: 1,
limit: 100,
firstUserId: dealerUser.userId
}),
// 二级分销商订单
pageShopDealerOrder({
page: 1,
limit: 100,
secondUserId: dealerUser.userId
}),
// 三级分销商订单
pageShopDealerOrder({
page: 1,
limit: 100,
thirdUserId: dealerUser.userId
})
])
const allOrders: OrderWithDetails[] = []
const stats = {
totalOrders: 0,
totalCommission: '0.00',
pendingCommission: '0.00',
level1: { orders: 0, commission: '0.00' },
level2: { orders: 0, commission: '0.00' },
level3: { orders: 0, commission: '0.00' }
if (isRefresh) {
setRefreshing(true)
} else if (page === 1) {
setLoading(true)
} else {
setLoadingMore(true)
}
// 处理一级分销订单
if (level1Result?.list) {
const level1Orders = level1Result.list.map(order => ({
const result = await pageShopDealerOrder({
page,
limit: 10
})
if (result?.list) {
const newOrders = result.list.map(order => ({
...order,
orderNo: `DD${order.orderId}`,
orderNo: `${order.orderId}`,
customerName: `用户${order.userId}`,
userLevel: 1 as const,
userCommission: order.firstMoney || '0.00',
totalCommission: (
parseFloat(order.firstMoney || '0') +
parseFloat(order.secondMoney || '0') +
parseFloat(order.thirdMoney || '0')
).toFixed(2)
userCommission: order.firstMoney || '0.00'
}))
allOrders.push(...level1Orders)
stats.level1.orders = level1Orders.length
stats.level1.commission = level1Orders.reduce((sum, order) =>
sum + parseFloat(order.userCommission || '0'), 0
).toFixed(2)
if (page === 1) {
setOrders(newOrders)
} else {
setOrders(prev => [...prev, ...newOrders])
}
setHasMore(newOrders.length === 10)
setCurrentPage(page)
}
// 处理二级分销订单
if (level2Result?.list) {
const level2Orders = level2Result.list.map(order => ({
...order,
orderNo: `DD${order.orderId}`,
customerName: `用户${order.userId}`,
userLevel: 2 as const,
userCommission: order.secondMoney || '0.00',
totalCommission: (
parseFloat(order.firstMoney || '0') +
parseFloat(order.secondMoney || '0') +
parseFloat(order.thirdMoney || '0')
).toFixed(2)
}))
allOrders.push(...level2Orders)
stats.level2.orders = level2Orders.length
stats.level2.commission = level2Orders.reduce((sum, order) =>
sum + parseFloat(order.userCommission || '0'), 0
).toFixed(2)
}
// 处理三级分销订单
if (level3Result?.list) {
const level3Orders = level3Result.list.map(order => ({
...order,
orderNo: `DD${order.orderId}`,
customerName: `用户${order.userId}`,
userLevel: 3 as const,
userCommission: order.thirdMoney || '0.00',
totalCommission: (
parseFloat(order.firstMoney || '0') +
parseFloat(order.secondMoney || '0') +
parseFloat(order.thirdMoney || '0')
).toFixed(2)
}))
allOrders.push(...level3Orders)
stats.level3.orders = level3Orders.length
stats.level3.commission = level3Orders.reduce((sum, order) =>
sum + parseFloat(order.userCommission || '0'), 0
).toFixed(2)
}
// 去重(同一个订单可能在多个层级中出现)
const uniqueOrders = allOrders.filter((order, index, self) =>
index === self.findIndex(o => o.orderId === order.orderId)
)
// 计算总统计
stats.totalOrders = uniqueOrders.length
stats.totalCommission = (
parseFloat(stats.level1.commission) +
parseFloat(stats.level2.commission) +
parseFloat(stats.level3.commission)
).toFixed(2)
stats.pendingCommission = allOrders
.filter(order => order.isSettled === 0)
.reduce((sum, order) => sum + parseFloat(order.userCommission || '0'), 0)
.toFixed(2)
setOrders(uniqueOrders)
setStatistics(stats)
} catch (error) {
console.error('获取分销订单失败:', error)
Taro.showToast({
@@ -164,18 +66,27 @@ const DealerOrders: React.FC = () => {
})
} finally {
setLoading(false)
setRefreshing(false)
setLoadingMore(false)
}
}, [dealerUser?.userId])
// 刷新数据
// 下拉刷新
const handleRefresh = async () => {
await fetchOrders()
await fetchOrders(1, true)
}
// 加载更多
const handleLoadMore = async () => {
if (!loadingMore && hasMore) {
await fetchOrders(currentPage + 1)
}
}
// 初始化加载数据
useEffect(() => {
if (dealerUser?.userId) {
fetchOrders().then()
fetchOrders(1)
}
}, [fetchOrders])
@@ -193,198 +104,87 @@ const DealerOrders: React.FC = () => {
const renderOrderItem = (order: OrderWithDetails) => (
<View key={order.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-3">
<View>
<Text className="font-semibold text-gray-800 mb-1">
{order.orderNo}
</Text>
<Text className="text-sm text-gray-500">
{order.customerName}
</Text>
{/* 显示用户在此订单中的层级 */}
<Text className="text-xs text-blue-500">
{order.userLevel === 1 && '一级分销'}
{order.userLevel === 2 && '二级分销'}
{order.userLevel === 3 && '三级分销'}
</Text>
</View>
<View className="flex justify-between items-start mb-1">
<Text className="font-semibold text-gray-800">
{order.orderNo}
</Text>
<Tag type={getStatusColor(order.isSettled, order.isInvalid)}>
{getStatusText(order.isSettled, order.isInvalid)}
</Tag>
</View>
<View className="flex justify-between items-center mb-1">
<Text className="text-sm text-gray-400">
¥{order.orderPrice || '0.00'}
</Text>
<Text className="text-sm text-orange-500 font-semibold">
¥{order.userCommission}
</Text>
</View>
<View className="flex justify-between items-center">
<View>
<Text className="text-sm text-gray-600">
¥{order.orderPrice || '0.00'}
</Text>
<Text className="text-sm text-orange-500 font-semibold">
¥{order.userCommission}
</Text>
<Text className="text-xs text-gray-400">
¥{order.totalCommission}
</Text>
</View>
<Text className="text-xs text-gray-400">
<Text className="text-sm text-gray-400">
{order.customerName}
</Text>
<Text className="text-sm text-gray-400">
{order.createTime}
</Text>
</View>
</View>
)
// 根据状态和层级过滤订单
const getFilteredOrders = (filter: string) => {
switch (filter) {
case '1': // 一级分销
return orders.filter(order => order.userLevel === 1)
case '2': // 二级分销
return orders.filter(order => order.userLevel === 2)
case '3': // 三级分销
return orders.filter(order => order.userLevel === 3)
case '4': // 待结算
return orders.filter(order => order.isSettled === 0 && order.isInvalid === 0)
case '5': // 已结算
return orders.filter(order => order.isSettled === 1)
case '6': // 已失效
return orders.filter(order => order.isInvalid === 1)
default: // 全部
return orders
}
}
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading />
<Loading/>
<Text className="text-gray-500 mt-2">...</Text>
</View>
)
}
return (
<View className="bg-gray-50 min-h-screen">
{/* 统计卡片 */}
<View className="bg-white p-4 mb-4">
{/* 总体统计 */}
<View className="grid grid-cols-3 gap-4 mb-4">
<View className="text-center">
<Text className="text-lg font-bold text-blue-500">{statistics.totalOrders}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="text-center">
<Text className="text-lg font-bold text-green-500">¥{statistics.totalCommission}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="text-center">
<Text className="text-lg font-bold text-orange-500">¥{statistics.pendingCommission}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
</View>
{/* 分层统计 */}
<View className="border-t pt-3">
<Text className="text-sm text-gray-600 mb-2"></Text>
<View className="grid grid-cols-3 gap-2">
<View className="text-center bg-gray-50 rounded p-2">
<Text className="text-sm font-semibold text-red-500">{statistics.level1.orders}</Text>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-xs text-red-500">¥{statistics.level1.commission}</Text>
</View>
<View className="text-center bg-gray-50 rounded p-2">
<Text className="text-sm font-semibold text-blue-500">{statistics.level2.orders}</Text>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-xs text-blue-500">¥{statistics.level2.commission}</Text>
</View>
<View className="text-center bg-gray-50 rounded p-2">
<Text className="text-sm font-semibold text-purple-500">{statistics.level3.orders}</Text>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-xs text-purple-500">¥{statistics.level3.commission}</Text>
</View>
</View>
</View>
</View>
{/* 订单列表 */}
<Tabs value={activeTab} onChange={() => setActiveTab}>
<Tabs.TabPane title="全部" value="0">
<PullToRefresh
onRefresh={handleRefresh}
>
<View className="p-4">
{loading ? (
<View className="text-center py-8">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : getFilteredOrders('0').length > 0 ? (
getFilteredOrders('0').map(renderOrderItem)
) : (
<Empty description="暂无分销订单" />
)}
</View>
</PullToRefresh>
</Tabs.TabPane>
<Tabs.TabPane title="一级分销" value="1">
<View className="min-h-screen bg-gray-50">
<PullToRefresh
onRefresh={handleRefresh}
disabled={refreshing}
pullingText="下拉刷新"
canReleaseText="释放刷新"
refreshingText="刷新中..."
completeText="刷新完成"
>
<ScrollView
scrollY
className="h-screen"
onScrollToLower={handleLoadMore}
lowerThreshold={50}
>
<View className="p-4">
{getFilteredOrders('1').length > 0 ? (
getFilteredOrders('1').map(renderOrderItem)
{loading && orders.length === 0 ? (
<View className="text-center py-8">
<Loading/>
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : orders.length > 0 ? (
<>
{orders.map(renderOrderItem)}
{loadingMore && (
<View className="text-center py-4">
<Loading/>
<Text className="text-gray-500 mt-1 text-sm">...</Text>
</View>
)}
{!hasMore && orders.length > 0 && (
<View className="text-center py-4">
<Text className="text-gray-400 text-sm"></Text>
</View>
)}
</>
) : (
<Empty description="暂无一级分销订单" />
<Empty description="暂无分销订单"/>
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="二级分销" value="2">
<View className="p-4">
{getFilteredOrders('2').length > 0 ? (
getFilteredOrders('2').map(renderOrderItem)
) : (
<Empty description="暂无二级分销订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="三级分销" value="3">
<View className="p-4">
{getFilteredOrders('3').length > 0 ? (
getFilteredOrders('3').map(renderOrderItem)
) : (
<Empty description="暂无三级分销订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="待结算" value="4">
<View className="p-4">
{getFilteredOrders('4').length > 0 ? (
getFilteredOrders('4').map(renderOrderItem)
) : (
<Empty description="暂无待结算订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="已结算" value="5">
<View className="p-4">
{getFilteredOrders('5').length > 0 ? (
getFilteredOrders('5').map(renderOrderItem)
) : (
<Empty description="暂无已结算订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="已失效" value="6">
<View className="p-4">
{getFilteredOrders('6').length > 0 ? (
getFilteredOrders('6').map(renderOrderItem)
) : (
<Empty description="暂无失效订单" />
)}
</View>
</Tabs.TabPane>
</Tabs>
</ScrollView>
</PullToRefresh>
</View>
)
}

View File

@@ -1,7 +1,7 @@
import React, {useState, useEffect} from 'react'
import {View, Text, Image} from '@tarojs/components'
import {Button, Loading} from '@nutui/nutui-react-taro'
import {Share, Download, Copy, QrCode} from '@nutui/icons-react-taro'
import {Download, QrCode} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {useDealerUser} from '@/hooks/useDealerUser'
import {generateInviteCode} from '@/api/invite'
@@ -115,52 +115,52 @@ const DealerQrcode: React.FC = () => {
}
// 复制邀请信息
const copyInviteInfo = () => {
if (!dealerUser?.userId) {
Taro.showToast({
title: '用户信息未加载',
icon: 'error'
})
return
}
const inviteText = `🎉 邀请您加入我的团队!
扫描小程序码或搜索"通源堂健康生态平台"小程序,即可享受优质商品和服务!
💰 成为我的团队成员,一起赚取丰厚佣金
🎁 新用户专享优惠等你来拿
邀请码:${dealerUser.userId}
快来加入我们吧!`
Taro.setClipboardData({
data: inviteText,
success: () => {
Taro.showToast({
title: '邀请信息已复制',
icon: 'success'
})
}
})
}
// const copyInviteInfo = () => {
// if (!dealerUser?.userId) {
// Taro.showToast({
// title: '用户信息未加载',
// icon: 'error'
// })
// return
// }
//
// const inviteText = `🎉 邀请您加入我的团队!
//
// 扫描小程序码或搜索"时里院子市集"小程序,即可享受优质商品和服务!
//
// 💰 成为我的团队成员,一起赚取丰厚佣金
// 🎁 新用户专享优惠等你来拿
//
// 邀请码:${dealerUser.userId}
// 快来加入我们吧!`
//
// Taro.setClipboardData({
// data: inviteText,
// success: () => {
// Taro.showToast({
// title: '邀请信息已复制',
// icon: 'success'
// })
// }
// })
// }
// 分享小程序码
const shareMiniProgramCode = () => {
if (!dealerUser?.userId) {
Taro.showToast({
title: '用户信息未加载',
icon: 'error'
})
return
}
// 小程序分享
Taro.showShareMenu({
withShareTicket: true,
showShareItems: ['shareAppMessage', 'shareTimeline']
})
}
// const shareMiniProgramCode = () => {
// if (!dealerUser?.userId) {
// Taro.showToast({
// title: '用户信息未加载',
// icon: 'error'
// })
// return
// }
//
// // 小程序分享
// Taro.showShareMenu({
// withShareTicket: true,
// showShareItems: ['shareAppMessage']
// })
// }
if (!dealerUser) {
return (
@@ -263,29 +263,29 @@ const DealerQrcode: React.FC = () => {
</Button>
</View>
<View className={'my-2 bg-white'}>
<Button
size="large"
block
icon={<Copy/>}
onClick={copyInviteInfo}
disabled={!dealerUser?.userId || loading}
>
</Button>
</View>
<View className={'my-2 bg-white'}>
<Button
size="large"
block
fill="outline"
icon={<Share/>}
onClick={shareMiniProgramCode}
disabled={!dealerUser?.userId || loading}
>
</Button>
</View>
{/*<View className={'my-2 bg-white'}>*/}
{/* <Button*/}
{/* size="large"*/}
{/* block*/}
{/* icon={<Copy/>}*/}
{/* onClick={copyInviteInfo}*/}
{/* disabled={!dealerUser?.userId || loading}*/}
{/* >*/}
{/* 复制邀请信息*/}
{/* </Button>*/}
{/*</View>*/}
{/*<View className={'my-2 bg-white'}>*/}
{/* <Button*/}
{/* size="large"*/}
{/* block*/}
{/* fill="outline"*/}
{/* icon={<Share/>}*/}
{/* onClick={shareMiniProgramCode}*/}
{/* disabled={!dealerUser?.userId || loading}*/}
{/* >*/}
{/* 分享给好友*/}
{/* </Button>*/}
{/*</View>*/}
</View>
{/* 推广说明 */}

View File

@@ -1,3 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我的团队'
navigationBarTitleText: '邀请推广'
})

View File

@@ -1,56 +1,151 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import { Empty, Tabs, Avatar, Tag, Progress, Loading, PullToRefresh } from '@nutui/nutui-react-taro'
import { User, Star, StarFill } from '@nutui/icons-react-taro'
import React, {useState, useEffect, useCallback} from 'react'
import {View, Text} from '@tarojs/components'
import {Phone, Edit, Message} from '@nutui/icons-react-taro'
import {Space, Empty, Avatar, Button} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import { useDealerUser } from '@/hooks/useDealerUser'
import { listShopDealerReferee } from '@/api/shop/shopDealerReferee'
import { pageShopDealerOrder } from '@/api/shop/shopDealerOrder'
import type { ShopDealerReferee } from '@/api/shop/shopDealerReferee/model'
import {useDealerUser} from '@/hooks/useDealerUser'
import {listShopDealerReferee} from '@/api/shop/shopDealerReferee'
import {pageShopDealerOrder} from '@/api/shop/shopDealerOrder'
import type {ShopDealerReferee} from '@/api/shop/shopDealerReferee/model'
import FixedButton from "@/components/FixedButton";
import navTo from "@/utils/common";
import {updateUser} from "@/api/system/user";
interface TeamMemberWithStats extends ShopDealerReferee {
name?: string
avatar?: string
nickname?: string;
alias?: string;
phone?: string;
orderCount?: number
commission?: string
status?: 'active' | 'inactive'
subMembers?: number
joinTime?: string
dealerAvatar?: string;
dealerName?: string;
dealerPhone?: string;
}
// 层级信息接口
interface LevelInfo {
dealerId: number
dealerName?: string
level: number
}
const DealerTeam: React.FC = () => {
const [activeTab, setActiveTab] = useState('0')
const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const [teamMembers, setTeamMembers] = useState<TeamMemberWithStats[]>([])
const [teamStats, setTeamStats] = useState({
total: 0,
firstLevel: 0,
secondLevel: 0,
thirdLevel: 0,
monthlyCommission: '0.00'
})
const {dealerUser} = useDealerUser()
const [dealerId, setDealerId] = useState<number>()
// 层级栈,用于支持返回上一层
const [levelStack, setLevelStack] = useState<LevelInfo[]>([])
const [loading, setLoading] = useState(false)
// 当前查看的用户名称
const [currentDealerName, setCurrentDealerName] = useState<string>('')
const { dealerUser } = useDealerUser()
// 异步加载成员统计数据
const loadMemberStats = async (members: TeamMemberWithStats[]) => {
// 分批处理,避免过多并发请求
const batchSize = 3
for (let i = 0; i < members.length; i += batchSize) {
const batch = members.slice(i, i + batchSize)
const batchStats = await Promise.all(
batch.map(async (member) => {
try {
// 并行获取订单统计和下级成员数量
const [orderResult, subMembersResult] = await Promise.all([
pageShopDealerOrder({
page: 1,
userId: member.userId
}),
listShopDealerReferee({
dealerId: member.userId,
deleted: 0
})
])
let orderCount = 0
let commission = '0.00'
let status: 'active' | 'inactive' = 'inactive'
if (orderResult?.list) {
const orders = orderResult.list
orderCount = orders.length
commission = orders.reduce((sum, order) => {
const levelCommission = member.level === 1 ? order.firstMoney :
member.level === 2 ? order.secondMoney :
order.thirdMoney
return sum + parseFloat(levelCommission || '0')
}, 0).toFixed(2)
// 判断活跃状态30天内有订单为活跃
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const hasRecentOrder = orders.some(order =>
new Date(order.createTime || '') > thirtyDaysAgo
)
status = hasRecentOrder ? 'active' : 'inactive'
}
return {
...member,
orderCount,
commission,
status,
subMembers: subMembersResult?.length || 0
}
} catch (error) {
console.error(`获取成员${member.userId}数据失败:`, error)
return {
...member,
orderCount: 0,
commission: '0.00',
status: 'inactive' as const,
subMembers: 0
}
}
})
)
// 更新这一批成员的数据
setTeamMembers(prevMembers => {
const updatedMembers = [...prevMembers]
batchStats.forEach(updatedMember => {
const index = updatedMembers.findIndex(m => m.userId === updatedMember.userId)
if (index !== -1) {
updatedMembers[index] = updatedMember
}
})
return updatedMembers
})
// 添加小延迟,避免请求过于密集
if (i + batchSize < members.length) {
await new Promise(resolve => setTimeout(resolve, 100))
}
}
}
// 获取团队数据
const fetchTeamData = useCallback(async () => {
if (!dealerUser?.userId) return
if (!dealerUser?.userId && !dealerId) return
try {
setLoading(true)
console.log(dealerId, 'dealerId>>>>>>>>>')
// 获取团队成员关系
const refereeResult = await listShopDealerReferee({
dealerId: dealerUser.userId
dealerId: dealerId ? dealerId : dealerUser?.userId
})
if (refereeResult) {
console.log('团队成员原始数据:', refereeResult)
// 处理团队成员数据
const processedMembers: TeamMemberWithStats[] = refereeResult.map(member => ({
...member,
name: `用户${member.userId}`,
avatar: '',
name: `${member.userId}`,
orderCount: 0,
commission: '0.00',
status: 'active' as const,
@@ -58,62 +153,13 @@ const DealerTeam: React.FC = () => {
joinTime: member.createTime
}))
// 并行获取每个成员的订单统计
const memberStats = await Promise.all(
processedMembers.map(async (member) => {
try {
const orderResult = await pageShopDealerOrder({
page: 1,
limit: 100,
userId: member.userId
})
// 先显示基础数据,然后异步加载详细统计
setTeamMembers(processedMembers)
setLoading(false)
if (orderResult?.list) {
const orders = orderResult.list
const orderCount = orders.length
const commission = orders.reduce((sum, order) => {
const levelCommission = member.level === 1 ? order.firstMoney :
member.level === 2 ? order.secondMoney :
order.thirdMoney
return sum + parseFloat(levelCommission || '0')
}, 0).toFixed(2)
// 异步加载每个成员的详细统计数据
loadMemberStats(processedMembers)
// 判断活跃状态30天内有订单为活跃
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const hasRecentOrder = orders.some(order =>
new Date(order.createTime || '') > thirtyDaysAgo
)
return {
...member,
orderCount,
commission,
status: hasRecentOrder ? 'active' as const : 'inactive' as const
}
}
return member
} catch (error) {
console.error(`获取成员${member.userId}订单失败:`, error)
return member
}
})
)
setTeamMembers(memberStats)
// 计算统计数据
const stats = {
total: memberStats.length,
firstLevel: memberStats.filter(m => m.level === 1).length,
secondLevel: memberStats.filter(m => m.level === 2).length,
thirdLevel: memberStats.filter(m => m.level === 3).length,
monthlyCommission: memberStats.reduce((sum, member) =>
sum + parseFloat(member.commission || '0'), 0
).toFixed(2)
}
setTeamStats(stats)
}
} catch (error) {
console.error('获取团队数据失败:', error)
@@ -124,244 +170,270 @@ const DealerTeam: React.FC = () => {
} finally {
setLoading(false)
}
}, [dealerUser?.userId])
}, [dealerUser?.userId, dealerId])
// 刷新数据
const handleRefresh = async () => {
setRefreshing(true)
await fetchTeamData()
setRefreshing(false)
// 查看下级成员
const getNextUser = (item: TeamMemberWithStats) => {
// 检查层级限制最多只能查看2层levelStack.length >= 1 表示已经是第2层了
if (levelStack.length >= 1) {
return
}
// 如果没有下级成员,不允许点击
if (!item.subMembers || item.subMembers === 0) {
return
}
console.log('点击用户:', item.userId, item.name)
// 将当前层级信息推入栈中
const currentLevel: LevelInfo = {
dealerId: dealerId || dealerUser?.userId || 0,
dealerName: currentDealerName || (dealerId ? '上级' : dealerUser?.realName || '我'),
level: levelStack.length
}
setLevelStack(prev => [...prev, currentLevel])
// 切换到下级
setDealerId(item.userId)
setCurrentDealerName(item.nickname || item.dealerName || `用户${item.userId}`)
}
// 初始化加载数据
// 返回上一层
const goBack = () => {
if (levelStack.length === 0) {
// 如果栈为空,返回首页或上一页
Taro.navigateBack()
return
}
// 从栈中弹出上一层信息
const prevLevel = levelStack[levelStack.length - 1]
setLevelStack(prev => prev.slice(0, -1))
if (prevLevel.dealerId === (dealerUser?.userId || 0)) {
// 返回到根层级
setDealerId(undefined)
setCurrentDealerName('')
} else {
setDealerId(prevLevel.dealerId)
setCurrentDealerName(prevLevel.dealerName || '')
}
}
// 一键拨打
const makePhoneCall = (phone: string) => {
Taro.makePhoneCall({
phoneNumber: phone,
fail: () => {
Taro.showToast({
title: '拨打取消',
icon: 'error'
});
}
});
};
// 别名备注
const editAlias = (item: any, index: number) => {
Taro.showModal({
title: '备注',
// @ts-ignore
editable: true,
placeholderText: '真实姓名',
content: item.alias || '',
success: async (res: any) => {
if (res.confirm && res.content !== undefined) {
try {
// 更新跟进情况
await updateUser({
userId: item.userId,
alias: res.content.trim()
});
teamMembers[index].alias = res.content.trim()
setTeamMembers(teamMembers)
} catch (error) {
console.error('备注失败:', error);
Taro.showToast({
title: '备注失败,请重试',
icon: 'error'
});
}
}
}
});
};
// 发送消息
const sendMessage = (item: TeamMemberWithStats) => {
return navTo(`/user/chat/message/add?id=${item.userId}`, true)
}
// 监听数据变化,获取团队数据
useEffect(() => {
if (dealerUser?.userId) {
if (dealerUser?.userId || dealerId) {
fetchTeamData().then()
}
}, [fetchTeamData])
const getLevelColor = (level: number) => {
switch (level) {
case 1: return '#f59e0b'
case 2: return '#8b5cf6'
case 3: return '#ec4899'
default: return '#6b7280'
// 初始化当前用户名称
useEffect(() => {
if (!dealerId && dealerUser?.realName && !currentDealerName) {
setCurrentDealerName(dealerUser.realName)
}
}
}, [dealerUser, dealerId, currentDealerName])
const getLevelIcon = (level: number) => {
switch (level) {
case 1: return <StarFill color={getLevelColor(level)} size="16" />
case 2: return <Star color={getLevelColor(level)} size="16" />
case 3: return <User color={getLevelColor(level)} size="16" />
default: return <User color={getLevelColor(level)} size="16" />
}
}
const renderMemberItem = (member: TeamMemberWithStats, index: number) => {
// 判断是否可以点击:有下级成员且未达到层级限制
const canClick = member.subMembers && member.subMembers > 0 && levelStack.length < 1
// 判断是否显示手机号只有本级levelStack.length === 0才显示
const showPhone = levelStack.length === 0
// 判断数据是否还在加载中初始值都是0或'0.00'
const isStatsLoading = member.orderCount === 0 && member.commission === '0.00' && member.subMembers === 0
const renderMemberItem = (member: TeamMemberWithStats) => (
<View key={member.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex items-center mb-3">
<Avatar
size="40"
src={member.avatar}
icon={<User />}
className="mr-3"
/>
<View className="flex-1">
<View className="flex items-center mb-1">
<Text className="font-semibold text-gray-800 mr-2">
{member.name}
</Text>
{getLevelIcon(Number(member.level))}
<Text className="text-xs text-gray-500 ml-1">
{member.level}
</Text>
</View>
<Text className="text-xs text-gray-500">
{member.joinTime}
</Text>
</View>
<View className="text-right">
<Tag
type={member.status === 'active' ? 'success' : 'default'}
>
{member.status === 'active' ? '活跃' : '沉默'}
</Tag>
</View>
</View>
<View className="grid grid-cols-3 gap-4 text-center">
<View>
<Text className="text-sm font-semibold text-blue-600">
{member.orderCount}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View>
<Text className="text-sm font-semibold text-green-600">
¥{member.commission}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View>
<Text className="text-sm font-semibold text-purple-600">
{member.subMembers}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
</View>
</View>
)
const renderOverview = () => (
<View className="p-4">
{/* 团队统计卡片 */}
<View className="rounded-xl p-6 mb-6 text-white relative overflow-hidden" style={{
background: 'linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%)'
}}>
{/* 装饰背景 - 小程序兼容版本 */}
<View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
top: '-16px',
right: '-16px'
}}></View>
<View className="absolute w-20 h-20 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
bottom: '-10px',
left: '-10px'
}}></View>
<View className="relative z-10">
<Text className="text-lg font-bold mb-4 text-white"></Text>
<View className="grid grid-cols-2 gap-4">
<View>
<Text className="text-2xl font-bold mb-1 text-white">{teamStats.total}</Text>
<Text className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.8)' }}></Text>
</View>
<View>
<Text className="text-2xl font-bold mb-1 text-white">¥{teamStats.monthlyCommission}</Text>
<Text className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.8)' }}></Text>
</View>
</View>
</View>
</View>
{/* 层级分布 */}
<View className="bg-white rounded-xl p-4 mb-4">
<Text className="font-semibold mb-4 text-gray-800"></Text>
<View className="gap-2">
<View className="flex items-center justify-between">
<View className="flex items-center">
<StarFill color="#f59e0b" size="16" className="mr-2" />
<Text className="text-sm"></Text>
</View>
<View className="flex items-center">
<Text className="text-sm font-semibold mr-2">{teamStats.firstLevel}</Text>
<Progress
percent={(teamStats.firstLevel / teamStats.total) * 100}
strokeWidth="6"
background={'#f59e0b'}
className="w-20"
/>
</View>
</View>
<View className="flex items-center justify-between">
<View className="flex items-center">
<Star color="#8b5cf6" size="16" className="mr-2" />
<Text className="text-sm"></Text>
</View>
<View className="flex items-center">
<Text className="text-sm font-semibold mr-2">{teamStats.secondLevel}</Text>
<Progress
percent={(teamStats.secondLevel / teamStats.total) * 100}
strokeWidth="6"
background={'#8b5cf6'}
className="w-20"
/>
</View>
</View>
<View className="flex items-center justify-between">
<View className="flex items-center">
<User color="#ec4899" size="16" className="mr-2" />
<Text className="text-sm"></Text>
</View>
<View className="flex items-center">
<Text className="text-sm font-semibold mr-2">{teamStats.thirdLevel}</Text>
<Progress
percent={(teamStats.thirdLevel / teamStats.total) * 100}
strokeWidth="6"
background={'#ec4899'}
className="w-20"
/>
</View>
</View>
</View>
</View>
{/* 最新成员 */}
<View className="bg-white rounded-xl p-4">
<Text className="font-semibold mb-4 text-gray-800"></Text>
{teamMembers.slice(0, 3).map(renderMemberItem)}
</View>
</View>
)
const renderMemberList = (level?: number) => (
<PullToRefresh
disabled={refreshing}
onRefresh={handleRefresh}
>
<View className="p-4">
{loading ? (
<View className="text-center py-8">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : teamMembers
.filter(member => !level || member.level === level)
.length > 0 ? (
teamMembers
.filter(member => !level || member.level === level)
.map(renderMemberItem)
) : (
<Empty description={`暂无${level ? level + '级' : ''}团队成员`} />
)}
</View>
</PullToRefresh>
)
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
<View
key={member.id}
className={`bg-white rounded-lg p-4 mb-3 shadow-sm ${
canClick ? 'cursor-pointer' : 'cursor-default opacity-75'
}`}
onClick={() => getNextUser(member)}
>
<View className="flex items-center mb-3">
<Avatar
size="40"
src={member.avatar}
className="mr-3"
/>
<View className="flex-1">
<View className="flex items-center justify-between mb-1">
<View className="flex items-center">
<Space>
{member.alias ? <Text className="font-semibold text-blue-700 mr-2">{member.alias}</Text> :
<Text className="font-semibold text-gray-800 mr-2">{member.nickname}</Text>}
{/*别名备注*/}
<Edit size={16} className={'text-blue-500 mr-2'} onClick={(e) => {
e.stopPropagation()
editAlias(member, index)
}}/>
{/*发送消息*/}
<Message size={16} className={'text-orange-500 mr-2'} onClick={(e) => {
e.stopPropagation()
sendMessage(member)
}}/>
</Space>
</View>
{/* 显示手机号(仅本级可见) */}
{showPhone && member.phone && (
<Text className="text-sm text-gray-500" onClick={(e) => {
e.stopPropagation();
makePhoneCall(member.phone || '');
}}>
{member.phone}
<Phone size={12} className="ml-1 text-green-500"/>
</Text>
)}
</View>
<Text className="text-xs text-gray-500">
{member.joinTime}
</Text>
</View>
</View>
<View className="grid grid-cols-3 gap-4 text-center">
<Space>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm font-semibold text-blue-600">
{isStatsLoading ? '-' : member.orderCount}
</Text>
</Space>
<Space>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm font-semibold text-green-600">
{isStatsLoading ? '-' : `¥${member.commission}`}
</Text>
</Space>
<Space>
<Text className="text-xs text-gray-500"></Text>
<Text className={`text-sm font-semibold ${
canClick ? 'text-purple-600' : 'text-gray-400'
}`}>
{isStatsLoading ? '-' : (member.subMembers || 0)}
</Text>
</Space>
</View>
</View>
)
}
return (
<View className="bg-gray-50 min-h-screen">
<Tabs value={activeTab} onChange={() => setActiveTab}>
<Tabs.TabPane title="团队总览" value="0">
{renderOverview()}
</Tabs.TabPane>
<Tabs.TabPane title="一级成员" value="1">
{renderMemberList(1)}
</Tabs.TabPane>
<Tabs.TabPane title="二级成员" value="2">
{renderMemberList(2)}
</Tabs.TabPane>
<Tabs.TabPane title="三级成员" value="3">
{renderMemberList(3)}
</Tabs.TabPane>
</Tabs>
const renderOverview = () => (
<View className="rounded-xl p-4">
<View
className={'bg-white rounded-lg py-2 px-4 mb-3 shadow-sm text-right text-sm font-medium flex justify-between items-center'}>
<Text className="text-lg font-semibold"></Text>
<Text className={'text-gray-500 '}>{teamMembers.length}</Text>
</View>
{teamMembers.map(renderMemberItem)}
</View>
)
// 渲染顶部导航栏
const renderHeader = () => {
if (levelStack.length === 0) return null
return (
<View className="bg-white p-4 mb-3 shadow-sm">
<View className="flex items-center justify-between">
<View className="flex items-center">
<Text className="text-lg font-semibold">
{currentDealerName}
</Text>
</View>
<Button
size="small"
type="primary"
onClick={goBack}
className="bg-blue-500"
>
</Button>
</View>
</View>
)
}
if (!dealerUser) {
return (
<Space className="flex items-center justify-center">
<Empty description="您还不是业务人员" style={{
backgroundColor: 'transparent'
}} actions={[{text: '立即申请', onClick: () => navTo(`/dealer/apply/add`, true)}]}
/>
</Space>
)
}
return (
<>
{renderHeader()}
{loading ? (
<View className="flex items-center justify-center mt-20">
<Text className="text-gray-500">...</Text>
</View>
) : teamMembers.length > 0 ? (
renderOverview()
) : (
<View className="flex items-center justify-center mt-20">
<Empty description="暂无成员" style={{
backgroundColor: 'transparent'
}}/>
</View>
)}
<FixedButton text={'立即邀请'} onClick={() => navTo(`/dealer/qrcode/index`, true)}/>
</>
)
}
export default DealerTeam
export default DealerTeam;

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '微信客服'
})

View File

@@ -0,0 +1,176 @@
.wechat-service-page {
min-height: 100vh;
.service-tabs {
background-color: #fff;
.nut-tabs__titles {
background-color: #fff;
}
.nut-tabs__content {
padding: 0;
}
}
.qr-container {
padding: 20px;
min-height: calc(100vh - 100px);
.qr-header {
text-align: center;
margin-bottom: 30px;
.qr-title {
display: block;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.qr-description {
display: block;
color: #666;
line-height: 1.5;
}
}
.qr-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
.qr-code-wrapper {
background-color: #fff;
border-radius: 12px;
padding: 30px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
text-align: center;
.qr-code-image {
width: 360px;
height: 360px;
border-radius: 8px;
margin-bottom: 15px;
}
.wechat-id {
display: block;
color: #333;
font-weight: 500;
}
}
.qr-tips {
background-color: #fff;
border-radius: 12px;
padding: 20px;
width: 100%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
.tip-title {
display: block;
font-weight: bold;
color: #333;
margin-bottom: 15px;
}
.tip-item {
display: block;
color: #666;
line-height: 1.8;
margin-bottom: 8px;
padding-left: 10px;
position: relative;
&:before {
content: '';
color: #07c160;
font-weight: bold;
position: absolute;
left: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
}
}
}
// 响应式适配
@media (max-width: 375px) {
.wechat-service-page {
.qr-container {
padding: 15px;
.qr-content {
.qr-code-wrapper {
padding: 20px;
.qr-code-image {
width: 180px;
height: 180px;
}
}
}
}
}
}
// 深色模式适配
@media (prefers-color-scheme: dark) {
.wechat-service-page {
background-color: #1a1a1a;
.service-tabs {
.nut-tabs__titles {
background-color: #2a2a2a;
border-bottom-color: #333;
}
}
.qr-container {
background-color: #1a1a1a;
.qr-header {
.qr-title {
color: #fff;
}
.qr-description {
color: #ccc;
}
}
.qr-content {
.qr-code-wrapper {
background-color: #2a2a2a;
.qr-code-image {
border-color: #444;
}
.wechat-id {
color: #fff;
}
}
.qr-tips {
background-color: #2a2a2a;
.tip-title {
color: #fff;
}
.tip-item {
color: #ccc;
}
}
}
}
}
}

121
src/dealer/wechat/index.tsx Normal file
View File

@@ -0,0 +1,121 @@
import {useEffect, useState} from 'react'
import {View, Text, Image} from '@tarojs/components'
import {Tabs} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import './index.scss'
import {listCmsWebsiteField} from "@/api/cms/cmsWebsiteField";
import {CmsWebsiteField} from "@/api/cms/cmsWebsiteField/model";
const WechatService = () => {
const [activeTab, setActiveTab] = useState('0')
const [codes, setCodes] = useState<CmsWebsiteField[]>([])
// 长按保存二维码到相册
const saveQRCodeToAlbum = (imageUrl: string) => {
// 首先下载图片到本地
Taro.downloadFile({
url: imageUrl,
success: (res) => {
if (res.statusCode === 200) {
// 保存图片到相册
Taro.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
Taro.showToast({
title: '保存成功',
icon: 'success',
duration: 2000
})
},
fail: (error) => {
console.error('保存失败:', error)
if (error.errMsg.includes('auth deny')) {
Taro.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
showCancel: true,
cancelText: '取消',
confirmText: '去设置',
success: (modalRes) => {
if (modalRes.confirm) {
Taro.openSetting()
}
}
})
} else {
Taro.showToast({
title: '保存失败',
icon: 'error',
duration: 2000
})
}
}
})
} else {
Taro.showToast({
title: '图片下载失败',
icon: 'error',
duration: 2000
})
}
},
fail: () => {
Taro.showToast({
title: '图片下载失败',
icon: 'error',
duration: 2000
})
}
})
}
const renderQRCode = (data: typeof codes[0]) => (
<View className="qr-container">
<View className="qr-content">
<View className="qr-code-wrapper">
<Image
src={`${data.value}`}
className="qr-code-image"
mode="aspectFit"
onLongPress={() => saveQRCodeToAlbum(`${data.value}`)}
/>
{data.style && <Text className="wechat-id">{data.style}</Text>}
</View>
<View className="qr-tips">
<Text className="tip-title">使</Text>
<Text className="tip-item">1. </Text>
<Text className="tip-item">2. </Text>
<Text className="tip-item">3. </Text>
<Text className="tip-item">4. </Text>
</View>
</View>
</View>
)
useEffect(() => {
listCmsWebsiteField({name: 'kefu'}).then(data => {
if (data) {
setCodes(data)
}
})
}, []);
return (
<View className="wechat-service-page">
<Tabs
value={activeTab}
onChange={(value) => setActiveTab(`${value}`)}
className="service-tabs"
>
{codes.map((item) => (
<Tabs.TabPane key={item.id} title={item.comments} value={item.id}>
{renderQRCode(item)}
</Tabs.TabPane>
))}
</Tabs>
</View>
)
}
export default WechatService

View File

@@ -0,0 +1,184 @@
import React from 'react'
import { render, fireEvent, waitFor } from '@testing-library/react'
import DealerWithdraw from '../index'
import { useDealerUser } from '@/hooks/useDealerUser'
import * as withdrawAPI from '@/api/shop/shopDealerWithdraw'
// Mock dependencies
jest.mock('@/hooks/useDealerUser')
jest.mock('@/api/shop/shopDealerWithdraw')
jest.mock('@tarojs/taro', () => ({
showToast: jest.fn(),
getStorageSync: jest.fn(() => 123),
}))
const mockUseDealerUser = useDealerUser as jest.MockedFunction<typeof useDealerUser>
const mockAddShopDealerWithdraw = withdrawAPI.addShopDealerWithdraw as jest.MockedFunction<typeof withdrawAPI.addShopDealerWithdraw>
const mockPageShopDealerWithdraw = withdrawAPI.pageShopDealerWithdraw as jest.MockedFunction<typeof withdrawAPI.pageShopDealerWithdraw>
describe('DealerWithdraw', () => {
const mockDealerUser = {
userId: 123,
money: '10000.00',
realName: '测试用户',
mobile: '13800138000'
}
beforeEach(() => {
mockUseDealerUser.mockReturnValue({
dealerUser: mockDealerUser,
loading: false,
error: null,
refresh: jest.fn()
})
mockPageShopDealerWithdraw.mockResolvedValue({
list: [],
count: 0
})
})
afterEach(() => {
jest.clearAllMocks()
})
test('应该正确显示可提现余额', () => {
const { getByText } = render(<DealerWithdraw />)
expect(getByText('10000.00')).toBeInTheDocument()
expect(getByText('可提现余额')).toBeInTheDocument()
})
test('应该验证最低提现金额', async () => {
mockAddShopDealerWithdraw.mockResolvedValue('success')
const { getByPlaceholderText, getByText } = render(<DealerWithdraw />)
// 输入低于最低金额的数值
const amountInput = getByPlaceholderText('请输入提现金额')
fireEvent.change(amountInput, { target: { value: '50' } })
// 选择提现方式
const wechatRadio = getByText('微信钱包')
fireEvent.click(wechatRadio)
// 提交表单
const submitButton = getByText('申请提现')
fireEvent.click(submitButton)
await waitFor(() => {
expect(require('@tarojs/taro').showToast).toHaveBeenCalledWith({
title: '最低提现金额为100元',
icon: 'error'
})
})
})
test('应该验证提现金额不超过可用余额', async () => {
const { getByPlaceholderText, getByText } = render(<DealerWithdraw />)
// 输入超过可用余额的金额
const amountInput = getByPlaceholderText('请输入提现金额')
fireEvent.change(amountInput, { target: { value: '20000' } })
// 选择提现方式
const wechatRadio = getByText('微信钱包')
fireEvent.click(wechatRadio)
// 提交表单
const submitButton = getByText('申请提现')
fireEvent.click(submitButton)
await waitFor(() => {
expect(require('@tarojs/taro').showToast).toHaveBeenCalledWith({
title: '提现金额超过可用余额',
icon: 'error'
})
})
})
test('应该验证支付宝账户信息完整性', async () => {
const { getByPlaceholderText, getByText } = render(<DealerWithdraw />)
// 输入有效金额
const amountInput = getByPlaceholderText('请输入提现金额')
fireEvent.change(amountInput, { target: { value: '1000' } })
// 选择支付宝提现
const alipayRadio = getByText('支付宝')
fireEvent.click(alipayRadio)
// 只填写账号,不填写姓名
const accountInput = getByPlaceholderText('请输入支付宝账号')
fireEvent.change(accountInput, { target: { value: 'test@alipay.com' } })
// 提交表单
const submitButton = getByText('申请提现')
fireEvent.click(submitButton)
await waitFor(() => {
expect(require('@tarojs/taro').showToast).toHaveBeenCalledWith({
title: '请填写完整的支付宝信息',
icon: 'error'
})
})
})
test('应该成功提交微信提现申请', async () => {
mockAddShopDealerWithdraw.mockResolvedValue('success')
const { getByPlaceholderText, getByText } = render(<DealerWithdraw />)
// 输入有效金额
const amountInput = getByPlaceholderText('请输入提现金额')
fireEvent.change(amountInput, { target: { value: '1000' } })
// 选择微信提现
const wechatRadio = getByText('微信钱包')
fireEvent.click(wechatRadio)
// 提交表单
const submitButton = getByText('申请提现')
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockAddShopDealerWithdraw).toHaveBeenCalledWith({
userId: 123,
money: '1000',
payType: 10,
applyStatus: 10,
platform: 'MiniProgram'
})
})
await waitFor(() => {
expect(require('@tarojs/taro').showToast).toHaveBeenCalledWith({
title: '提现申请已提交',
icon: 'success'
})
})
})
test('快捷金额按钮应该正常工作', () => {
const { getByText, getByPlaceholderText } = render(<DealerWithdraw />)
// 点击快捷金额按钮
const quickAmountButton = getByText('500')
fireEvent.click(quickAmountButton)
// 验证金额输入框的值
const amountInput = getByPlaceholderText('请输入提现金额') as HTMLInputElement
expect(amountInput.value).toBe('500')
})
test('全部按钮应该设置为可用余额', () => {
const { getByText, getByPlaceholderText } = render(<DealerWithdraw />)
// 点击全部按钮
const allButton = getByText('全部')
fireEvent.click(allButton)
// 验证金额输入框的值
const amountInput = getByPlaceholderText('请输入提现金额') as HTMLInputElement
expect(amountInput.value).toBe('10000.00')
})
})

View File

@@ -0,0 +1,80 @@
import React, { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { Tabs, Button } from '@nutui/nutui-react-taro'
/**
* 提现功能调试组件
* 用于测试 Tabs 组件的点击和切换功能
*/
const WithdrawDebug: React.FC = () => {
const [activeTab, setActiveTab] = useState<string | number>('0')
const [clickCount, setClickCount] = useState(0)
// Tab 切换处理函数
const handleTabChange = (value: string | number) => {
console.log('Tab切换:', { from: activeTab, to: value, type: typeof value })
setActiveTab(value)
setClickCount(prev => prev + 1)
}
// 手动切换测试
const manualSwitch = (tab: string | number) => {
console.log('手动切换到:', tab)
setActiveTab(tab)
setClickCount(prev => prev + 1)
}
return (
<View className="bg-gray-50 min-h-screen p-4">
<View className="bg-white rounded-lg p-4 mb-4">
<Text className="text-lg font-bold mb-2"></Text>
<Text className="block mb-1">Tab: {String(activeTab)}</Text>
<Text className="block mb-1">: {clickCount}</Text>
<Text className="block mb-1">Tab类型: {typeof activeTab}</Text>
</View>
<View className="bg-white rounded-lg p-4 mb-4">
<Text className="text-lg font-bold mb-2"></Text>
<View className="flex gap-2">
<Button size="small" onClick={() => manualSwitch('0')}>
</Button>
<Button size="small" onClick={() => manualSwitch('1')}>
</Button>
</View>
</View>
<View className="bg-white rounded-lg">
<Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.TabPane title="申请提现" value="0">
<View className="p-4">
<Text className="text-center text-gray-600"></Text>
<Text className="text-center text-sm text-gray-400 mt-2">
Tab值: {String(activeTab)}
</Text>
</View>
</Tabs.TabPane>
<Tabs.TabPane title="提现记录" value="1">
<View className="p-4">
<Text className="text-center text-gray-600"></Text>
<Text className="text-center text-sm text-gray-400 mt-2">
Tab值: {String(activeTab)}
</Text>
</View>
</Tabs.TabPane>
</Tabs>
</View>
<View className="bg-white rounded-lg p-4 mt-4">
<Text className="text-lg font-bold mb-2"></Text>
<Text className="text-sm text-gray-500">
</Text>
</View>
</View>
)
}
export default WithdrawDebug

View File

@@ -1,7 +1,8 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import React, {useState, useRef, useEffect, useCallback} from 'react'
import {View, Text} from '@tarojs/components'
import {
Cell,
Space,
Button,
Form,
Input,
@@ -13,19 +14,19 @@ import {
Loading,
PullToRefresh
} from '@nutui/nutui-react-taro'
import { Wallet } from '@nutui/icons-react-taro'
import { businessGradients } from '@/styles/gradients'
import {Wallet} from '@nutui/icons-react-taro'
import {businessGradients} from '@/styles/gradients'
import Taro from '@tarojs/taro'
import { useDealerUser } from '@/hooks/useDealerUser'
import { pageShopDealerWithdraw, addShopDealerWithdraw } from '@/api/shop/shopDealerWithdraw'
import type { ShopDealerWithdraw } from '@/api/shop/shopDealerWithdraw/model'
import {useDealerUser} from '@/hooks/useDealerUser'
import {pageShopDealerWithdraw, addShopDealerWithdraw} from '@/api/shop/shopDealerWithdraw'
import type {ShopDealerWithdraw} from '@/api/shop/shopDealerWithdraw/model'
interface WithdrawRecordWithDetails extends ShopDealerWithdraw {
accountDisplay?: string
}
const DealerWithdraw: React.FC = () => {
const [activeTab, setActiveTab] = useState('0')
const [activeTab, setActiveTab] = useState<string | number>('0')
const [selectedAccount, setSelectedAccount] = useState('')
const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
@@ -34,16 +35,28 @@ const DealerWithdraw: React.FC = () => {
const [withdrawRecords, setWithdrawRecords] = useState<WithdrawRecordWithDetails[]>([])
const formRef = useRef<any>(null)
const { dealerUser } = useDealerUser()
const {dealerUser} = useDealerUser()
// Tab 切换处理函数
const handleTabChange = (value: string | number) => {
console.log('Tab切换到:', value)
setActiveTab(value)
// 如果切换到提现记录页面,刷新数据
if (String(value) === '1') {
fetchWithdrawRecords()
}
}
// 获取可提现余额
const fetchBalance = useCallback(async () => {
console.log(dealerUser, 'dealerUser...')
try {
setAvailableAmount(dealerUser?.money || '0.00')
setAvailableAmount(dealerUser?.money || '0.00')
} catch (error) {
console.error('获取余额失败:', error)
}
}, [])
}, [dealerUser])
// 获取提现记录
const fetchWithdrawRecords = useCallback(async () => {
@@ -104,21 +117,31 @@ const DealerWithdraw: React.FC = () => {
const getStatusText = (status?: number) => {
switch (status) {
case 40: return '已到账'
case 20: return '审核通过'
case 10: return '待审核'
case 30: return '已驳回'
default: return '未知'
case 40:
return '已到账'
case 20:
return '审核通过'
case 10:
return '待审核'
case 30:
return '已驳回'
default:
return '未知'
}
}
const getStatusColor = (status?: number) => {
switch (status) {
case 40: return 'success'
case 20: return 'success'
case 10: return 'warning'
case 30: return 'danger'
default: return 'default'
case 40:
return 'success'
case 20:
return 'success'
case 10:
return 'warning'
case 30:
return 'danger'
default:
return 'default'
}
}
@@ -131,9 +154,25 @@ const DealerWithdraw: React.FC = () => {
return
}
if (!values.accountType) {
Taro.showToast({
title: '请选择提现方式',
icon: 'error'
})
return
}
// 验证提现金额
const amount = parseFloat(values.amount)
const available = parseFloat(availableAmount.replace(',', ''))
const available = parseFloat(availableAmount.replace(/,/g, ''))
if (isNaN(amount) || amount <= 0) {
Taro.showToast({
title: '请输入有效的提现金额',
icon: 'error'
})
return
}
if (amount < 100) {
Taro.showToast({
@@ -151,6 +190,25 @@ const DealerWithdraw: React.FC = () => {
return
}
// 验证账户信息
if (values.accountType === 'alipay') {
if (!values.account || !values.accountName) {
Taro.showToast({
title: '请填写完整的支付宝信息',
icon: 'error'
})
return
}
} else if (values.accountType === 'bank') {
if (!values.account || !values.accountName || !values.bankName) {
Taro.showToast({
title: '请填写完整的银行卡信息',
icon: 'error'
})
return
}
}
try {
setSubmitting(true)
@@ -158,7 +216,7 @@ const DealerWithdraw: React.FC = () => {
userId: dealerUser.userId,
money: values.amount,
payType: values.accountType === 'wechat' ? 10 :
values.accountType === 'alipay' ? 20 : 30,
values.accountType === 'alipay' ? 20 : 30,
applyStatus: 10, // 待审核
platform: 'MiniProgram'
}
@@ -204,15 +262,21 @@ const DealerWithdraw: React.FC = () => {
const quickAmounts = ['100', '300', '500', '1000']
const setQuickAmount = (amount: string) => {
formRef.current?.setFieldsValue({ amount })
formRef.current?.setFieldsValue({amount})
}
const setAllAmount = () => {
formRef.current?.setFieldsValue({ amount: availableAmount.replace(',', '') })
formRef.current?.setFieldsValue({amount: availableAmount.replace(/,/g, '')})
}
// 格式化金额
const formatMoney = (money?: string) => {
if (!money) return '0.00'
return parseFloat(money).toFixed(2)
}
const renderWithdrawForm = () => (
<View className="p-4">
<View>
{/* 余额卡片 */}
<View className="rounded-xl p-6 mb-6 text-white relative overflow-hidden" style={{
background: businessGradients.dealer.header
@@ -225,14 +289,14 @@ const DealerWithdraw: React.FC = () => {
}}></View>
<View className="flex items-center justify-between relative z-10">
<View>
<View className={'flex flex-col'}>
<Text className="text-2xl font-bold text-white">{formatMoney(dealerUser?.money)}</Text>
<Text className="text-white text-opacity-80 text-sm mb-1"></Text>
<Text className="text-2xl font-bold text-white">¥{availableAmount}</Text>
</View>
<View className="p-3 rounded-full" style={{
background: 'rgba(255, 255, 255, 0.2)'
}}>
<Wallet color="white" size="32" />
<Wallet color="white" size="32"/>
</View>
</View>
<View className="mt-4 pt-4 relative z-10" style={{
@@ -254,7 +318,14 @@ const DealerWithdraw: React.FC = () => {
<Input
placeholder="请输入提现金额"
type="number"
clearable
onChange={(value) => {
// 实时验证提现金额
const amount = parseFloat(value)
const available = parseFloat(availableAmount.replace(/,/g, ''))
if (!isNaN(amount) && amount > available) {
// 可以在这里添加实时提示,但不阻止输入
}
}}
/>
</Form.Item>
@@ -301,10 +372,10 @@ const DealerWithdraw: React.FC = () => {
{selectedAccount === 'alipay' && (
<>
<Form.Item name="account" label="支付宝账号" required>
<Input placeholder="请输入支付宝账号" />
<Input placeholder="请输入支付宝账号"/>
</Form.Item>
<Form.Item name="accountName" label="支付宝姓名" required>
<Input placeholder="请输入支付宝实名姓名" />
<Input placeholder="请输入支付宝实名姓名"/>
</Form.Item>
</>
)}
@@ -312,13 +383,13 @@ const DealerWithdraw: React.FC = () => {
{selectedAccount === 'bank' && (
<>
<Form.Item name="bankName" label="开户银行" required>
<Input placeholder="请输入开户银行名称" />
<Input placeholder="请输入开户银行名称"/>
</Form.Item>
<Form.Item name="account" label="银行卡号" required>
<Input placeholder="请输入银行卡号" />
<Input placeholder="请输入银行卡号"/>
</Form.Item>
<Form.Item name="accountName" label="开户姓名" required>
<Input placeholder="请输入银行卡开户姓名" />
<Input placeholder="请输入银行卡开户姓名"/>
</Form.Item>
</>
)}
@@ -347,60 +418,64 @@ const DealerWithdraw: React.FC = () => {
</View>
)
const renderWithdrawRecords = () => (
<PullToRefresh
disabled={refreshing}
onRefresh={handleRefresh}
>
<View className="p-4">
{loading ? (
<View className="text-center py-8">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : withdrawRecords.length > 0 ? (
withdrawRecords.map(record => (
<View key={record.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-3">
<View>
<Text className="font-semibold text-gray-800 mb-1">
¥{record.money}
</Text>
<Text className="text-sm text-gray-500">
{record.accountDisplay}
</Text>
</View>
<Tag type={getStatusColor(record.applyStatus)}>
{getStatusText(record.applyStatus)}
</Tag>
</View>
const renderWithdrawRecords = () => {
console.log('渲染提现记录:', {loading, recordsCount: withdrawRecords.length, dealerUserId: dealerUser?.userId})
<View className="text-xs text-gray-400">
<Text>{record.createTime}</Text>
{record.auditTime && (
<Text className="block mt-1">
{new Date(record.auditTime).toLocaleString()}
</Text>
)}
{record.rejectReason && (
<Text className="block mt-1 text-red-500">
{record.rejectReason}
</Text>
)}
</View>
return (
<PullToRefresh
disabled={refreshing}
onRefresh={handleRefresh}
>
<View>
{loading ? (
<View className="text-center py-8">
<Loading/>
<Text className="text-gray-500 mt-2">...</Text>
</View>
))
) : (
<Empty description="暂无提现记录" />
)}
</View>
</PullToRefresh>
)
) : withdrawRecords.length > 0 ? (
withdrawRecords.map(record => (
<View key={record.id} className="rounded-lg bg-gray-50 p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-3">
<Space>
<Text className="font-semibold text-gray-800 mb-1">
¥{record.money}
</Text>
<Text className="text-sm text-gray-500">
{record.accountDisplay}
</Text>
</Space>
<Tag type={getStatusColor(record.applyStatus)}>
{getStatusText(record.applyStatus)}
</Tag>
</View>
<View className="text-xs text-gray-400">
<Text>{record.createTime}</Text>
{record.auditTime && (
<Text className="block mt-1">
{new Date(record.auditTime).toLocaleString()}
</Text>
)}
{record.rejectReason && (
<Text className="block mt-1 text-red-500">
{record.rejectReason}
</Text>
)}
</View>
</View>
))
) : (
<Empty description="暂无提现记录"/>
)}
</View>
</PullToRefresh>
)
}
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading />
<Loading/>
<Text className="text-gray-500 mt-2">...</Text>
</View>
)
@@ -408,7 +483,7 @@ const DealerWithdraw: React.FC = () => {
return (
<View className="bg-gray-50 min-h-screen">
<Tabs value={activeTab} onChange={() => setActiveTab}>
<Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.TabPane title="申请提现" value="0">
{renderWithdrawForm()}
</Tabs.TabPane>

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '医生入驻申请通道',
navigationBarTextStyle: 'black'
})

191
src/doctor/apply/add.tsx Normal file
View File

@@ -0,0 +1,191 @@
import {useEffect, useState, useRef} from "react";
import {Loading, CellGroup, Cell, Input, Form} from '@nutui/nutui-react-taro'
import {Edit} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {View} from '@tarojs/components'
import FixedButton from "@/components/FixedButton";
import {useUser} from "@/hooks/useUser";
import {ShopDealerApply} from "@/api/shop/shopDealerApply/model";
import {
addShopDealerApply,
pageShopDealerApply,
updateShopDealerApply
} from "@/api/shop/shopDealerApply";
import {getShopDealerUser} from "@/api/shop/shopDealerUser";
const AddUserAddress = () => {
const {user} = useUser()
const [loading, setLoading] = useState<boolean>(true)
const [FormData, setFormData] = useState<ShopDealerApply>()
const formRef = useRef<any>(null)
const [isEditMode, setIsEditMode] = useState<boolean>(false)
const [existingApply, setExistingApply] = useState<ShopDealerApply | null>(null)
// 获取审核状态文字
const getApplyStatusText = (status?: number) => {
switch (status) {
case 10:
return '待审核'
case 20:
return '审核通过'
case 30:
return '驳回'
default:
return '未知状态'
}
}
const reload = async () => {
// 判断用户是否登录
if (!user?.userId) {
return false;
}
// 查询当前用户ID是否已有申请记录
try {
const res = await pageShopDealerApply({userId: user?.userId});
if (res && res.count > 0) {
setIsEditMode(true);
setExistingApply(res.list[0]);
// 如果有记录,填充表单数据
setFormData(res.list[0]);
setLoading(false)
} else {
setIsEditMode(false);
setExistingApply(null);
setLoading(false)
}
} catch (error) {
setLoading(true)
console.error('查询申请记录失败:', error);
setIsEditMode(false);
setExistingApply(null);
}
}
// 提交表单
const submitSucceed = async (values: any) => {
try {
// 准备提交的数据
const submitData = {
...values,
realName: values.realName || user?.nickname,
mobile: user?.phone,
refereeId: values.refereeId || FormData?.refereeId,
applyStatus: 10,
auditTime: undefined
};
await getShopDealerUser(submitData.refereeId);
// 如果是编辑模式添加现有申请的id
if (isEditMode && existingApply?.applyId) {
submitData.applyId = existingApply.applyId;
}
// 执行新增或更新操作
if (isEditMode) {
await updateShopDealerApply(submitData);
} else {
await addShopDealerApply(submitData);
}
Taro.showToast({
title: `${isEditMode ? '提交' : '提交'}成功`,
icon: 'success'
});
setTimeout(() => {
Taro.navigateBack();
}, 1000);
} catch (error) {
console.error('验证邀请人失败:', error);
return Taro.showToast({
title: '邀请人ID不存在',
icon: 'error'
});
}
}
// 处理固定按钮点击事件
const handleFixedButtonClick = () => {
// 触发表单提交
formRef.current?.submit();
};
const submitFailed = (error: any) => {
console.log(error, 'err...')
}
useEffect(() => {
reload().then(() => {
setLoading(false)
})
}, [user?.userId]); // 依赖用户ID当用户变化时重新加载
if (loading) {
return <Loading className={'px-2'}></Loading>
}
return (
<>
<Form
ref={formRef}
divider
initialValues={FormData}
labelPosition="left"
onFinish={(values) => submitSucceed(values)}
onFinishFailed={(errors) => submitFailed(errors)}
>
<View className={'bg-gray-100 h-3'}></View>
<CellGroup style={{padding: '4px 0'}}>
<Form.Item name="realName" label="名称" initialValue={user?.nickname} required>
<Input placeholder="经销商名称" maxLength={10}/>
</Form.Item>
<Form.Item name="mobile" label="手机号" initialValue={user?.mobile} required>
<Input placeholder="请输入手机号" disabled={true} maxLength={11}/>
</Form.Item>
<Form.Item name="refereeId" label="邀请人ID" initialValue={FormData?.refereeId} required>
<Input placeholder="邀请人ID"/>
</Form.Item>
</CellGroup>
</Form>
{/* 审核状态显示(仅在编辑模式下显示) */}
{isEditMode && (
<CellGroup>
<Cell
title={'审核状态'}
extra={
<span style={{
color: FormData?.applyStatus === 20 ? '#52c41a' :
FormData?.applyStatus === 30 ? '#ff4d4f' : '#faad14'
}}>
{getApplyStatusText(FormData?.applyStatus)}
</span>
}
/>
{FormData?.applyStatus === 20 && (
<Cell title={'审核时间'} extra={FormData?.auditTime || '无'}/>
)}
{FormData?.applyStatus === 30 && (
<Cell title={'驳回原因'} extra={FormData?.rejectReason || '无'}/>
)}
</CellGroup>
)}
{/* 底部浮动按钮 */}
{(!isEditMode || FormData?.applyStatus === 10 || FormData?.applyStatus === 30) && (
<FixedButton
icon={<Edit/>}
text={isEditMode ? '保存修改' : '提交申请'}
disabled={FormData?.applyStatus === 10}
onClick={handleFixedButtonClick}
/>
)}
</>
);
};
export default AddUserAddress;

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '医生版'
})

0
src/doctor/index.scss Normal file
View File

295
src/doctor/index.tsx Normal file
View File

@@ -0,0 +1,295 @@
import React from 'react'
import {View, Text} from '@tarojs/components'
import {ConfigProvider, Button, Grid, Avatar} from '@nutui/nutui-react-taro'
import {
User,
Shopping,
Dongdong,
ArrowRight,
Purse,
People
} from '@nutui/icons-react-taro'
import {useDealerUser} from '@/hooks/useDealerUser'
import { useThemeStyles } from '@/hooks/useTheme'
import {businessGradients, cardGradients, gradientUtils} from '@/styles/gradients'
import Taro from '@tarojs/taro'
const DealerIndex: React.FC = () => {
const {
dealerUser,
error,
refresh,
} = useDealerUser()
// 使用主题样式
const themeStyles = useThemeStyles()
// 导航到各个功能页面
const navigateToPage = (url: string) => {
Taro.navigateTo({url})
}
// 格式化金额
const formatMoney = (money?: string) => {
if (!money) return '0.00'
return parseFloat(money).toFixed(2)
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return '-'
return new Date(time).toLocaleDateString()
}
// 获取用户主题
const userTheme = gradientUtils.getThemeByUserId(dealerUser?.userId)
// 获取渐变背景
const getGradientBackground = (themeColor?: string) => {
if (themeColor) {
const darkerColor = gradientUtils.adjustColorBrightness(themeColor, -30)
return gradientUtils.createGradient(themeColor, darkerColor)
}
return userTheme.background
}
console.log(getGradientBackground(),'getGradientBackground()')
if (error) {
return (
<View className="p-4">
<View className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<Text className="text-red-600">{error}</Text>
</View>
<Button type="primary" onClick={refresh}>
</Button>
</View>
)
}
return (
<View className="bg-gray-100 min-h-screen">
<View>
{/*头部信息*/}
{dealerUser && (
<View className="px-4 py-6 relative overflow-hidden" style={themeStyles.primaryBackground}>
{/* 装饰性背景元素 - 小程序兼容版本 */}
<View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
top: '-16px',
right: '-16px'
}}></View>
<View className="absolute w-24 h-24 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.08)',
bottom: '-12px',
left: '-12px'
}}></View>
<View className="absolute w-16 h-16 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
top: '60px',
left: '120px'
}}></View>
<View className="flex items-center justify-between relative z-10 mb-4">
<Avatar
size="50"
src={dealerUser?.qrcode}
icon={<User/>}
className="mr-4"
style={{
border: '2px solid rgba(255, 255, 255, 0.3)'
}}
/>
<View className="flex-1 flex-col">
<View className="text-white text-lg font-bold mb-1" style={{
}}>
{dealerUser?.realName || '分销商'}
</View>
<View className="text-sm" style={{
color: 'rgba(255, 255, 255, 0.8)'
}}>
ID: {dealerUser.userId} | : {dealerUser.refereeId || '无'}
</View>
</View>
<View className="text-right hidden">
<Text className="text-xs" style={{
color: 'rgba(255, 255, 255, 0.9)'
}}></Text>
<Text className="text-xs" style={{
color: 'rgba(255, 255, 255, 0.7)'
}}>
{formatTime(dealerUser.createTime)}
</Text>
</View>
</View>
</View>
)}
{/* 佣金统计卡片 */}
{dealerUser && (
<View className="mx-4 -mt-6 rounded-xl p-4 relative z-10" style={cardGradients.elevated}>
<View className="mb-4">
<Text className="font-semibold text-gray-800"></Text>
</View>
<View className="grid grid-cols-3 gap-4">
<View className="text-center p-3 rounded-lg" style={{
background: businessGradients.money.available
}}>
<Text className="text-2xl font-bold mb-1 text-white">
¥{formatMoney(dealerUser.money)}
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View>
<View className="text-center p-3 rounded-lg" style={{
background: businessGradients.money.frozen
}}>
<Text className="text-2xl font-bold mb-1 text-white">
¥{formatMoney(dealerUser.freezeMoney)}
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View>
<View className="text-center p-3 rounded-lg" style={{
background: businessGradients.money.total
}}>
<Text className="text-2xl font-bold mb-1 text-white">
¥{formatMoney(dealerUser.totalMoney)}
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View>
</View>
</View>
)}
{/* 团队统计 */}
{dealerUser && (
<View className="bg-white mx-4 mt-4 rounded-xl p-4 hidden">
<View className="flex items-center justify-between mb-4">
<Text className="font-semibold text-gray-800"></Text>
<View
className="text-gray-400 text-sm flex items-center"
onClick={() => navigateToPage('/dealer/team/index')}
>
<Text></Text>
<ArrowRight size="12"/>
</View>
</View>
<View className="grid grid-cols-3 gap-4">
<View className="text-center grid">
<Text className="text-xl font-bold text-purple-500 mb-1">
{dealerUser.firstNum || 0}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="text-center grid">
<Text className="text-xl font-bold text-indigo-500 mb-1">
{dealerUser.secondNum || 0}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="text-center grid">
<Text className="text-xl font-bold text-pink-500 mb-1">
{dealerUser.thirdNum || 0}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
</View>
</View>
)}
{/* 功能导航 */}
<View className="bg-white mx-4 mt-4 rounded-xl p-4">
<View className="font-semibold mb-4 text-gray-800"></View>
<ConfigProvider>
<Grid
columns={4}
className="no-border-grid"
style={{
'--nutui-grid-border-color': 'transparent',
'--nutui-grid-item-border-width': '0px',
border: 'none'
} as React.CSSProperties}
>
<Grid.Item text="分销订单" onClick={() => navigateToPage('/dealer/orders/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Shopping color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'提现申请'} onClick={() => navigateToPage('/dealer/withdraw/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Purse color="#10b981" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'我的邀请'} onClick={() => navigateToPage('/dealer/team/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<People color="#8b5cf6" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'我的邀请码'} onClick={() => navigateToPage('/dealer/qrcode/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Dongdong color="#f59e0b" size="20"/>
</View>
</View>
</Grid.Item>
</Grid>
{/* 第二行功能 */}
{/*<Grid*/}
{/* columns={4}*/}
{/* className="no-border-grid mt-4"*/}
{/* style={{*/}
{/* '--nutui-grid-border-color': 'transparent',*/}
{/* '--nutui-grid-item-border-width': '0px',*/}
{/* border: 'none'*/}
{/* } as React.CSSProperties}*/}
{/*>*/}
{/* <Grid.Item text={'邀请统计'} onClick={() => navigateToPage('/dealer/invite-stats/index')}>*/}
{/* <View className="text-center">*/}
{/* <View className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
{/* <Presentation color="#6366f1" size="20"/>*/}
{/* </View>*/}
{/* </View>*/}
{/* </Grid.Item>*/}
{/* /!* 预留其他功能位置 *!/*/}
{/* <Grid.Item text={''}>*/}
{/* <View className="text-center">*/}
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
{/* </View>*/}
{/* </View>*/}
{/* </Grid.Item>*/}
{/* <Grid.Item text={''}>*/}
{/* <View className="text-center">*/}
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
{/* </View>*/}
{/* </View>*/}
{/* </Grid.Item>*/}
{/* <Grid.Item text={''}>*/}
{/* <View className="text-center">*/}
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
{/* </View>*/}
{/* </View>*/}
{/* </Grid.Item>*/}
{/*</Grid>*/}
</ConfigProvider>
</View>
</View>
{/* 底部安全区域 */}
<View className="h-20"></View>
</View>
)
}
export default DealerIndex

157
src/doctor/info.tsx Normal file
View File

@@ -0,0 +1,157 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Cell, CellGroup, Tag } from '@nutui/nutui-react-taro'
import { useDealerUser } from '@/hooks/useDealerUser'
import Taro from '@tarojs/taro'
const DealerInfo: React.FC = () => {
const {
dealerUser,
loading,
error,
refresh,
} = useDealerUser()
// 跳转到申请页面
const navigateToApply = () => {
Taro.navigateTo({
url: '/pages/dealer/apply/add'
})
}
if (error) {
return (
<View className="p-4">
<View className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<Text className="text-red-600">{error}</Text>
</View>
<Button type="primary" onClick={refresh}>
</Button>
</View>
)
}
return (
<View className="bg-gray-50 min-h-screen">
{/* 页面标题 */}
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-lg font-bold text-center">
</Text>
</View>
{!dealerUser ? (
// 非经销商状态
<View className="bg-white mx-4 mt-4 rounded-lg p-6">
<View className="text-center py-8">
<Text className="text-gray-500 mb-4"></Text>
<Text className="text-sm text-gray-400 mb-6">
</Text>
<Button
type="primary"
size="large"
onClick={navigateToApply}
>
</Button>
</View>
</View>
) : (
// 经销商信息展示
<View>
{/* 状态卡片 */}
<View className="bg-white mx-4 mt-4 rounded-lg p-4">
<View className="flex items-center justify-between mb-4">
<Text className="text-lg font-semibold"></Text>
<Tag>
{dealerUser.realName}
</Tag>
</View>
{/* 基本信息 */}
<CellGroup>
<Cell
title="经销商ID"
extra={dealerUser.userId || '-'}
/>
<Cell
title="refereeId"
extra={dealerUser.refereeId || '-'}
/>
<Cell
title="成为经销商时间"
extra={
dealerUser.money
}
/>
</CellGroup>
{/* 操作按钮 */}
<View className="mt-6 gap-2">
<Button
type="primary"
size="large"
loading={loading}
>
</Button>
</View>
</View>
{/* 经销商权益 */}
<View className="bg-white mx-4 mt-4 rounded-lg p-4">
<Text className="font-semibold mb-3"></Text>
<View className="gap-2">
<Text className="text-sm text-gray-600">
</Text>
<Text className="text-sm text-gray-600">
广
</Text>
<Text className="text-sm text-gray-600">
</Text>
<Text className="text-sm text-gray-600">
</Text>
</View>
</View>
{/* 佣金统计 */}
<View className="bg-white mx-4 mt-4 rounded-lg p-4">
<Text className="font-semibold mb-3"></Text>
<View className="grid grid-cols-3 gap-4">
<View className="text-center">
<Text className="text-lg font-bold text-blue-600">0</Text>
<Text className="text-sm text-gray-500"></Text>
</View>
<View className="text-center">
<Text className="text-lg font-bold text-green-600">0</Text>
<Text className="text-sm text-gray-500"></Text>
</View>
<View className="text-center">
<Text className="text-lg font-bold text-orange-600">0</Text>
<Text className="text-sm text-gray-500"></Text>
</View>
</View>
</View>
</View>
)}
{/* 刷新按钮 */}
<View className="text-center py-4">
<Text
className="text-blue-500 text-sm"
onClick={refresh}
>
</Text>
</View>
</View>
)
}
export default DealerInfo

View File

@@ -0,0 +1,7 @@
export default definePageConfig({
navigationBarTitleText: '邀请统计',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f5f5f5',
enablePullDownRefresh: true
})

View File

@@ -0,0 +1,336 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import {
Empty,
Tabs,
Loading,
PullToRefresh,
Card,
} from '@nutui/nutui-react-taro'
import {
User,
ArrowUp,
Calendar,
Share,
Target,
Gift
} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import { useDealerUser } from '@/hooks/useDealerUser'
import {
getInviteStats,
getMyInviteRecords,
getInviteRanking
} from '@/api/invite'
import type {
InviteStats,
InviteRecord
} from '@/api/invite'
import { businessGradients } from '@/styles/gradients'
import {InviteRanking} from "@/api/invite/model";
const InviteStatsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<string>('stats')
const [loading, setLoading] = useState<boolean>(false)
const [inviteStats, setInviteStats] = useState<InviteStats | null>(null)
const [inviteRecords, setInviteRecords] = useState<InviteRecord[]>([])
const [ranking, setRanking] = useState<InviteRanking[]>([])
const [dateRange, setDateRange] = useState<string>('month')
const { dealerUser } = useDealerUser()
// 获取邀请统计数据
const fetchInviteStats = useCallback(async () => {
if (!dealerUser?.userId) return
try {
setLoading(true)
const stats = await getInviteStats(dealerUser.userId)
stats && setInviteStats(stats)
} catch (error) {
console.error('获取邀请统计失败:', error)
Taro.showToast({
title: '获取统计数据失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}, [dealerUser?.userId])
// 获取邀请记录
const fetchInviteRecords = useCallback(async () => {
if (!dealerUser?.userId) return
try {
const result = await getMyInviteRecords({
page: 1,
limit: 50,
inviterId: dealerUser.userId
})
setInviteRecords(result?.list || [])
} catch (error) {
console.error('获取邀请记录失败:', error)
}
}, [dealerUser?.userId])
// 获取邀请排行榜
const fetchRanking = useCallback(async () => {
try {
const result = await getInviteRanking({
limit: 20,
period: dateRange as 'day' | 'week' | 'month'
})
setRanking(result || [])
} catch (error) {
console.error('获取排行榜失败:', error)
}
}, [dateRange])
// 刷新数据
const handleRefresh = async () => {
await Promise.all([
fetchInviteStats(),
fetchInviteRecords(),
fetchRanking()
])
}
// 初始化数据
useEffect(() => {
if (dealerUser?.userId) {
fetchInviteStats().then()
fetchInviteRecords().then()
fetchRanking().then()
}
}, [fetchInviteStats, fetchInviteRecords, fetchRanking])
// 获取状态显示文本
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
'pending': '待注册',
'registered': '已注册',
'activated': '已激活'
}
return statusMap[status] || status
}
// 获取状态颜色
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
'pending': 'text-orange-500',
'registered': 'text-blue-500',
'activated': 'text-green-500'
}
return colorMap[status] || 'text-gray-500'
}
// 渲染统计概览
const renderStatsOverview = () => (
<View className="px-4 space-y-4">
{/* 核心数据卡片 */}
<Card className="bg-white rounded-2xl shadow-sm">
<View className="p-4">
<Text className="text-lg font-semibold text-gray-800 mb-4"></Text>
{loading ? (
<View className="flex items-center justify-center py-8">
<Loading />
</View>
) : inviteStats ? (
<View className="grid grid-cols-2 gap-4">
<View className="text-center p-4 bg-blue-50 rounded-xl">
<ArrowUp size="24" className="text-blue-500 mx-auto mb-2" />
<Text className="text-2xl font-bold text-blue-600">
{inviteStats.totalInvites || 0}
</Text>
<Text className="text-sm text-gray-600"></Text>
</View>
<View className="text-center p-4 bg-green-50 rounded-xl">
<User size="24" className="text-green-500 mx-auto mb-2" />
<Text className="text-2xl font-bold text-green-600">
{inviteStats.successfulRegistrations || 0}
</Text>
<Text className="text-sm text-gray-600"></Text>
</View>
<View className="text-center p-4 bg-purple-50 rounded-xl">
<Target size="24" className="text-purple-500 mx-auto mb-2" />
<Text className="text-2xl font-bold text-purple-600">
{inviteStats.conversionRate ? `${(inviteStats.conversionRate * 100).toFixed(1)}%` : '0%'}
</Text>
<Text className="text-sm text-gray-600"></Text>
</View>
<View className="text-center p-4 bg-orange-50 rounded-xl">
<Calendar size="24" className="text-orange-500 mx-auto mb-2" />
<Text className="text-2xl font-bold text-orange-600">
{inviteStats.todayInvites || 0}
</Text>
<Text className="text-sm text-gray-600"></Text>
</View>
</View>
) : (
<View className="text-center py-8">
<Text className="text-gray-500"></Text>
</View>
)}
</View>
</Card>
{/* 邀请来源分析 */}
{inviteStats?.sourceStats && inviteStats.sourceStats.length > 0 && (
<Card className="bg-white rounded-2xl shadow-sm">
<View className="p-4">
<Text className="text-lg font-semibold text-gray-800 mb-4"></Text>
<View className="space-y-3">
{inviteStats.sourceStats.map((source, index) => (
<View key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<View className="flex items-center">
<Share size="16" className="text-blue-500 mr-2" />
<Text className="font-medium text-gray-800">{source.source}</Text>
</View>
<View className="text-right">
<Text className="text-lg font-bold text-gray-800">{source.count}</Text>
<Text className="text-sm text-gray-500">
{source.conversionRate ? `${(source.conversionRate * 100).toFixed(1)}%` : '0%'}
</Text>
</View>
</View>
))}
</View>
</View>
</Card>
)}
</View>
)
// 渲染邀请记录
const renderInviteRecords = () => (
<View className="px-4">
{inviteRecords.length > 0 ? (
<View className="space-y-3">
{inviteRecords.map((record, index) => (
<Card key={record.id || index} className="bg-white rounded-xl shadow-sm">
<View className="p-4">
<View className="flex items-center justify-between mb-2">
<Text className="font-medium text-gray-800">
{record.inviteeName || `用户${record.inviteeId}`}
</Text>
<Text className={`text-sm font-medium ${getStatusColor(record.status || 'pending')}`}>
{getStatusText(record.status || 'pending')}
</Text>
</View>
<View className="flex items-center justify-between text-sm text-gray-500">
<Text>: {record.source || '未知'}</Text>
<Text>{record.inviteTime ? new Date(record.inviteTime).toLocaleDateString() : ''}</Text>
</View>
{record.registerTime && (
<Text className="text-xs text-green-600 mt-1">
: {new Date(record.registerTime).toLocaleString()}
</Text>
)}
</View>
</Card>
))}
</View>
) : (
<Empty description="暂无邀请记录" />
)}
</View>
)
// 渲染排行榜
const renderRanking = () => (
<View className="px-4">
<View className="mb-4">
<Tabs value={dateRange} onChange={() => setDateRange}>
<Tabs.TabPane title="日榜" value="day" />
<Tabs.TabPane title="周榜" value="week" />
<Tabs.TabPane title="月榜" value="month" />
</Tabs>
</View>
{ranking.length > 0 ? (
<View className="space-y-3">
{ranking.map((item, index) => (
<Card key={item.inviterId} className="bg-white rounded-xl shadow-sm">
<View className="p-4 flex items-center">
<View className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 mr-3">
{index < 3 ? (
<Gift size="16" className={index === 0 ? 'text-yellow-500' : index === 1 ? 'text-gray-400' : 'text-orange-400'} />
) : (
<Text className="text-sm font-bold text-gray-600">{index + 1}</Text>
)}
</View>
<View className="flex-1">
<Text className="font-medium text-gray-800">{item.inviterName}</Text>
<Text className="text-sm text-gray-500">
{item.inviteCount} · {item.conversionRate ? `${(item.conversionRate * 100).toFixed(1)}%` : '0%'}
</Text>
</View>
<Text className="text-lg font-bold text-blue-600">{item.successCount}</Text>
</View>
</Card>
))}
</View>
) : (
<Empty description="暂无排行数据" />
)}
</View>
)
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
)
}
return (
<View className="bg-gray-50 min-h-screen">
{/* 头部 */}
<View className="rounded-b-3xl p-6 mb-6 text-white relative overflow-hidden" style={{
background: businessGradients.dealer.header
}}>
<View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
top: '-16px',
right: '-16px'
}}></View>
<View className="relative z-10">
<Text className="text-2xl font-bold mb-2 text-white"></Text>
<Text className="text-white text-opacity-80">
广
</Text>
</View>
</View>
{/* 标签页 */}
<View className="px-4 mb-4">
<Tabs value={activeTab} onChange={() => setActiveTab}>
<Tabs.TabPane title="统计概览" value="stats" />
<Tabs.TabPane title="邀请记录" value="records" />
<Tabs.TabPane title="排行榜" value="ranking" />
</Tabs>
</View>
{/* 内容区域 */}
<PullToRefresh onRefresh={handleRefresh}>
<View className="pb-6">
{activeTab === 'stats' && renderStatsOverview()}
{activeTab === 'records' && renderInviteRecords()}
{activeTab === 'ranking' && renderRanking()}
</View>
</PullToRefresh>
</View>
)
}
export default InviteStatsPage

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '分销订单'
})

392
src/doctor/orders/index.tsx Normal file
View File

@@ -0,0 +1,392 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import { Empty, Tabs, Tag, PullToRefresh, Loading } from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import { pageShopDealerOrder } from '@/api/shop/shopDealerOrder'
import { useDealerUser } from '@/hooks/useDealerUser'
import type { ShopDealerOrder } from '@/api/shop/shopDealerOrder/model'
interface OrderWithDetails extends ShopDealerOrder {
orderNo?: string
customerName?: string
totalCommission?: string
// 当前用户在此订单中的层级和佣金
userLevel?: 1 | 2 | 3
userCommission?: string
}
const DealerOrders: React.FC = () => {
const [activeTab, setActiveTab] = useState<string>('0')
const [loading, setLoading] = useState<boolean>(false)
const [orders, setOrders] = useState<OrderWithDetails[]>([])
const [statistics, setStatistics] = useState({
totalOrders: 0,
totalCommission: '0.00',
pendingCommission: '0.00',
// 分层统计
level1: { orders: 0, commission: '0.00' },
level2: { orders: 0, commission: '0.00' },
level3: { orders: 0, commission: '0.00' }
})
const { dealerUser } = useDealerUser()
// 获取订单数据 - 查询当前用户作为各层级分销商的所有订单
const fetchOrders = useCallback(async () => {
if (!dealerUser?.userId) return
try {
setLoading(true)
// 并行查询三个层级的订单
const [level1Result, level2Result, level3Result] = await Promise.all([
// 一级分销商订单
pageShopDealerOrder({
page: 1,
limit: 100,
firstUserId: dealerUser.userId
}),
// 二级分销商订单
pageShopDealerOrder({
page: 1,
limit: 100,
secondUserId: dealerUser.userId
}),
// 三级分销商订单
pageShopDealerOrder({
page: 1,
limit: 100,
thirdUserId: dealerUser.userId
})
])
const allOrders: OrderWithDetails[] = []
const stats = {
totalOrders: 0,
totalCommission: '0.00',
pendingCommission: '0.00',
level1: { orders: 0, commission: '0.00' },
level2: { orders: 0, commission: '0.00' },
level3: { orders: 0, commission: '0.00' }
}
// 处理一级分销订单
if (level1Result?.list) {
const level1Orders = level1Result.list.map(order => ({
...order,
orderNo: `DD${order.orderId}`,
customerName: `用户${order.userId}`,
userLevel: 1 as const,
userCommission: order.firstMoney || '0.00',
totalCommission: (
parseFloat(order.firstMoney || '0') +
parseFloat(order.secondMoney || '0') +
parseFloat(order.thirdMoney || '0')
).toFixed(2)
}))
allOrders.push(...level1Orders)
stats.level1.orders = level1Orders.length
stats.level1.commission = level1Orders.reduce((sum, order) =>
sum + parseFloat(order.userCommission || '0'), 0
).toFixed(2)
}
// 处理二级分销订单
if (level2Result?.list) {
const level2Orders = level2Result.list.map(order => ({
...order,
orderNo: `DD${order.orderId}`,
customerName: `用户${order.userId}`,
userLevel: 2 as const,
userCommission: order.secondMoney || '0.00',
totalCommission: (
parseFloat(order.firstMoney || '0') +
parseFloat(order.secondMoney || '0') +
parseFloat(order.thirdMoney || '0')
).toFixed(2)
}))
allOrders.push(...level2Orders)
stats.level2.orders = level2Orders.length
stats.level2.commission = level2Orders.reduce((sum, order) =>
sum + parseFloat(order.userCommission || '0'), 0
).toFixed(2)
}
// 处理三级分销订单
if (level3Result?.list) {
const level3Orders = level3Result.list.map(order => ({
...order,
orderNo: `DD${order.orderId}`,
customerName: `用户${order.userId}`,
userLevel: 3 as const,
userCommission: order.thirdMoney || '0.00',
totalCommission: (
parseFloat(order.firstMoney || '0') +
parseFloat(order.secondMoney || '0') +
parseFloat(order.thirdMoney || '0')
).toFixed(2)
}))
allOrders.push(...level3Orders)
stats.level3.orders = level3Orders.length
stats.level3.commission = level3Orders.reduce((sum, order) =>
sum + parseFloat(order.userCommission || '0'), 0
).toFixed(2)
}
// 去重(同一个订单可能在多个层级中出现)
const uniqueOrders = allOrders.filter((order, index, self) =>
index === self.findIndex(o => o.orderId === order.orderId)
)
// 计算总统计
stats.totalOrders = uniqueOrders.length
stats.totalCommission = (
parseFloat(stats.level1.commission) +
parseFloat(stats.level2.commission) +
parseFloat(stats.level3.commission)
).toFixed(2)
stats.pendingCommission = allOrders
.filter(order => order.isSettled === 0)
.reduce((sum, order) => sum + parseFloat(order.userCommission || '0'), 0)
.toFixed(2)
setOrders(uniqueOrders)
setStatistics(stats)
} catch (error) {
console.error('获取分销订单失败:', error)
Taro.showToast({
title: '获取订单失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}, [dealerUser?.userId])
// 刷新数据
const handleRefresh = async () => {
await fetchOrders()
}
// 初始化加载数据
useEffect(() => {
if (dealerUser?.userId) {
fetchOrders().then()
}
}, [fetchOrders])
const getStatusText = (isSettled?: number, isInvalid?: number) => {
if (isInvalid === 1) return '已失效'
if (isSettled === 1) return '已结算'
return '待结算'
}
const getStatusColor = (isSettled?: number, isInvalid?: number) => {
if (isInvalid === 1) return 'danger'
if (isSettled === 1) return 'success'
return 'warning'
}
const renderOrderItem = (order: OrderWithDetails) => (
<View key={order.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-3">
<View>
<Text className="font-semibold text-gray-800 mb-1">
{order.orderNo}
</Text>
<Text className="text-sm text-gray-500">
{order.customerName}
</Text>
{/* 显示用户在此订单中的层级 */}
<Text className="text-xs text-blue-500">
{order.userLevel === 1 && '一级分销'}
{order.userLevel === 2 && '二级分销'}
{order.userLevel === 3 && '三级分销'}
</Text>
</View>
<Tag type={getStatusColor(order.isSettled, order.isInvalid)}>
{getStatusText(order.isSettled, order.isInvalid)}
</Tag>
</View>
<View className="flex justify-between items-center">
<View>
<Text className="text-sm text-gray-600">
¥{order.orderPrice || '0.00'}
</Text>
<Text className="text-sm text-orange-500 font-semibold">
¥{order.userCommission}
</Text>
<Text className="text-xs text-gray-400">
¥{order.totalCommission}
</Text>
</View>
<Text className="text-xs text-gray-400">
{order.createTime}
</Text>
</View>
</View>
)
// 根据状态和层级过滤订单
const getFilteredOrders = (filter: string) => {
switch (filter) {
case '1': // 一级分销
return orders.filter(order => order.userLevel === 1)
case '2': // 二级分销
return orders.filter(order => order.userLevel === 2)
case '3': // 三级分销
return orders.filter(order => order.userLevel === 3)
case '4': // 待结算
return orders.filter(order => order.isSettled === 0 && order.isInvalid === 0)
case '5': // 已结算
return orders.filter(order => order.isSettled === 1)
case '6': // 已失效
return orders.filter(order => order.isInvalid === 1)
default: // 全部
return orders
}
}
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
)
}
return (
<View className="bg-gray-50 min-h-screen">
{/* 统计卡片 */}
<View className="bg-white p-4 mb-4">
{/* 总体统计 */}
<View className="grid grid-cols-3 gap-4 mb-4">
<View className="text-center">
<Text className="text-lg font-bold text-blue-500">{statistics.totalOrders}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="text-center">
<Text className="text-lg font-bold text-green-500">¥{statistics.totalCommission}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="text-center">
<Text className="text-lg font-bold text-orange-500">¥{statistics.pendingCommission}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
</View>
{/* 分层统计 */}
<View className="border-t pt-3">
<Text className="text-sm text-gray-600 mb-2"></Text>
<View className="grid grid-cols-3 gap-2">
<View className="text-center bg-gray-50 rounded p-2">
<Text className="text-sm font-semibold text-red-500">{statistics.level1.orders}</Text>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-xs text-red-500">¥{statistics.level1.commission}</Text>
</View>
<View className="text-center bg-gray-50 rounded p-2">
<Text className="text-sm font-semibold text-blue-500">{statistics.level2.orders}</Text>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-xs text-blue-500">¥{statistics.level2.commission}</Text>
</View>
<View className="text-center bg-gray-50 rounded p-2">
<Text className="text-sm font-semibold text-purple-500">{statistics.level3.orders}</Text>
<Text className="text-xs text-gray-500"></Text>
<Text className="text-xs text-purple-500">¥{statistics.level3.commission}</Text>
</View>
</View>
</View>
</View>
{/* 订单列表 */}
<Tabs value={activeTab} onChange={() => setActiveTab}>
<Tabs.TabPane title="全部" value="0">
<PullToRefresh
onRefresh={handleRefresh}
>
<View className="p-4">
{loading ? (
<View className="text-center py-8">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : getFilteredOrders('0').length > 0 ? (
getFilteredOrders('0').map(renderOrderItem)
) : (
<Empty description="暂无分销订单" />
)}
</View>
</PullToRefresh>
</Tabs.TabPane>
<Tabs.TabPane title="一级分销" value="1">
<View className="p-4">
{getFilteredOrders('1').length > 0 ? (
getFilteredOrders('1').map(renderOrderItem)
) : (
<Empty description="暂无一级分销订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="二级分销" value="2">
<View className="p-4">
{getFilteredOrders('2').length > 0 ? (
getFilteredOrders('2').map(renderOrderItem)
) : (
<Empty description="暂无二级分销订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="三级分销" value="3">
<View className="p-4">
{getFilteredOrders('3').length > 0 ? (
getFilteredOrders('3').map(renderOrderItem)
) : (
<Empty description="暂无三级分销订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="待结算" value="4">
<View className="p-4">
{getFilteredOrders('4').length > 0 ? (
getFilteredOrders('4').map(renderOrderItem)
) : (
<Empty description="暂无待结算订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="已结算" value="5">
<View className="p-4">
{getFilteredOrders('5').length > 0 ? (
getFilteredOrders('5').map(renderOrderItem)
) : (
<Empty description="暂无已结算订单" />
)}
</View>
</Tabs.TabPane>
<Tabs.TabPane title="已失效" value="6">
<View className="p-4">
{getFilteredOrders('6').length > 0 ? (
getFilteredOrders('6').map(renderOrderItem)
) : (
<Empty description="暂无失效订单" />
)}
</View>
</Tabs.TabPane>
</Tabs>
</View>
)
}
export default DealerOrders

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '推广二维码'
})

398
src/doctor/qrcode/index.tsx Normal file
View File

@@ -0,0 +1,398 @@
import React, {useState, useEffect} from 'react'
import {View, Text, Image} from '@tarojs/components'
import {Button, Loading} from '@nutui/nutui-react-taro'
import {Share, Download, Copy, QrCode} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {useDealerUser} from '@/hooks/useDealerUser'
import {generateInviteCode} from '@/api/invite'
// import type {InviteStats} from '@/api/invite'
import {businessGradients} from '@/styles/gradients'
const DealerQrcode: React.FC = () => {
const [miniProgramCodeUrl, setMiniProgramCodeUrl] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
// const [inviteStats, setInviteStats] = useState<InviteStats | null>(null)
// const [statsLoading, setStatsLoading] = useState<boolean>(false)
const {dealerUser} = useDealerUser()
// 生成小程序码
const generateMiniProgramCode = async () => {
if (!dealerUser?.userId) {
return
}
try {
setLoading(true)
// 生成邀请小程序码
const codeUrl = await generateInviteCode(dealerUser.userId)
if (codeUrl) {
setMiniProgramCodeUrl(codeUrl)
} else {
throw new Error('返回的小程序码URL为空')
}
} catch (error: any) {
Taro.showToast({
title: error.message || '生成小程序码失败',
icon: 'error'
})
// 清空之前的二维码
setMiniProgramCodeUrl('')
} finally {
setLoading(false)
}
}
// 获取邀请统计数据
// const fetchInviteStats = async () => {
// if (!dealerUser?.userId) return
//
// try {
// setStatsLoading(true)
// const stats = await getInviteStats(dealerUser.userId)
// stats && setInviteStats(stats)
// } catch (error) {
// // 静默处理错误,不影响用户体验
// } finally {
// setStatsLoading(false)
// }
// }
// 初始化生成小程序码和获取统计数据
useEffect(() => {
if (dealerUser?.userId) {
generateMiniProgramCode()
// fetchInviteStats()
}
}, [dealerUser?.userId])
// 保存小程序码到相册
const saveMiniProgramCode = async () => {
if (!miniProgramCodeUrl) {
Taro.showToast({
title: '小程序码未生成',
icon: 'error'
})
return
}
try {
// 先下载图片到本地
const res = await Taro.downloadFile({
url: miniProgramCodeUrl
})
if (res.statusCode === 200) {
// 保存到相册
await Taro.saveImageToPhotosAlbum({
filePath: res.tempFilePath
})
Taro.showToast({
title: '保存成功',
icon: 'success'
})
}
} catch (error: any) {
if (error.errMsg?.includes('auth deny')) {
Taro.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
success: (res) => {
if (res.confirm) {
Taro.openSetting()
}
}
})
} else {
Taro.showToast({
title: '保存失败',
icon: 'error'
})
}
}
}
// 复制邀请信息
const copyInviteInfo = () => {
if (!dealerUser?.userId) {
Taro.showToast({
title: '用户信息未加载',
icon: 'error'
})
return
}
const inviteText = `🎉 邀请您加入我的团队!
扫描小程序码或搜索"通源堂健康生态平台"小程序,即可享受优质商品和服务!
💰 成为我的团队成员,一起赚取丰厚佣金
🎁 新用户专享优惠等你来拿
邀请码:${dealerUser.userId}
快来加入我们吧!`
Taro.setClipboardData({
data: inviteText,
success: () => {
Taro.showToast({
title: '邀请信息已复制',
icon: 'success'
})
}
})
}
// 分享小程序码
const shareMiniProgramCode = () => {
if (!dealerUser?.userId) {
Taro.showToast({
title: '用户信息未加载',
icon: 'error'
})
return
}
// 小程序分享
Taro.showShareMenu({
withShareTicket: true,
showShareItems: ['shareAppMessage', 'shareTimeline']
})
}
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading/>
<Text className="text-gray-500 mt-2">...</Text>
</View>
)
}
return (
<View className="bg-gray-50 min-h-screen">
{/* 头部卡片 */}
<View className="rounded-b-3xl p-6 mb-6 text-white relative overflow-hidden" style={{
background: businessGradients.dealer.header
}}>
{/* 装饰背景 */}
<View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
top: '-16px',
right: '-16px'
}}></View>
<View className="relative z-10 flex flex-col">
<Text className="text-2xl font-bold mb-2 text-white"></Text>
<Text className="text-white text-opacity-80">
</Text>
</View>
</View>
<View className="px-4">
{/* 小程序码展示区 */}
<View className="bg-white rounded-2xl p-6 mb-6 shadow-sm">
<View className="text-center">
{loading ? (
<View className="w-48 h-48 mx-auto mb-4 flex items-center justify-center bg-gray-50 rounded-xl">
<Loading/>
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : miniProgramCodeUrl ? (
<View className="w-48 h-48 mx-auto mb-4 bg-white rounded-xl shadow-sm p-4">
<Image
src={miniProgramCodeUrl}
className="w-full h-full"
mode="aspectFit"
onError={() => {
Taro.showModal({
title: '二维码加载失败',
content: '请检查网络连接或联系管理员',
showCancel: true,
confirmText: '重新生成',
success: (res) => {
if (res.confirm) {
generateMiniProgramCode();
}
}
});
}}
/>
</View>
) : (
<View className="w-48 h-48 mx-auto mb-4 flex items-center justify-center bg-gray-50 rounded-xl">
<QrCode size="48" className="text-gray-400 mb-2"/>
<Text className="text-gray-500"></Text>
<Button
size="small"
type="primary"
className="mt-2"
onClick={generateMiniProgramCode}
>
</Button>
</View>
)}
<View className="text-lg font-semibold text-gray-800 mb-2">
</View>
<View className="text-sm text-gray-500 mb-4">
</View>
</View>
</View>
{/* 操作按钮 */}
<View className={'gap-2'}>
<View className={'my-2'}>
<Button
type="primary"
size="large"
block
icon={<Download/>}
onClick={saveMiniProgramCode}
disabled={!miniProgramCodeUrl || loading}
>
</Button>
</View>
<View className={'my-2 bg-white'}>
<Button
size="large"
block
icon={<Copy/>}
onClick={copyInviteInfo}
disabled={!dealerUser?.userId || loading}
>
</Button>
</View>
<View className={'my-2 bg-white'}>
<Button
size="large"
block
fill="outline"
icon={<Share/>}
onClick={shareMiniProgramCode}
disabled={!dealerUser?.userId || loading}
>
</Button>
</View>
</View>
{/* 推广说明 */}
<View className="bg-white rounded-2xl p-4 mt-6 hidden">
<Text className="font-semibold text-gray-800 mb-3">广</Text>
<View className="space-y-2">
<View className="flex items-start">
<View className="w-2 h-2 bg-blue-500 rounded-full mt-2 mr-3 flex-shrink-0"></View>
<Text className="text-sm text-gray-600">
</Text>
</View>
<View className="flex items-start">
<View className="w-2 h-2 bg-green-500 rounded-full mt-2 mr-3 flex-shrink-0"></View>
<Text className="text-sm text-gray-600">
</Text>
</View>
<View className="flex items-start">
<View className="w-2 h-2 bg-purple-500 rounded-full mt-2 mr-3 flex-shrink-0"></View>
<Text className="text-sm text-gray-600">
</Text>
</View>
</View>
</View>
{/* 邀请统计数据 */}
{/*<View className="bg-white rounded-2xl p-4 mt-4 mb-6">*/}
{/* <Text className="font-semibold text-gray-800 mb-3">我的邀请数据</Text>*/}
{/* {statsLoading ? (*/}
{/* <View className="flex items-center justify-center py-8">*/}
{/* <Loading/>*/}
{/* <Text className="text-gray-500 mt-2">加载中...</Text>*/}
{/* </View>*/}
{/* ) : inviteStats ? (*/}
{/* <View className="space-y-4">*/}
{/* <View className="grid grid-cols-2 gap-4">*/}
{/* <View className="text-center">*/}
{/* <Text className="text-2xl font-bold text-blue-500">*/}
{/* {inviteStats.totalInvites || 0}*/}
{/* </Text>*/}
{/* <Text className="text-sm text-gray-500">总邀请数</Text>*/}
{/* </View>*/}
{/* <View className="text-center">*/}
{/* <Text className="text-2xl font-bold text-green-500">*/}
{/* {inviteStats.successfulRegistrations || 0}*/}
{/* </Text>*/}
{/* <Text className="text-sm text-gray-500">成功注册</Text>*/}
{/* </View>*/}
{/* </View>*/}
{/* <View className="grid grid-cols-2 gap-4">*/}
{/* <View className="text-center">*/}
{/* <Text className="text-2xl font-bold text-purple-500">*/}
{/* {inviteStats.conversionRate ? `${(inviteStats.conversionRate * 100).toFixed(1)}%` : '0%'}*/}
{/* </Text>*/}
{/* <Text className="text-sm text-gray-500">转化率</Text>*/}
{/* </View>*/}
{/* <View className="text-center">*/}
{/* <Text className="text-2xl font-bold text-orange-500">*/}
{/* {inviteStats.todayInvites || 0}*/}
{/* </Text>*/}
{/* <Text className="text-sm text-gray-500">今日邀请</Text>*/}
{/* </View>*/}
{/* </View>*/}
{/* /!* 邀请来源统计 *!/*/}
{/* {inviteStats.sourceStats && inviteStats.sourceStats.length > 0 && (*/}
{/* <View className="mt-4">*/}
{/* <Text className="text-sm font-medium text-gray-700 mb-2">邀请来源分布</Text>*/}
{/* <View className="space-y-2">*/}
{/* {inviteStats.sourceStats.map((source, index) => (*/}
{/* <View key={index} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">*/}
{/* <View className="flex items-center">*/}
{/* <View className="w-3 h-3 rounded-full bg-blue-500 mr-2"></View>*/}
{/* <Text className="text-sm text-gray-700">{source.source}</Text>*/}
{/* </View>*/}
{/* <View className="text-right">*/}
{/* <Text className="text-sm font-medium text-gray-800">{source.count}</Text>*/}
{/* <Text className="text-xs text-gray-500">*/}
{/* {source.conversionRate ? `${(source.conversionRate * 100).toFixed(1)}%` : '0%'}*/}
{/* </Text>*/}
{/* </View>*/}
{/* </View>*/}
{/* ))}*/}
{/* </View>*/}
{/* </View>*/}
{/* )}*/}
{/* </View>*/}
{/* ) : (*/}
{/* <View className="text-center py-8">*/}
{/* <View className="text-gray-500">暂无邀请数据</View>*/}
{/* <Button*/}
{/* size="small"*/}
{/* type="primary"*/}
{/* className="mt-2"*/}
{/* onClick={fetchInviteStats}*/}
{/* >*/}
{/* 刷新数据*/}
{/* </Button>*/}
{/* </View>*/}
{/* )}*/}
{/*</View>*/}
</View>
</View>
)
}
export default DealerQrcode

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我的团队'
})

367
src/doctor/team/index.tsx Normal file
View File

@@ -0,0 +1,367 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import { Empty, Tabs, Avatar, Tag, Progress, Loading, PullToRefresh } from '@nutui/nutui-react-taro'
import { User, Star, StarFill } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import { useDealerUser } from '@/hooks/useDealerUser'
import { listShopDealerReferee } from '@/api/shop/shopDealerReferee'
import { pageShopDealerOrder } from '@/api/shop/shopDealerOrder'
import type { ShopDealerReferee } from '@/api/shop/shopDealerReferee/model'
interface TeamMemberWithStats extends ShopDealerReferee {
name?: string
avatar?: string
orderCount?: number
commission?: string
status?: 'active' | 'inactive'
subMembers?: number
joinTime?: string
}
const DealerTeam: React.FC = () => {
const [activeTab, setActiveTab] = useState('0')
const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const [teamMembers, setTeamMembers] = useState<TeamMemberWithStats[]>([])
const [teamStats, setTeamStats] = useState({
total: 0,
firstLevel: 0,
secondLevel: 0,
thirdLevel: 0,
monthlyCommission: '0.00'
})
const { dealerUser } = useDealerUser()
// 获取团队数据
const fetchTeamData = useCallback(async () => {
if (!dealerUser?.userId) return
try {
setLoading(true)
// 获取团队成员关系
const refereeResult = await listShopDealerReferee({
dealerId: dealerUser.userId
})
if (refereeResult) {
// 处理团队成员数据
const processedMembers: TeamMemberWithStats[] = refereeResult.map(member => ({
...member,
name: `用户${member.userId}`,
avatar: '',
orderCount: 0,
commission: '0.00',
status: 'active' as const,
subMembers: 0,
joinTime: member.createTime
}))
// 并行获取每个成员的订单统计
const memberStats = await Promise.all(
processedMembers.map(async (member) => {
try {
const orderResult = await pageShopDealerOrder({
page: 1,
limit: 100,
userId: member.userId
})
if (orderResult?.list) {
const orders = orderResult.list
const orderCount = orders.length
const commission = orders.reduce((sum, order) => {
const levelCommission = member.level === 1 ? order.firstMoney :
member.level === 2 ? order.secondMoney :
order.thirdMoney
return sum + parseFloat(levelCommission || '0')
}, 0).toFixed(2)
// 判断活跃状态30天内有订单为活跃
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const hasRecentOrder = orders.some(order =>
new Date(order.createTime || '') > thirtyDaysAgo
)
return {
...member,
orderCount,
commission,
status: hasRecentOrder ? 'active' as const : 'inactive' as const
}
}
return member
} catch (error) {
console.error(`获取成员${member.userId}订单失败:`, error)
return member
}
})
)
setTeamMembers(memberStats)
// 计算统计数据
const stats = {
total: memberStats.length,
firstLevel: memberStats.filter(m => m.level === 1).length,
secondLevel: memberStats.filter(m => m.level === 2).length,
thirdLevel: memberStats.filter(m => m.level === 3).length,
monthlyCommission: memberStats.reduce((sum, member) =>
sum + parseFloat(member.commission || '0'), 0
).toFixed(2)
}
setTeamStats(stats)
}
} catch (error) {
console.error('获取团队数据失败:', error)
Taro.showToast({
title: '获取团队数据失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}, [dealerUser?.userId])
// 刷新数据
const handleRefresh = async () => {
setRefreshing(true)
await fetchTeamData()
setRefreshing(false)
}
// 初始化加载数据
useEffect(() => {
if (dealerUser?.userId) {
fetchTeamData().then()
}
}, [fetchTeamData])
const getLevelColor = (level: number) => {
switch (level) {
case 1: return '#f59e0b'
case 2: return '#8b5cf6'
case 3: return '#ec4899'
default: return '#6b7280'
}
}
const getLevelIcon = (level: number) => {
switch (level) {
case 1: return <StarFill color={getLevelColor(level)} size="16" />
case 2: return <Star color={getLevelColor(level)} size="16" />
case 3: return <User color={getLevelColor(level)} size="16" />
default: return <User color={getLevelColor(level)} size="16" />
}
}
const renderMemberItem = (member: TeamMemberWithStats) => (
<View key={member.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex items-center mb-3">
<Avatar
size="40"
src={member.avatar}
icon={<User />}
className="mr-3"
/>
<View className="flex-1">
<View className="flex items-center mb-1">
<Text className="font-semibold text-gray-800 mr-2">
{member.name}
</Text>
{getLevelIcon(Number(member.level))}
<Text className="text-xs text-gray-500 ml-1">
{member.level}
</Text>
</View>
<Text className="text-xs text-gray-500">
{member.joinTime}
</Text>
</View>
<View className="text-right">
<Tag
type={member.status === 'active' ? 'success' : 'default'}
>
{member.status === 'active' ? '活跃' : '沉默'}
</Tag>
</View>
</View>
<View className="grid grid-cols-3 gap-4 text-center">
<View>
<Text className="text-sm font-semibold text-blue-600">
{member.orderCount}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View>
<Text className="text-sm font-semibold text-green-600">
¥{member.commission}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View>
<Text className="text-sm font-semibold text-purple-600">
{member.subMembers}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
</View>
</View>
)
const renderOverview = () => (
<View className="p-4">
{/* 团队统计卡片 */}
<View className="rounded-xl p-6 mb-6 text-white relative overflow-hidden" style={{
background: 'linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%)'
}}>
{/* 装饰背景 - 小程序兼容版本 */}
<View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
top: '-16px',
right: '-16px'
}}></View>
<View className="absolute w-20 h-20 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
bottom: '-10px',
left: '-10px'
}}></View>
<View className="relative z-10">
<Text className="text-lg font-bold mb-4 text-white"></Text>
<View className="grid grid-cols-2 gap-4">
<View>
<Text className="text-2xl font-bold mb-1 text-white">{teamStats.total}</Text>
<Text className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.8)' }}></Text>
</View>
<View>
<Text className="text-2xl font-bold mb-1 text-white">¥{teamStats.monthlyCommission}</Text>
<Text className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.8)' }}></Text>
</View>
</View>
</View>
</View>
{/* 层级分布 */}
<View className="bg-white rounded-xl p-4 mb-4">
<Text className="font-semibold mb-4 text-gray-800"></Text>
<View className="gap-2">
<View className="flex items-center justify-between">
<View className="flex items-center">
<StarFill color="#f59e0b" size="16" className="mr-2" />
<Text className="text-sm"></Text>
</View>
<View className="flex items-center">
<Text className="text-sm font-semibold mr-2">{teamStats.firstLevel}</Text>
<Progress
percent={(teamStats.firstLevel / teamStats.total) * 100}
strokeWidth="6"
background={'#f59e0b'}
className="w-20"
/>
</View>
</View>
<View className="flex items-center justify-between">
<View className="flex items-center">
<Star color="#8b5cf6" size="16" className="mr-2" />
<Text className="text-sm"></Text>
</View>
<View className="flex items-center">
<Text className="text-sm font-semibold mr-2">{teamStats.secondLevel}</Text>
<Progress
percent={(teamStats.secondLevel / teamStats.total) * 100}
strokeWidth="6"
background={'#8b5cf6'}
className="w-20"
/>
</View>
</View>
<View className="flex items-center justify-between">
<View className="flex items-center">
<User color="#ec4899" size="16" className="mr-2" />
<Text className="text-sm"></Text>
</View>
<View className="flex items-center">
<Text className="text-sm font-semibold mr-2">{teamStats.thirdLevel}</Text>
<Progress
percent={(teamStats.thirdLevel / teamStats.total) * 100}
strokeWidth="6"
background={'#ec4899'}
className="w-20"
/>
</View>
</View>
</View>
</View>
{/* 最新成员 */}
<View className="bg-white rounded-xl p-4">
<Text className="font-semibold mb-4 text-gray-800"></Text>
{teamMembers.slice(0, 3).map(renderMemberItem)}
</View>
</View>
)
const renderMemberList = (level?: number) => (
<PullToRefresh
disabled={refreshing}
onRefresh={handleRefresh}
>
<View className="p-4">
{loading ? (
<View className="text-center py-8">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : teamMembers
.filter(member => !level || member.level === level)
.length > 0 ? (
teamMembers
.filter(member => !level || member.level === level)
.map(renderMemberItem)
) : (
<Empty description={`暂无${level ? level + '级' : ''}团队成员`} />
)}
</View>
</PullToRefresh>
)
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
)
}
return (
<View className="bg-gray-50 min-h-screen">
<Tabs value={activeTab} onChange={() => setActiveTab}>
<Tabs.TabPane title="团队总览" value="0">
{renderOverview()}
</Tabs.TabPane>
<Tabs.TabPane title="一级成员" value="1">
{renderMemberList(1)}
</Tabs.TabPane>
<Tabs.TabPane title="二级成员" value="2">
{renderMemberList(2)}
</Tabs.TabPane>
<Tabs.TabPane title="三级成员" value="3">
{renderMemberList(3)}
</Tabs.TabPane>
</Tabs>
</View>
)
}
export default DealerTeam

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '提现申请'
})

View File

@@ -0,0 +1,424 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import {
Cell,
Button,
Form,
Input,
CellGroup,
Radio,
Tabs,
Tag,
Empty,
Loading,
PullToRefresh
} from '@nutui/nutui-react-taro'
import { Wallet } from '@nutui/icons-react-taro'
import { businessGradients } from '@/styles/gradients'
import Taro from '@tarojs/taro'
import { useDealerUser } from '@/hooks/useDealerUser'
import { pageShopDealerWithdraw, addShopDealerWithdraw } from '@/api/shop/shopDealerWithdraw'
import type { ShopDealerWithdraw } from '@/api/shop/shopDealerWithdraw/model'
interface WithdrawRecordWithDetails extends ShopDealerWithdraw {
accountDisplay?: string
}
const DealerWithdraw: React.FC = () => {
const [activeTab, setActiveTab] = useState('0')
const [selectedAccount, setSelectedAccount] = useState('')
const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const [submitting, setSubmitting] = useState<boolean>(false)
const [availableAmount, setAvailableAmount] = useState<string>('0.00')
const [withdrawRecords, setWithdrawRecords] = useState<WithdrawRecordWithDetails[]>([])
const formRef = useRef<any>(null)
const { dealerUser } = useDealerUser()
// 获取可提现余额
const fetchBalance = useCallback(async () => {
try {
setAvailableAmount(dealerUser?.money || '0.00')
} catch (error) {
console.error('获取余额失败:', error)
}
}, [])
// 获取提现记录
const fetchWithdrawRecords = useCallback(async () => {
if (!dealerUser?.userId) return
try {
setLoading(true)
const result = await pageShopDealerWithdraw({
page: 1,
limit: 100,
userId: dealerUser.userId
})
if (result?.list) {
const processedRecords = result.list.map(record => ({
...record,
accountDisplay: getAccountDisplay(record)
}))
setWithdrawRecords(processedRecords)
}
} catch (error) {
console.error('获取提现记录失败:', error)
Taro.showToast({
title: '获取提现记录失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}, [dealerUser?.userId])
// 格式化账户显示
const getAccountDisplay = (record: ShopDealerWithdraw) => {
if (record.payType === 10) {
return '微信钱包'
} else if (record.payType === 20 && record.alipayAccount) {
return `支付宝(${record.alipayAccount.slice(-4)})`
} else if (record.payType === 30 && record.bankCard) {
return `${record.bankName || '银行卡'}(尾号${record.bankCard.slice(-4)})`
}
return '未知账户'
}
// 刷新数据
const handleRefresh = async () => {
setRefreshing(true)
await Promise.all([fetchBalance(), fetchWithdrawRecords()])
setRefreshing(false)
}
// 初始化加载数据
useEffect(() => {
if (dealerUser?.userId) {
fetchBalance().then()
fetchWithdrawRecords().then()
}
}, [fetchBalance, fetchWithdrawRecords])
const getStatusText = (status?: number) => {
switch (status) {
case 40: return '已到账'
case 20: return '审核通过'
case 10: return '待审核'
case 30: return '已驳回'
default: return '未知'
}
}
const getStatusColor = (status?: number) => {
switch (status) {
case 40: return 'success'
case 20: return 'success'
case 10: return 'warning'
case 30: return 'danger'
default: return 'default'
}
}
const handleSubmit = async (values: any) => {
if (!dealerUser?.userId) {
Taro.showToast({
title: '用户信息获取失败',
icon: 'error'
})
return
}
// 验证提现金额
const amount = parseFloat(values.amount)
const available = parseFloat(availableAmount.replace(',', ''))
if (amount < 100) {
Taro.showToast({
title: '最低提现金额为100元',
icon: 'error'
})
return
}
if (amount > available) {
Taro.showToast({
title: '提现金额超过可用余额',
icon: 'error'
})
return
}
try {
setSubmitting(true)
const withdrawData: ShopDealerWithdraw = {
userId: dealerUser.userId,
money: values.amount,
payType: values.accountType === 'wechat' ? 10 :
values.accountType === 'alipay' ? 20 : 30,
applyStatus: 10, // 待审核
platform: 'MiniProgram'
}
// 根据提现方式设置账户信息
if (values.accountType === 'alipay') {
withdrawData.alipayAccount = values.account
withdrawData.alipayName = values.accountName
} else if (values.accountType === 'bank') {
withdrawData.bankCard = values.account
withdrawData.bankAccount = values.accountName
withdrawData.bankName = values.bankName || '银行卡'
}
await addShopDealerWithdraw(withdrawData)
Taro.showToast({
title: '提现申请已提交',
icon: 'success'
})
// 重置表单
formRef.current?.resetFields()
setSelectedAccount('')
// 刷新数据
await handleRefresh()
// 切换到提现记录页面
setActiveTab('1')
} catch (error: any) {
console.error('提现申请失败:', error)
Taro.showToast({
title: error.message || '提现申请失败',
icon: 'error'
})
} finally {
setSubmitting(false)
}
}
const quickAmounts = ['100', '300', '500', '1000']
const setQuickAmount = (amount: string) => {
formRef.current?.setFieldsValue({ amount })
}
const setAllAmount = () => {
formRef.current?.setFieldsValue({ amount: availableAmount.replace(',', '') })
}
const renderWithdrawForm = () => (
<View className="p-4">
{/* 余额卡片 */}
<View className="rounded-xl p-6 mb-6 text-white relative overflow-hidden" style={{
background: businessGradients.dealer.header
}}>
{/* 装饰背景 - 小程序兼容版本 */}
<View className="absolute top-0 right-0 w-24 h-24 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
right: '-12px',
top: '-12px'
}}></View>
<View className="flex items-center justify-between relative z-10">
<View>
<Text className="text-white text-opacity-80 text-sm mb-1"></Text>
<Text className="text-2xl font-bold text-white">¥{availableAmount}</Text>
</View>
<View className="p-3 rounded-full" style={{
background: 'rgba(255, 255, 255, 0.2)'
}}>
<Wallet color="white" size="32" />
</View>
</View>
<View className="mt-4 pt-4 relative z-10" style={{
borderTop: '1px solid rgba(255, 255, 255, 0.3)'
}}>
<Text className="text-white text-opacity-80 text-xs">
¥100 |
</Text>
</View>
</View>
<Form
ref={formRef}
onFinish={handleSubmit}
labelPosition="top"
>
<CellGroup>
<Form.Item name="amount" label="提现金额" required>
<Input
placeholder="请输入提现金额"
type="number"
clearable
/>
</Form.Item>
{/* 快捷金额 */}
<View className="px-4 py-2">
<Text className="text-sm text-gray-600 mb-2"></Text>
<View className="flex flex-wrap gap-2">
{quickAmounts.map(amount => (
<Button
key={amount}
size="small"
fill="outline"
onClick={() => setQuickAmount(amount)}
>
{amount}
</Button>
))}
<Button
size="small"
fill="outline"
onClick={setAllAmount}
>
</Button>
</View>
</View>
<Form.Item name="accountType" label="提现方式" required>
<Radio.Group value={selectedAccount} onChange={() => setSelectedAccount}>
<Cell.Group>
<Cell>
<Radio value="wechat"></Radio>
</Cell>
<Cell>
<Radio value="alipay"></Radio>
</Cell>
<Cell>
<Radio value="bank"></Radio>
</Cell>
</Cell.Group>
</Radio.Group>
</Form.Item>
{selectedAccount === 'alipay' && (
<>
<Form.Item name="account" label="支付宝账号" required>
<Input placeholder="请输入支付宝账号" />
</Form.Item>
<Form.Item name="accountName" label="支付宝姓名" required>
<Input placeholder="请输入支付宝实名姓名" />
</Form.Item>
</>
)}
{selectedAccount === 'bank' && (
<>
<Form.Item name="bankName" label="开户银行" required>
<Input placeholder="请输入开户银行名称" />
</Form.Item>
<Form.Item name="account" label="银行卡号" required>
<Input placeholder="请输入银行卡号" />
</Form.Item>
<Form.Item name="accountName" label="开户姓名" required>
<Input placeholder="请输入银行卡开户姓名" />
</Form.Item>
</>
)}
{selectedAccount === 'wechat' && (
<View className="px-4 py-2">
<Text className="text-sm text-gray-500">
</Text>
</View>
)}
</CellGroup>
<View className="mt-6 px-4">
<Button
block
type="primary"
nativeType="submit"
loading={submitting}
disabled={submitting || !selectedAccount}
>
{submitting ? '提交中...' : '申请提现'}
</Button>
</View>
</Form>
</View>
)
const renderWithdrawRecords = () => (
<PullToRefresh
disabled={refreshing}
onRefresh={handleRefresh}
>
<View className="p-4">
{loading ? (
<View className="text-center py-8">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : withdrawRecords.length > 0 ? (
withdrawRecords.map(record => (
<View key={record.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-3">
<View>
<Text className="font-semibold text-gray-800 mb-1">
¥{record.money}
</Text>
<Text className="text-sm text-gray-500">
{record.accountDisplay}
</Text>
</View>
<Tag type={getStatusColor(record.applyStatus)}>
{getStatusText(record.applyStatus)}
</Tag>
</View>
<View className="text-xs text-gray-400">
<Text>{record.createTime}</Text>
{record.auditTime && (
<Text className="block mt-1">
{new Date(record.auditTime).toLocaleString()}
</Text>
)}
{record.rejectReason && (
<Text className="block mt-1 text-red-500">
{record.rejectReason}
</Text>
)}
</View>
</View>
))
) : (
<Empty description="暂无提现记录" />
)}
</View>
</PullToRefresh>
)
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading />
<Text className="text-gray-500 mt-2">...</Text>
</View>
)
}
return (
<View className="bg-gray-50 min-h-screen">
<Tabs value={activeTab} onChange={() => setActiveTab}>
<Tabs.TabPane title="申请提现" value="0">
{renderWithdrawForm()}
</Tabs.TabPane>
<Tabs.TabPane title="提现记录" value="1">
{renderWithdrawRecords()}
</Tabs.TabPane>
</Tabs>
</View>
)
}
export default DealerWithdraw

66
src/hooks/useAdminMode.ts Normal file
View File

@@ -0,0 +1,66 @@
import { useState, useCallback, useEffect } from 'react';
import Taro from '@tarojs/taro';
/**
* 管理员模式Hook
* 用于管理管理员用户的模式切换(普通用户模式 vs 管理员模式)
*/
export function useAdminMode() {
const [isAdminMode, setIsAdminMode] = useState<boolean>(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
};
}

View File

@@ -28,6 +28,9 @@ export const useOrderStats = () => {
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})

228
src/hooks/useQRLogin.ts Normal file
View File

@@ -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>(ScanLoginState.IDLE);
const [error, setError] = useState<string>('');
const [result, setResult] = useState<ConfirmLoginResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
// 用于取消操作的引用
const cancelRef = useRef<boolean>(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<string>((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
};
}

View File

@@ -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>(UnifiedScanState.IDLE);
const [error, setError] = useState<string>('');
const [result, setResult] = useState<UnifiedScanResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [scanType, setScanType] = useState<ScanType>(ScanType.UNKNOWN);
// 用于取消操作的引用
const cancelRef = useRef<boolean>(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<UnifiedScanResult> => {
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<UnifiedScanResult> => {
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<UnifiedScanResult | null> => {
try {
reset();
setState(UnifiedScanState.SCANNING);
// 调用扫码API
const scanResult = await new Promise<string>((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
};
}

View File

@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react';
import Taro from '@tarojs/taro';
import { User } from '@/api/system/user/model';
import { getUserInfo, updateUserInfo } from '@/api/layout';
import { getUserInfo, updateUserInfo, loginByOpenId } from '@/api/layout';
import { TenantId } from '@/config/app';
import {getStoredInviteParams, handleInviteRelation} from '@/utils/invite';
// 用户Hook
export const useUser = () => {
@@ -9,8 +11,62 @@ export const useUser = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [loading, setLoading] = useState(true);
// 自动登录通过OpenID
const autoLoginByOpenId = async () => {
try {
const res = await new Promise<any>((resolve, reject) => {
Taro.login({
success: (loginRes) => {
loginByOpenId({
code: loginRes.code,
tenantId: TenantId
}).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 = () => {
const loadUserFromStorage = async () => {
try {
const token = Taro.getStorageSync('access_token');
const userData = Taro.getStorageSync('User');
@@ -26,8 +82,13 @@ export const useUser = () => {
setIsLoggedIn(true);
setUser({ userId, tenantId } as User);
} else {
setUser(null);
setIsLoggedIn(false);
// 没有本地登录信息,尝试自动登录
console.log('没有本地登录信息,尝试自动登录...');
const autoLoginResult = await autoLoginByOpenId();
if (!autoLoginResult) {
setUser(null);
setIsLoggedIn(false);
}
}
} catch (error) {
console.error('加载用户数据失败:', error);
@@ -43,9 +104,24 @@ export const useUser = () => {
try {
Taro.setStorageSync('access_token', token);
Taro.setStorageSync('User', userInfo);
Taro.setStorageSync('UserId', userInfo.userId);
Taro.setStorageSync('TenantId', userInfo.tenantId);
Taro.setStorageSync('Phone', userInfo.phone);
// 确保关键字段不为空时才保存,避免覆盖现有数据
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);
}
@@ -114,9 +190,16 @@ export const useUser = () => {
}
try {
const updatedUser = { ...user, ...userData };
// 先获取最新的用户信息,确保我们有完整的数据
const latestUserInfo = await getUserInfo();
// 合并最新的用户信息和要更新的数据
const updatedUser = { ...latestUserInfo, ...userData };
// 调用API更新用户信息
await updateUserInfo(updatedUser);
// 更新本地状态
setUser(updatedUser);
// 更新本地存储
@@ -216,7 +299,10 @@ export const useUser = () => {
// 初始化时加载用户数据
useEffect(() => {
loadUserFromStorage();
loadUserFromStorage().catch(error => {
console.error('初始化用户数据失败:', error);
setLoading(false);
});
}, []);
return {
@@ -231,6 +317,7 @@ export const useUser = () => {
fetchUserInfo,
updateUser,
loadUserFromStorage,
autoLoginByOpenId,
// 工具方法
hasPermission,

View File

@@ -39,6 +39,10 @@ export const useUserData = (): UseUserDataReturn => {
setLoading(true)
setError(null)
if(!Taro.getStorageSync('UserId')){
return;
}
// 并发请求所有数据
const [userDataRes, couponsRes, giftCardsRes] = await Promise.all([
getUserInfo(),

View File

@@ -1,5 +1,5 @@
import {useEffect, useState} from "react";
import Taro, {useShareAppMessage, useShareTimeline, useDidShow} from '@tarojs/taro';
import Taro, {useShareAppMessage, useDidShow} from '@tarojs/taro';
import {
NavBar,
Checkbox,
@@ -39,15 +39,9 @@ function Cart() {
nutuiInputnumberButtonBorderRadius: '4px',
}
useShareTimeline(() => {
return {
title: '购物车 - 网宿小店'
};
});
useShareAppMessage(() => {
return {
title: '购物车 - 网宿小店',
title: '购物车 - 时里院子市集',
success: function () {
console.log('分享成功');
},

View File

@@ -0,0 +1,25 @@
import {Image, Cell} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
const ArticleList = (props: any) => {
return (
<>
<div className={'px-3'}>
{props.data.map((item, index) => {
return (
<Cell
title={item.title}
extra={
<Image src={item.image} mode={'aspectFit'} lazyLoad={false} width={100} height="100"/>
}
key={index}
onClick={() => Taro.navigateTo({url: '/cms/detail/index?id=' + item.articleId})}
/>
)
})}
</div>
</>
)
}
export default ArticleList

View File

@@ -0,0 +1,59 @@
import {useEffect, useState} from "react";
import {Tabs, Loading} from '@nutui/nutui-react-taro'
import {pageCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model";
import ArticleList from "./ArticleList";
const ArticleTabs = (props: any) => {
const [loading, setLoading] = useState<boolean>(true)
const [tab1value, setTab1value] = useState<string | number>('0')
const [list, setList] = useState<CmsArticle[]>([])
const reload = async (value) => {
const {data} = props
pageCmsArticle({
categoryId: data[value].navigationId,
page: 1,
status: 0,
limit: 10
}).then((res) => {
res && setList(res?.list || [])
})
.catch(err => {
console.log(err)
})
.finally(() => {
setTab1value(value)
setLoading(false)
})
}
useEffect(() => {
reload(0).then()
}, []);
if (loading) {
return (
<Loading className={'px-2'}></Loading>
)
}
return (
<>
<Tabs
value={tab1value}
onChange={(value) => {
reload(value).then()
}}
>
{props.data?.map((item, index) => {
return (
<Tabs.TabPane title={item.categoryName} key={index}/>
)
})}
</Tabs>
<ArticleList data={list}/>
</>
)
}
export default ArticleTabs

View File

@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
import { Swiper } from '@nutui/nutui-react-taro'
import {CmsAd} from "@/api/cms/cmsAd/model";
import {Image} from '@nutui/nutui-react-taro'
import {getCmsAd} from "@/api/cms/cmsAd";
const MyPage = () => {
const [item, setItem] = useState<CmsAd>()
const reload = () => {
getCmsAd(439).then(data => {
setItem(data)
})
}
useEffect(() => {
reload()
}, [])
return (
<>
<Swiper defaultValue={0} height={item?.height} indicator style={{ height: item?.height + 'px', display: 'none' }}>
{item?.imageList?.map((item) => (
<Swiper.Item key={item}>
<Image width="100%" height="100%" src={item.url} mode={'scaleToFill'} lazyLoad={false} style={{ height: item.height + 'px' }} />
</Swiper.Item>
))}
</Swiper>
</>
)
}
export default MyPage

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '文章列表',
navigationBarTextStyle: 'black'
})

View File

View File

@@ -0,0 +1,71 @@
import Taro from '@tarojs/taro'
import {useShareAppMessage} from "@tarojs/taro"
import {Loading} from '@nutui/nutui-react-taro'
import {useEffect, useState} from "react"
import {useRouter} from '@tarojs/taro'
import {getCmsNavigation, listCmsNavigation} from "@/api/cms/cmsNavigation";
import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
import {pageCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model";
import ArticleList from './components/ArticleList'
import ArticleTabs from "./components/ArticleTabs";
import './index.scss'
function Category() {
const {params} = useRouter();
const [categoryId, setCategoryId] = useState<number>(0)
const [category, setCategory] = useState<CmsNavigation[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [nav, setNav] = useState<CmsNavigation>()
const [list, setList] = useState<CmsArticle[]>([])
const reload = async () => {
// 1.加载远程数据
const id = Number(params.id || 4328)
const nav = await getCmsNavigation(id)
const categoryList = await listCmsNavigation({parentId: id})
const shopGoods = await pageCmsArticle({categoryId: id})
// 2.赋值
setCategoryId(id)
setNav(nav)
setList(shopGoods?.list || [])
setCategory(categoryList)
Taro.setNavigationBarTitle({
title: `${nav?.categoryName}`
})
};
useEffect(() => {
reload().then(() => {
setLoading(false)
})
}, []);
useShareAppMessage(() => {
return {
title: `${nav?.categoryName}_时里院子市集`,
path: `/shop/category/index?id=${categoryId}`,
success: function () {
console.log('分享成功');
},
fail: function () {
console.log('分享失败');
}
};
});
if (loading) {
return (
<Loading className={'px-2'}></Loading>
)
}
if(category.length > 0){
return <ArticleTabs data={category}/>
}
return <ArticleList data={list}/>
}
export default Category

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '文章详情'
})

View File

View File

@@ -0,0 +1,53 @@
import Taro from '@tarojs/taro'
import {useEffect, useState} from 'react'
import {useRouter} from '@tarojs/taro'
import {Loading} from '@nutui/nutui-react-taro'
import {View, RichText} from '@tarojs/components'
import {wxParse} from "@/utils/common";
import {getCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model"
import Line from "@/components/Gap";
import './index.scss'
function Detail() {
const {params} = useRouter();
const [loading, setLoading] = useState<boolean>(true)
// 文章详情
const [item, setItem] = useState<CmsArticle>()
const reload = async () => {
const item = await getCmsArticle(Number(params.id))
if (item) {
item.content = wxParse(item.content)
setItem(item)
Taro.setNavigationBarTitle({
title: `${item?.categoryName}`
})
}
}
useEffect(() => {
reload().then(() => {
setLoading(false)
});
}, []);
if (loading) {
return (
<Loading className={'px-2'}></Loading>
)
}
return (
<div className={'bg-white'}>
<div className={'p-4 font-bold text-lg'}>{item?.title}</div>
<div className={'text-gray-400 text-sm px-4 '}>{item?.createTime}</div>
<View className={'content p-4'}>
<RichText nodes={item?.content}/>
</View>
<Line height={44}/>
</div>
)
}
export default Detail

View File

@@ -1,31 +1,123 @@
import { useEffect, useState } from 'react'
import { Swiper } from '@nutui/nutui-react-taro'
import {useEffect, useState} from 'react'
import {View} from '@tarojs/components'
import {Swiper} from '@nutui/nutui-react-taro'
import {CmsAd} from "@/api/cms/cmsAd/model";
import {Image} from '@nutui/nutui-react-taro'
import {getCmsAd} from "@/api/cms/cmsAd";
import {getCmsAdByCode} from "@/api/cms/cmsAd";
import navTo from "@/utils/common";
import {ShopGoods} from "@/api/shop/shopGoods/model";
import {listShopGoods} from "@/api/shop/shopGoods";
const MyPage = () => {
const [item, setItem] = useState<CmsAd>()
const reload = () => {
getCmsAd(439).then(data => {
setItem(data)
const [carouselData, setCarouselData] = useState<CmsAd>()
// const [hotToday, setHotToday] = useState<CmsAd>()
// const [groupBuy, setGroupBuy] = useState<CmsAd>()
const [hotGoods, setHotGoods] = useState<ShopGoods[]>([])
// 加载数据
const loadData = () => {
// 轮播图
getCmsAdByCode('flash').then(data => {
setCarouselData(data)
})
// 今日热卖素材(上层图片)
// getCmsAd(444).then(data => {
// setHotToday(data)
// })
// 社区拼团素材(下层图片)
// getCmsAd(445).then(data => {
// setGroupBuy(data)
// })
// 今日热卖
listShopGoods({categoryId: 4424, limit: 2}).then(data => {
setHotGoods(data)
})
}
useEffect(() => {
reload()
loadData()
}, [])
// 轮播图高度默认200px
const carouselHeight = carouselData?.height || 200;
return (
<>
<Swiper defaultValue={0} height={item?.height} indicator style={{ height: item?.height + 'px', display: 'none' }}>
{item?.imageList?.map((item) => (
<Swiper.Item key={item}>
<Image width="100%" height="100%" src={item.url} mode={'scaleToFill'} lazyLoad={false} style={{ height: item.height + 'px' }} />
</Swiper.Item>
))}
</Swiper>
</>
<View className="flex p-2 justify-between" style={{height: `${carouselHeight}px`}}>
{/* 左侧轮播图区域 */}
<View style={{width: '50%', height: '100%'}}>
<Swiper
defaultValue={0}
height={carouselHeight}
indicator
style={{height: `${carouselHeight}px`}}
>
{carouselData?.imageList?.map((img, index) => (
<Swiper.Item key={index}>
<Image
width="100%"
height="100%"
src={img.url}
mode={'scaleToFill'}
onClick={() => navTo(`${img.path}`)}
lazyLoad={false}
style={{height: `${carouselHeight}px`, borderRadius: '4px'}}
/>
</Swiper.Item>
))}
</Swiper>
</View>
{/* 右侧上下图片区域 - 从API获取数据 */}
<View className="flex flex-col" style={{width: '50%', height: '100%'}}>
{/* 上层图片 - 使用今日热卖素材 */}
<View className={'ml-2 bg-white rounded-lg'}>
<View className={'px-3 my-2 font-bold text-sm'}></View>
<View className={'px-3 flex'} style={{
height: '110px'
}}>
{
hotGoods.map(item => (
<View className={'item flex flex-col mr-4'}>
<Image
width={70}
height={70}
src={item.image}
mode={'scaleToFill'}
lazyLoad={false}
style={{
borderRadius: '4px'
}}
onClick={() => navTo('/shop/category/index?id=4424')}
/>
<View className={'text-xs py-2'}>¥{item.price}</View>
</View>
))
}
</View>
</View>
{/* 下层图片 - 使用社区拼团素材 */}
<View className={'ml-2 bg-white rounded-lg mt-3'}>
<View className={'px-3 my-2 font-bold text-sm'}></View>
<View className={'rounded-lg px-3 pb-3'}>
<Image
width={'100%'}
height={100}
src={'https://oss.wsdns.cn/20250919/941c99899e694a7798cab3bb28f1f238.png?x-oss-process=image/resize,m_fixed,w_750/quality,Q_90'}
mode={'scaleToFill'}
lazyLoad={false}
style={{
borderRadius: '4px'
}}
onClick={() => navTo('cms/detail/index?id=10109')}
/>
</View>
</View>
</View>
</View>
)
}
export default MyPage

View File

@@ -1,21 +1,56 @@
import {useEffect, useState} from "react";
import {Image} from '@nutui/nutui-react-taro'
import {Image, Swiper, SwiperItem, Empty} from '@nutui/nutui-react-taro'
import {Share} from '@nutui/icons-react-taro'
import {View, Text} from '@tarojs/components';
import Taro, {useShareAppMessage, useShareTimeline} from "@tarojs/taro";
import Taro from "@tarojs/taro";
import {Tabs} from '@nutui/nutui-react-taro'
import {ShopGoods} from "@/api/shop/shopGoods/model";
import {pageShopGoods} from "@/api/shop/shopGoods";
import './BestSellers.scss'
const BestSellers = () => {
const [tab1value, setTab1value] = useState<string | number>('0')
const [list, setList] = useState<ShopGoods[]>([])
const [goods, setGoods] = useState<ShopGoods>()
const [goods, setGoods] = useState<ShopGoods | null>(null)
// 轮播图固定高度,可根据需求调整
const SWIPER_HEIGHT = 180;
const reload = () => {
pageShopGoods({}).then(res => {
setList(res?.list || []);
})
const processGoodsItem = (item: ShopGoods) => {
const pics: string[] = [];
// 添加主图
if (item.image) {
pics.push(item.image);
}
// 处理附加图片
if (item.files) {
try {
// 解析文件字符串为对象
const files = typeof item.files === "string"
? JSON.parse(item.files)
: item.files;
// 收集所有图片URL
Object.values(files).forEach(file => {
if (file?.url) {
pics.push(file.url);
}
});
} catch (error) {
console.error('解析文件失败:', error);
}
}
// 返回新对象,避免直接修改原对象
return {...item, pics};
};
// 处理商品列表
const goods = (res?.list || []).map(processGoodsItem);
setList(goods);
}).catch(err => {
console.error('获取商品列表失败:', err);
});
}
// 处理分享点击
@@ -24,14 +59,12 @@ const BestSellers = () => {
// 显示分享选项菜单
Taro.showActionSheet({
itemList: ['分享给好友', '分享到朋友圈'],
itemList: ['分享给好友'],
success: (res) => {
if (res.tapIndex === 0) {
// 分享给好友 - 触发转发
Taro.showShareMenu({
withShareTicket: true,
success: () => {
// 提示用户点击右上角分享
Taro.showToast({
title: '请点击右上角分享给好友',
icon: 'none',
@@ -39,13 +72,6 @@ const BestSellers = () => {
});
}
});
} else if (res.tapIndex === 1) {
// 分享到朋友圈
Taro.showToast({
title: '请点击右上角分享到朋友圈',
icon: 'none',
duration: 2000
});
}
},
fail: (err) => {
@@ -55,87 +81,135 @@ const BestSellers = () => {
}
useEffect(() => {
reload()
}, [])
reload();
}, []);
// 分享给好友
useShareAppMessage(() => {
// 配置分享内容
Taro.useShareAppMessage(() => {
if (goods) {
return {
title: goods.name,
path: `/shop/goodsDetail/index?id=${goods.goodsId}`,
imageUrl: goods.image || ''
};
}
return {
title: goods?.name || '精选商品',
path: `/shop/goodsDetail/index?id=${goods?.goodsId}`,
imageUrl: goods?.image, // 分享图片
success: function (res: any) {
console.log('分享成功', res);
Taro.showToast({
title: '分享成功',
icon: 'success',
duration: 2000
});
},
fail: function (res: any) {
console.log('分享失败', res);
Taro.showToast({
title: '分享失败',
icon: 'none',
duration: 2000
});
}
};
});
// 分享到朋友圈
useShareTimeline(() => {
return {
title: `${goods?.name || '精选商品'} - 网宿小店`,
path: `/shop/goodsDetail/index?id=${goods?.goodsId}`,
imageUrl: goods?.image
title: '热销商品',
path: '/pages/index/index'
};
});
return (
<>
<View className={'py-3'}>
<View className={'flex flex-col justify-between items-center rounded-lg px-2'}>
{list?.map((item, index) => {
return (
<View key={index} className={'flex flex-col rounded-lg bg-white shadow-sm w-full mb-5'}>
<Image src={item.image} mode={'aspectFit'} lazyLoad={false}
radius="10px 10px 0 0" height="180"
onClick={() => Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}/>
<View className={'flex flex-col p-2 rounded-lg'}>
<View>
<View className={'car-no text-sm'}>{item.name}</View>
<View className={'flex justify-between text-xs py-1'}>
<Text className={'text-orange-500'}>{item.comments}</Text>
<Text className={'text-gray-400'}> {item.sales}</Text>
</View>
<View className={'flex justify-between items-center py-2'}>
<View className={'flex text-red-500 text-xl items-baseline'}>
<Text className={'text-xs'}></Text>
<Text className={'font-bold text-2xl'}>{item.price}</Text>
</View>
<View className={'buy-btn'}>
<View className={'cart-icon flex items-center'}>
<View
className={'flex flex-col justify-center items-center text-white px-3 gap-1 text-nowrap whitespace-nowrap cursor-pointer'}
onClick={() => handleShare(item)}
>
<Share size={20}/>
</View>
</View>
<Text className={'text-white pl-4 pr-5'}
onClick={() => Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}>
</Text>
<View className={'py-3'}>
<View className={'flex flex-col justify-between items-center rounded-lg px-2'}>
<Tabs
value={tab1value}
className={'w-full'}
onChange={(value) => {
setTab1value(value)
}}
style={{
backgroundColor: '#fff',
}}
activeType="smile"
>
<Tabs.TabPane title="今日主推">
</Tabs.TabPane>
<Tabs.TabPane title="即将到期">
</Tabs.TabPane>
<Tabs.TabPane title="活动预告">
</Tabs.TabPane>
</Tabs>
{tab1value == '0' && list?.map((item) => (
<View
key={item.goodsId || item.id} // 使用商品唯一ID作为key
className={'flex flex-col rounded-lg bg-white shadow-sm w-full mb-5'}
>
{/* 轮播图组件 */}
{item.pics && item.pics.length > 0 ? (
<Swiper
defaultValue={0}
height={SWIPER_HEIGHT}
indicator
className="swiper-container"
autoPlay
interval={3000}
>
{item.pics.map((pic, picIndex) => (
<SwiperItem key={picIndex}>
<Image
radius="12px 12px 0 0"
height={SWIPER_HEIGHT}
src={pic}
mode={'aspectFill'} // 使用aspectFill保持比例并填充容器
lazyLoad
onClick={() => Taro.navigateTo({
url: `/shop/goodsDetail/index?id=${item.goodsId}`
})}
className="swiper-image"
/>
</SwiperItem>
))}
</Swiper>
) : (
// 没有图片时显示占位图
<View className="no-image-placeholder" style={{height: `${SWIPER_HEIGHT}px`}}>
<Text className="placeholder-text"></Text>
</View>
)}
<View className={'flex flex-col p-2 rounded-lg'}>
<View>
<View className={'car-no text-sm'}>{item.name}</View>
<View className={'flex justify-between text-xs py-1'}>
<Text className={'text-orange-500'}>{item.comments}</Text>
<Text className={'text-gray-400'}> {item.sales}</Text>
</View>
<View className={'flex justify-between items-center py-2'}>
<View className={'flex text-red-500 text-xl items-baseline'}>
<Text className={'text-xs'}></Text>
<Text className={'font-bold text-2xl'}>{item.price}</Text>
</View>
<View className={'buy-btn'}>
<View className={'cart-icon flex items-center hidden'}>
<View
className={'flex flex-col justify-center items-center text-white px-3 gap-1 text-nowrap whitespace-nowrap cursor-pointer'}
onClick={() => handleShare(item)}
>
<Share size={20}/>
</View>
</View>
<Text
className={'text-white pl-5 pr-5'}
onClick={() => Taro.navigateTo({
url: `/shop/goodsDetail/index?id=${item.goodsId}`
})}
>
</Text>
</View>
</View>
</View>
)
})}
</View>
</View>
</View>
))}
{
tab1value == '1' && <Empty description="暂无相关商品" style={{
background: 'transparent',
}}/>
}
{
tab1value == '2' && <Empty description="暂无相关商品" style={{
background: 'transparent',
}}/>
}
</View>
</>
</View>
)
}
export default BestSellers

View File

@@ -8,7 +8,7 @@ import {TenantId} from "@/config/app";
import {getOrganization} from "@/api/system/organization";
import {myUserVerify} from "@/api/system/userVerify";
import { useShopInfo } from '@/hooks/useShopInfo';
import {handleInviteRelation} from "@/utils/invite";
import {handleInviteRelation, getStoredInviteParams} from "@/utils/invite";
import {View,Text} from '@tarojs/components'
import MySearch from "./MySearch";
import './Header.scss';
@@ -88,6 +88,22 @@ const Header = (props: any) => {
/* 获取用户手机号 */
const handleGetPhoneNumber = ({detail}: {detail: {code?: string, encryptedData?: string, iv?: string}}) => {
const {code, encryptedData, iv} = detail
// 防重复登录检查
const loginKey = 'login_in_progress'
const loginInProgress = Taro.getStorageSync(loginKey)
if (loginInProgress && Date.now() - loginInProgress < 5000) { // 5秒内防重
return
}
// 标记登录开始
Taro.setStorageSync(loginKey, Date.now())
// 获取存储的邀请参数
const inviteParams = getStoredInviteParams()
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0
Taro.login({
success: function () {
if (code) {
@@ -99,7 +115,7 @@ const Header = (props: any) => {
encryptedData,
iv,
notVerifyPhone: true,
refereeId: 0,
refereeId: refereeId, // 使用解析出的推荐人ID
sceneType: 'save_referee',
tenantId: TenantId
},
@@ -108,6 +124,9 @@ const Header = (props: any) => {
TenantId
},
success: async function (res) {
// 清除登录防重标记
Taro.removeStorageSync('login_in_progress')
if (res.data.code == 1) {
Taro.showToast({
title: res.data.message,
@@ -124,14 +143,7 @@ const Header = (props: any) => {
// 处理邀请关系
if (res.data.data.user?.userId) {
try {
const inviteSuccess = await handleInviteRelation(res.data.data.user.userId)
if (inviteSuccess) {
Taro.showToast({
title: '邀请关系建立成功',
icon: 'success',
duration: 2000
})
}
await handleInviteRelation(res.data.data.user.userId)
} catch (error) {
console.error('处理邀请关系失败:', error)
}
@@ -141,6 +153,10 @@ const Header = (props: any) => {
Taro.reLaunch({
url: '/pages/index/index'
})
},
fail: function() {
// 清除登录防重标记
Taro.removeStorageSync('login_in_progress')
}
})
} else {
@@ -157,9 +173,10 @@ const Header = (props: any) => {
return (
<>
<View className={'fixed top-0 header-bg'} style={{
height: !props.stickyStatus ? '180px' : '148px',
height: !props.stickyStatus ? '180px' : 'auto',
paddingBottom: '12px'
}}>
<MySearch/>
<MySearch statusBarHeight={statusBarHeight} />
{/*{!props.stickyStatus && <MySearch done={reload}/>}*/}
</View>
<NavBar
@@ -190,6 +207,7 @@ const Header = (props: any) => {
<TriangleDown className={'text-white'} size={9}/>
</View>
)}>
{/*<QRLoginButton />*/}
</NavBar>
</>
)

View File

@@ -4,7 +4,7 @@ import {Input, Radio, Button} from '@nutui/nutui-react-taro'
import {TenantId} from "@/config/app";
import './login.scss';
import {saveStorageByLoginUser} from "@/utils/server";
import {handleInviteRelation} from "@/utils/invite";
import {handleInviteRelation, getStoredInviteParams} from "@/utils/invite";
// 微信获取手机号回调参数类型
interface GetPhoneNumberDetail {
@@ -40,6 +40,11 @@ const Login = (props: LoginProps) => {
/* 获取用户手机号 */
const handleGetPhoneNumber = ({detail}: GetPhoneNumberEvent) => {
const {code, encryptedData, iv} = detail
// 获取存储的邀请参数
const inviteParams = getStoredInviteParams()
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0
Taro.login({
success: function () {
if (code) {
@@ -51,7 +56,7 @@ const Login = (props: LoginProps) => {
encryptedData,
iv,
notVerifyPhone: true,
refereeId: 0,
refereeId: refereeId, // 使用解析出的推荐人ID
sceneType: 'save_referee',
tenantId: TenantId
},

View File

@@ -4,7 +4,7 @@ import {useState} from "react";
import Taro from '@tarojs/taro';
import { goTo } from '@/utils/navigation';
function MySearch() {
function MySearch(props: any) {
const [keywords, setKeywords] = useState<string>('')
const onKeywords = (keywords: string) => {
@@ -39,7 +39,7 @@ function MySearch() {
background: '#ffffff',
padding: '0 5px',
borderRadius: '20px',
marginTop: '100px',
marginTop: `${props.statusBarHeight + 50}px`,
}}
>
<Search size={18} className={'ml-2 text-gray-400'}/>

View File

@@ -1,7 +1,7 @@
import Header from './Header';
import BestSellers from './BestSellers';
import Taro from '@tarojs/taro';
import {useShareAppMessage, useShareTimeline} from "@tarojs/taro"
import {useShareAppMessage} from "@tarojs/taro"
import {useEffect, useState} from "react";
import {getShopInfo} from "@/api/layout";
import {Sticky} from '@nutui/nutui-react-taro'
@@ -16,22 +16,28 @@ function Home() {
// 吸顶状态
const [stickyStatus, setStickyStatus] = useState<boolean>(false)
useShareTimeline(() => {
return {
title: '网宿小店 - 网宿软件',
path: `/pages/index/index`
};
});
useShareAppMessage(() => {
// 获取当前用户ID用于生成邀请链接
const userId = Taro.getStorageSync('UserId');
return {
title: '网宿小店 - 网宿软件',
path: `/pages/index/index`,
path: userId ? `/pages/index/index?inviter=${userId}&source=share&t=${Date.now()}` : `/pages/index/index`,
success: function () {
console.log('分享成功');
console.log('首页分享成功');
Taro.showToast({
title: '分享成功',
icon: 'success',
duration: 2000
});
},
fail: function () {
console.log('分享失败');
console.log('首页分享失败');
Taro.showToast({
title: '分享失败',
icon: 'none',
duration: 2000
});
}
};
});
@@ -89,18 +95,32 @@ function Home() {
})
// 检查是否有待处理的邀请关系
// 检查是否有待处理的邀请关系 - 异步处理,不阻塞页面加载
if (hasPendingInvite()) {
console.log('检测到待处理的邀请关系')
// 延迟处理,确保用户信息已加载
// 延迟处理,确保用户信息已加载,并设置超时保护
setTimeout(async () => {
try {
const success = await checkAndHandleInviteRelation()
// 设置超时保护,避免长时间等待
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('邀请关系处理超时')), 8000)
);
const invitePromise = checkAndHandleInviteRelation();
const success = await Promise.race([invitePromise, timeoutPromise]);
if (success) {
console.log('首页邀请关系处理成功')
}
} catch (error) {
console.error('首页邀请关系处理失败:', error)
// 邀请关系处理失败不应该影响页面正常显示
// 可以选择清除邀请参数,避免重复尝试
const errorMessage = error instanceof Error ? error.message : String(error)
if (errorMessage?.includes('超时')) {
console.log('邀请关系处理超时,清除邀请参数')
// 可以选择清除邀请参数或稍后重试
}
}
}, 2000)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '菜单'
})

81
src/pages/menu/menu.tsx Normal file
View File

@@ -0,0 +1,81 @@
import {useEffect, useState} from "react";
import Taro from '@tarojs/taro';
import {pageCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model";
import {NavBar, Cell} from '@nutui/nutui-react-taro';
import {Text} from '@tarojs/components';
import {ArrowRight, ImageRectangle, Coupon, Follow} from '@nutui/icons-react-taro'
import navTo from "@/utils/common";
/**
* 文章终极列表
* @constructor
*/
const Menu = () => {
const [statusBarHeight, setStatusBarHeight] = useState<number>()
const [loading, setLoading] = useState<boolean>(false)
const [list, setList] = useState<CmsArticle[]>()
const reload = async () => {
setLoading(true)
const article = await pageCmsArticle({categoryId: 4289, status: 0})
if (article) {
setList(article?.list)
setLoading(false)
}
}
useEffect(() => {
Taro.getSystemInfo({
success: (res) => {
setStatusBarHeight(res.statusBarHeight)
},
})
reload().then(() => {
console.log('初始化完成')
})
}, [])
return (
<>
{loading && (<div></div>)}
<NavBar
fixed={false}
style={{marginTop: `${statusBarHeight}px`, backgroundColor: 'transparent'}}
onBackClick={() => {
}}
>
<span></span>
</NavBar>
{list && (
<>
<Cell title={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<ImageRectangle size={18}/>
<Text className={'pl-3'} style={{fontSize: '16px'}}></Text>
</div>
} extra={<ArrowRight color="#cccccc" size={18}/>}/>
<Cell
title={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<Coupon size={18}/>
<span className={'pl-3'} style={{fontSize: '16px'}}></span>
</div>
}
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => {
navTo('/shop/shopArticle/index', true)
}}
/>
<Cell title={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<Follow size={18}/>
<span className={'pl-3'} style={{fontSize: '16px'}}></span>
</div>
} extra={<ArrowRight color="#cccccc" size={18}/>}/>
</>
)}
</>
)
}
export default Menu

View File

@@ -6,7 +6,7 @@ import {useUser} from '@/hooks/useUser'
import {useEffect} from "react";
import {useDealerUser} from "@/hooks/useDealerUser";
const UserCell = () => {
const IsDealer = () => {
const {isSuperAdmin} = useUser();
const {dealerUser} = useDealerUser()
@@ -20,7 +20,7 @@ const UserCell = () => {
if (isSuperAdmin()) {
return (
<>
<View className={'px-4'}>
<View className={'px-4'} style={{ marginTop: '8px', position: 'relative', zIndex: 25 }}>
<Cell
className="nutui-cell-clickable"
style={{
@@ -46,7 +46,7 @@ const UserCell = () => {
if (dealerUser) {
return (
<>
<View className={'px-4'}>
<View className={'px-4'} style={{ marginTop: '8px', position: 'relative', zIndex: 25 }}>
<Cell
className="nutui-cell-clickable"
style={{
@@ -56,7 +56,7 @@ const UserCell = () => {
<View style={{display: 'inline-flex', alignItems: 'center'}}>
<Reward className={'text-orange-100 '} size={16}/>
<Text style={{fontSize: '16px'}}
className={'pl-3 text-orange-100 font-medium'}></Text>
className={'pl-3 text-orange-100 font-medium'}></Text>
{/*<Text className={'text-white opacity-80 pl-3'}>门店核销</Text>*/}
</View>
}
@@ -73,7 +73,7 @@ const UserCell = () => {
*/
return (
<>
<View className={'px-4'}>
<View className={'px-4'} style={{ marginTop: '8px', position: 'relative', zIndex: 25 }}>
<Cell
className="nutui-cell-clickable"
style={{
@@ -82,19 +82,15 @@ const UserCell = () => {
title={
<View style={{display: 'inline-flex', alignItems: 'center'}}>
<Reward className={'text-orange-100 '} size={16}/>
<Text style={{fontSize: '16px'}} className={'pl-3 text-orange-100 font-medium'}></Text>
<Text style={{fontSize: '16px'}} className={'pl-3 text-orange-100 font-medium'}>VIP</Text>
<Text className={'text-white opacity-80 pl-3'}></Text>
</View>
}
extra={
<>
<Text className={'text-white opacity-80 px-3'}></Text>
<ArrowRight color="#cccccc" size={18}/>
</>
}
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => navTo('/dealer/apply/add', true)}
/>
</View>
</>
)
}
export default UserCell
export default IsDealer

View File

@@ -1,28 +1,21 @@
import {Button} from '@nutui/nutui-react-taro'
import {Avatar, Tag} from '@nutui/nutui-react-taro'
import {View, Text} from '@tarojs/components'
import {Scan} from '@nutui/icons-react-taro';
import {Avatar, Tag, Space, Button} from '@nutui/nutui-react-taro'
import {View, Text, Image} from '@tarojs/components'
import {getUserInfo, getWxOpenId} from '@/api/layout';
import Taro from '@tarojs/taro';
import {useEffect, useState} from "react";
import {useEffect, useState, forwardRef, useImperativeHandle} from "react";
import {User} from "@/api/system/user/model";
import navTo from "@/utils/common";
import {TenantId} from "@/config/app";
import {getMyAvailableCoupons} from "@/api/shop/shopUserCoupon";
import {useUser} from "@/hooks/useUser";
import {useUserData} from "@/hooks/useUserData";
import {getStoredInviteParams} from "@/utils/invite";
import UnifiedQRButton from "@/components/UnifiedQRButton";
function UserCard() {
const {
isAdmin
} = useUser();
const { data, refresh } = useUserData()
const UserCard = forwardRef<any, any>((_, ref) => {
const {data, refresh} = useUserData()
const {getDisplayName, getRoleName} = useUser();
const [IsLogin, setIsLogin] = useState<boolean>(false)
const [userInfo, setUserInfo] = useState<User>()
const [couponCount, setCouponCount] = useState(0)
const [pointsCount, setPointsCount] = useState(0)
const [giftCount, setGiftCount] = useState(0)
// 下拉刷新
const handleRefresh = async () => {
@@ -33,6 +26,11 @@ function UserCard() {
})
}
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
handleRefresh
}))
useEffect(() => {
// Taro.getSetting获取用户的当前设置。返回值中只会出现小程序已经向用户请求过的权限。
Taro.getSetting({
@@ -50,33 +48,6 @@ function UserCard() {
});
}, []);
const loadUserStats = (userId: number) => {
// 加载优惠券数量
getMyAvailableCoupons()
.then((coupons: any) => {
setCouponCount(coupons?.length || 0)
})
.catch((error: any) => {
console.error('Coupon count error:', error)
})
// 加载积分数量
console.log(userId)
setPointsCount(0)
// getUserPointsStats(userId)
// .then((res: any) => {
// setPointsCount(res.currentPoints || 0)
// })
// .catch((error: any) => {
// console.error('Points stats error:', error)
// })
// 加载礼品劵数量
setGiftCount(0)
// pageUserGiftLog({userId, page: 1, limit: 1}).then(res => {
// setGiftCount(res.count || 0)
// })
}
const reload = () => {
Taro.getUserInfo({
success: (res) => {
@@ -92,11 +63,6 @@ function UserCard() {
setIsLogin(true);
Taro.setStorageSync('UserId', data.userId)
// 加载用户统计数据
if (data.userId) {
loadUserStats(data.userId)
}
// 获取openId
if (!data.openid) {
Taro.login({
@@ -149,8 +115,13 @@ function UserCard() {
};
/* 获取用户手机号 */
const handleGetPhoneNumber = ({detail}: {detail: {code?: string, encryptedData?: string, iv?: string}}) => {
const handleGetPhoneNumber = ({detail}: { detail: { code?: string, encryptedData?: string, iv?: string } }) => {
const {code, encryptedData, iv} = detail
// 获取存储的邀请参数
const inviteParams = getStoredInviteParams()
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0
Taro.login({
success: function () {
if (code) {
@@ -162,7 +133,7 @@ function UserCard() {
encryptedData,
iv,
notVerifyPhone: true,
refereeId: 0,
refereeId: refereeId, // 使用解析出的推荐人ID
sceneType: 'save_referee',
tenantId: TenantId
},
@@ -194,74 +165,114 @@ function UserCard() {
}
return (
<View className={'header-bg pt-20'}>
<View className={'pt-20'}>
<View className={'p-4'}>
<View
className={'user-card w-full flex flex-col justify-around rounded-xl shadow-sm'}
style={{
background: 'linear-gradient(to bottom, #ffffff, #ffffff)', // 这种情况建议使用类名来控制样式(引入外联样式)
// width: '720rpx',
// margin: '10px auto 0px auto',
height: '170px',
// borderRadius: '22px 22px 0 0',
}}
>
<View className={'user-card-header flex w-full justify-between items-center pt-4'}>
<View className={'flex items-center mx-4'}>
{
IsLogin ? (
<Avatar size="large" src={userInfo?.avatar} shape="round"/>
) : (
<Button className={'text-black'} open-type="getPhoneNumber" onGetPhoneNumber={handleGetPhoneNumber}>
<Avatar size="large" src={userInfo?.avatar} shape="round"/>
</Button>
)
}
<View className={'user-info flex flex-col px-2'}>
<View className={'py-1 text-black font-bold'}>{getDisplayName()}</View>
{IsLogin ? (
<View className={'grade text-xs py-1'}>
<Tag type="success" round>
<Text className={'p-1'}>
{getRoleName()}
</Text>
</Tag>
</View>
) : ''}
{/* 使用相对定位容器,让个人资料图片可以绝对定位在右上角 */}
<View className="relative z-20">
<View
className={'user-card w-full flex flex-col justify-around rounded-xl'}
style={{
background: 'linear-gradient(to bottom, #ffffff, #ffffff)',
height: '170px',
}}
>
<View className={'user-card-header flex w-full justify-between items-center pt-4'}>
<View className={'flex items-center mx-4'}>
{
IsLogin ? (
<Avatar size="large"
src={userInfo?.avatar || 'https://oss.wsdns.cn/20250623/62f830b85edb4a7293b8948c25e6f987.jpeg'}
shape="round"/>
) : (
<Button className={'text-black'} open-type="getPhoneNumber"
onGetPhoneNumber={handleGetPhoneNumber}>
<Avatar size="large"
src={userInfo?.avatar || 'https://oss.wsdns.cn/20250623/62f830b85edb4a7293b8948c25e6f987.jpeg'}
shape="round"/>
</Button>
)
}
<View className={'user-info flex flex-col px-2'}>
<View className={'py-1 text-black font-bold'}>{getDisplayName()}</View>
{IsLogin ? (
<View className={'grade text-xs py-1'}>
<Tag type="success" round>
<Text className={'p-1'}>
{getRoleName()}
</Text>
</Tag>
</View>
) : ''}
</View>
</View>
<Space style={{
marginTop: '30px',
marginRight: '10px'
}}>
{/*统一扫码入口 - 支持登录和核销*/}
<UnifiedQRButton
text="扫码"
size="small"
onSuccess={(result) => {
console.log('统一扫码成功:', result);
// 根据扫码类型给出不同的提示
if (result.type === 'verification') {
// 核销成功,可以显示更多信息或跳转到详情页
Taro.showModal({
title: '核销成功',
content: `已成功核销的品类:${result.data.goodsName || '礼品卡'},面值¥${result.data.faceValue}`
});
}
}}
onError={(error) => {
console.error('统一扫码失败:', error);
}}
/>
</Space>
</View>
<View className={'flex justify-around mt-1'}>
<View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/wallet/wallet', true)}>
<Text className={'text-sm text-gray-500'}></Text>
<Text className={'text-xl'}>{data?.balance || '0.00'}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}>
<Text className={'text-sm text-gray-500'}></Text>
<Text className={'text-xl'}>{data?.points || 0}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/coupon/index', true)}>
<Text className={'text-sm text-gray-500'}></Text>
<Text className={'text-xl'}>{data?.coupons || 0}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/gift/index', true)}>
<Text className={'text-sm text-gray-500'}></Text>
<Text className={'text-xl'}>{data?.giftCards || 0}</Text>
</View>
</View>
{isAdmin() && <Scan onClick={() => navTo('/user/store/verification', true)} />}
<View className={'mr-4 text-sm px-3 py-1 text-black border-gray-400 border-solid border-2 rounded-3xl'}
onClick={() => navTo('/user/profile/profile', true)}>
{'个人资料'}
</View>
</View>
<View className={'flex justify-around mt-1'}>
<View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/wallet/wallet', true)}>
<Text className={'text-sm text-gray-500'}></Text>
<Text className={'text-xl'}>{data?.balance || '0.00'}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}>
<Text className={'text-sm text-gray-500'}></Text>
<Text className={'text-xl'}>{data?.points || 0}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/coupon/index', true)}>
<Text className={'text-sm text-gray-500'}></Text>
<Text className={'text-xl'}>{data?.coupons || 0}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/gift/index', true)}>
<Text className={'text-sm text-gray-500'}></Text>
<Text className={'text-xl'}>{data?.giftCards || 0}</Text>
</View>
{/* 个人资料图片,定位在右上角 */}
<View
className="absolute top-0 right-0 overflow-hidden z-30"
style={{
borderRadius: "0 0.75rem 0 0"
}}
onClick={() => navTo('/user/profile/profile', true)}
>
<Image
src="https://oss.wsdns.cn/20250913/7c3de38b377344b89131aba40214f63f.png"
style={{
width: "200rpx"
}}
mode="widthFix"
/>
</View>
</View>
</View>
</View>
)
}
})
export default UserCard;

View File

@@ -26,7 +26,7 @@ const UserCell = () => {
return (
<>
<View className={'px-4'}>
<View className={'px-4'} style={{ marginTop: '8px', position: 'relative', zIndex: 20 }}>
<Cell.Group divider={true} description={
<View style={{display: 'inline-flex', alignItems: 'center'}}>

View File

@@ -0,0 +1,145 @@
import {Grid, ConfigProvider} from '@nutui/nutui-react-taro'
import navTo from "@/utils/common";
import Taro from '@tarojs/taro'
import {View, Button} from '@tarojs/components'
import {
ShieldCheck,
Location,
Tips,
Ask,
// Dongdong,
People,
// AfterSaleService,
Logout,
ShoppingAdd,
Service
} from '@nutui/icons-react-taro'
import {useUser} from "@/hooks/useUser";
const UserCell = () => {
const {logoutUser} = useUser();
const onLogout = () => {
Taro.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: function (res) {
if (res.confirm) {
// 使用 useUser hook 的 logoutUser 方法
logoutUser();
Taro.reLaunch({
url: '/pages/index/index'
})
}
}
})
}
return (
<>
<View className="bg-white mx-4 mt-4 rounded-xl">
<View className="font-semibold text-gray-800 pt-4 pl-4"></View>
<ConfigProvider>
<Grid
columns={4}
className="no-border-grid"
style={{
'--nutui-grid-border-color': 'transparent',
'--nutui-grid-item-border-width': '0px',
border: 'none'
} as React.CSSProperties}
>
<Grid.Item text="企业采购" onClick={() => navTo('/user/poster/poster', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<ShoppingAdd color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
{/* 修改联系我们为微信客服 */}
<Grid.Item text="联系我们">
<Button
open-type="contact"
className="w-full h-full flex flex-col items-center justify-center p-0 bg-transparent"
hover-class="none"
style={{border: 'none'}}
>
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Service color="#67C23A" size="20"/>
</View>
</Button>
</Grid.Item>
<Grid.Item text="收货地址" onClick={() => navTo('/user/address/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Location color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'实名认证'} onClick={() => navTo('/user/userVerify/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<ShieldCheck color="#10b981" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'推广邀请'} onClick={() => navTo('/dealer/team/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<People color="#8b5cf6" size="20"/>
</View>
</View>
</Grid.Item>
{/*<Grid.Item text={'我的邀请码'} onClick={() => navTo('/dealer/qrcode/index', true)}>*/}
{/* <View className="text-center">*/}
{/* <View className="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
{/* <Dongdong color="#f59e0b" size="20"/>*/}
{/* </View>*/}
{/* </View>*/}
{/*</Grid.Item>*/}
{/*<Grid.Item text={'管理中心'} onClick={() => navTo('/admin/index', true)}>*/}
{/* <View className="text-center">*/}
{/* <View className="w-12 h-12 bg-red-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
{/* <AfterSaleService className={'text-red-500'} size="20"/>*/}
{/* </View>*/}
{/* </View>*/}
{/*</Grid.Item>*/}
<Grid.Item text={'常见问题'} onClick={() => navTo('/user/help/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-cyan-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Ask className={'text-cyan-500'} size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'关于我们'} onClick={() => navTo('/user/about/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Tips className={'text-amber-500'} size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'安全退出'} onClick={onLogout}>
<View className="text-center">
<View className="w-12 h-12 bg-pink-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Logout className={'text-pink-500'} size="20"/>
</View>
</View>
</Grid.Item>
</Grid>
</ConfigProvider>
</View>
</>
)
}
export default UserCell

View File

@@ -14,7 +14,7 @@ function UserOrder() {
return (
<>
<View className={'px-4 pb-2'}>
<View className={'px-4 pb-2 z-30 relative'} style={{ marginTop: '8px' }}>
<View
className={'user-card w-full flex flex-col justify-around rounded-xl shadow-sm'}
style={{

Some files were not shown because too many files have changed in this diff Show More