diff --git a/.vercel/project.json b/.vercel/project.json index 752ae2e..a4ff8d7 100644 --- a/.vercel/project.json +++ b/.vercel/project.json @@ -1 +1 @@ -{"projectName":"trae_template-10550_mhk8"} \ No newline at end of file +{"projectName":"trae_template-10584_mhk8"} diff --git a/config/app.ts b/config/app.ts index 7d73acb..8256046 100644 --- a/config/app.ts +++ b/config/app.ts @@ -1,12 +1,14 @@ import { API_BASE_URL } from './env' // 租户ID - 请根据实际情况修改 -export const TenantId = '10550'; +export const TenantId = '10584'; +// 租户名称 +export const TenantName = '桂乐淘'; // 接口地址 - 请根据实际情况修改 export const BaseUrl = API_BASE_URL; // 当前版本 export const Version = 'v3.0.8'; // 版权信息 -export const Copyright = 'WebSoft Inc.'; +export const Copyright = '桂乐淘·购享无界 乐惠万家'; // java -jar CertificateDownloader.jar -k 0kF5OlPr482EZwtn9zGufUcqa7ovgxRL -m 1723321338 -f ./apiclient_key.pem -s 2B933F7C35014A1C363642623E4A62364B34C4EB -o ./ diff --git a/config/env.ts b/config/env.ts index 152eae3..4ec1704 100644 --- a/config/env.ts +++ b/config/env.ts @@ -2,20 +2,21 @@ export const ENV_CONFIG = { // 开发环境 development: { - API_BASE_URL: 'http://127.0.0.1:9200/api', - // API_BASE_URL: 'https://cms-api.websoft.top/api', + // API_BASE_URL: 'http://127.0.0.1:9200/api', + API_BASE_URL: 'https://glt-api.websoft.top/api', APP_NAME: '开发环境', DEBUG: 'true', }, // 生产环境 production: { - API_BASE_URL: 'https://cms-api.websoft.top/api', - APP_NAME: '时里院子市集', + API_BASE_URL: 'https://glt-api.websoft.top/api', + APP_NAME: '桂乐淘', DEBUG: 'false', }, // 测试环境 test: { - API_BASE_URL: 'https://cms-api.s209.websoft.top/api', + // API_BASE_URL: 'http://127.0.0.1:9200/api', + API_BASE_URL: 'https://glt-api.websoft.top/api', APP_NAME: '测试环境', DEBUG: 'true', } diff --git a/docs/DEALER_OPTIMIZATION.md b/docs/DEALER_OPTIMIZATION.md index d6f384a..b52633c 100644 --- a/docs/DEALER_OPTIMIZATION.md +++ b/docs/DEALER_OPTIMIZATION.md @@ -20,7 +20,7 @@ #### 新增功能 - 用户头像和基本信息展示 -- 佣金统计(可提现、冻结中、累计收益) +- 佣金统计(可提现、待使用、累计收益) - 团队统计(一级、二级、三级成员) - 功能导航网格(分销订单、提现申请、我的团队、推广二维码) diff --git a/docs/GRADIENT_DESIGN_GUIDE.md b/docs/GRADIENT_DESIGN_GUIDE.md index 926cbf6..e362e6e 100644 --- a/docs/GRADIENT_DESIGN_GUIDE.md +++ b/docs/GRADIENT_DESIGN_GUIDE.md @@ -35,7 +35,7 @@ dealer: { // 金额相关 money: { available: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', // 可提现 - 绿色 - frozen: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', // 冻结中 - 蓝色 + frozen: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', // 待使用 - 蓝色 total: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' // 累计 - 橙色 } ``` diff --git a/docs/IMPLICIT_ANY_TYPE_FIX.md b/docs/IMPLICIT_ANY_TYPE_FIX.md index c8720f6..f63abda 100644 --- a/docs/IMPLICIT_ANY_TYPE_FIX.md +++ b/docs/IMPLICIT_ANY_TYPE_FIX.md @@ -90,7 +90,7 @@ const handleGetPhoneNumber = ({detail}: {detail: {code?: string, encryptedData?: success: function () { if (code) { Taro.request({ - url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone', + url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone', method: 'POST', data: { code, diff --git a/docs/水票配送订单-后端提示词.md b/docs/水票配送订单-后端提示词.md new file mode 100644 index 0000000..c064ea9 --- /dev/null +++ b/docs/水票配送订单-后端提示词.md @@ -0,0 +1,41 @@ +# 水票配送订单:后端提示词(可直接发给后端) + +## 1) 订单查询(配送员端) +请在 `GET /glt/glt-ticket-order/page` 支持以下筛选,并保证权限隔离: +- `riderId`:只返回该配送员的订单(必要) +- `deliveryStatus`:10待配送、20配送中、30待客户确认、40已完成(必要) +- 排序:建议 `sendTime asc` + `createTime desc`(或给前端一个可控排序字段) + +## 2) 配送流程字段(建议后端落库并回传) +订单表建议确保有以下字段(当前前端已按这些字段做流程判断/展示): +- `riderId/riderName/riderPhone`:配送员信息 +- `deliveryStatus`:10/20/30/40 +- `sendStartTime`:配送员点击“开始配送”的时间 +- `sendEndTime`:配送员点击“确认送达”的时间 +- `sendEndImg`:送达拍照留档图片 URL(可选/必填由后端策略决定) +- `receiveConfirmTime`:客户确认收货时间 +- `receiveConfirmType`:10客户手动确认、20配送照片自动确认、30超时自动确认 + +## 3) 状态流转与校验(强烈建议在后端做) +请在更新订单时做状态机校验,避免前端绕过流程: +- `10 -> 20`:仅允许订单属于当前配送员,且未开始/未送达 +- `20 -> 30`:配送员确认送达(可带 `sendEndImg`) +- `20/30 -> 40`:完成;来源可能是 + - 客户手动确认(写 `receiveConfirmTime` + `receiveConfirmType=10`) + - 配送照片直接完成(写 `receiveConfirmTime` + `receiveConfirmType=20`,并要求 `sendEndImg`) + - 超时自动确认(写 `receiveConfirmTime` + `receiveConfirmType=30`,建议由定时任务执行) + +## 4) 建议新增/明确的接口能力 +为了避免并发抢单/越权更新,建议新增更语义化的接口(或在 update 内做等价校验): +- 接单(抢单/派单):`POST /glt/glt-ticket-order/{id}/accept` + - 后端原子校验:仅当 `riderId is null` 才能写入当前 rider 信息 +- 开始配送:`POST /glt/glt-ticket-order/{id}/start`(写 `sendStartTime` + `deliveryStatus=20`) +- 确认送达:`POST /glt/glt-ticket-order/{id}/delivered`(写 `sendEndTime` + `deliveryStatus=30` + 可选 `sendEndImg`) +- 客户确认收货:`POST /glt/glt-ticket-order/{id}/confirm-receive` + - 校验:只能本人 `userId` 操作,且必须已送达 + +## 5) 为了“导航到收货地址/取货点”的字段补充(建议) +当前仅有 `address` 字符串,无法在小程序内 `openLocation` 精准导航;建议补充: +- 收货地址:`receiverName`、`receiverPhone`、`province/city/district/detail`、`latitude/longitude` +- 取货点(门店/仓库):`storeLatitude/storeLongitude` 或 `warehouseLatitude/warehouseLongitude` + diff --git a/package.json b/package.json index e57ea99..cb6d20f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "template-10550", + "name": "template-10584", "version": "1.0.0", "private": true, "description": "WebSoft Inc.", diff --git a/project.config.json b/project.config.json index 33ad7f0..443f70e 100644 --- a/project.config.json +++ b/project.config.json @@ -1,8 +1,8 @@ { "miniprogramRoot": "dist/", - "projectname": "template-10550", - "description": "时里院子市集", - "appid": "wx5170f9f17a813877", + "projectname": "template-10584", + "description": "桂乐淘", + "appid": "wxad831ba00ad6a026", "setting": { "urlCheck": true, "es6": false, diff --git a/project.tt.json b/project.tt.json index 71d3700..986f0e8 100644 --- a/project.tt.json +++ b/project.tt.json @@ -1,7 +1,7 @@ { "miniprogramRoot": "./", "projectname": "mp-react", - "description": "时里院子市集", + "description": "桂乐淘", "appid": "touristappid", "setting": { "urlCheck": true, diff --git a/src/admin/components/UserCard.tsx b/src/admin/components/UserCard.tsx index a83f124..97d808e 100644 --- a/src/admin/components/UserCard.tsx +++ b/src/admin/components/UserCard.tsx @@ -144,7 +144,7 @@ function UserCard() { success: function () { if (code) { Taro.request({ - url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone', + url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone', method: 'POST', data: { code, @@ -237,7 +237,7 @@ function UserCard() {
navTo('/user/gift/index', true)}> - 礼品卡 + 水票 {giftCount}
{/*
*/} diff --git a/src/api/glt/gltTicketOrder/index.ts b/src/api/glt/gltTicketOrder/index.ts new file mode 100644 index 0000000..46693fb --- /dev/null +++ b/src/api/glt/gltTicketOrder/index.ts @@ -0,0 +1,101 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api/index'; +import type { GltTicketOrder, GltTicketOrderParam } from './model'; + +/** + * 分页查询送水订单 + */ +export async function pageGltTicketOrder(params: GltTicketOrderParam) { + const res = await request.get>>( + '/glt/glt-ticket-order/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询送水订单列表 + */ +export async function listGltTicketOrder(params?: GltTicketOrderParam) { + const res = await request.get>( + '/glt/glt-ticket-order', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加送水订单 + */ +export async function addGltTicketOrder(data: GltTicketOrder) { + const res = await request.post>( + '/glt/glt-ticket-order', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改送水订单 + */ +export async function updateGltTicketOrder(data: GltTicketOrder) { + const res = await request.put>( + '/glt/glt-ticket-order', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除送水订单 + */ +export async function removeGltTicketOrder(id?: number) { + const res = await request.del>( + '/glt/glt-ticket-order/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除送水订单 + */ +export async function removeBatchGltTicketOrder(data: (number | undefined)[]) { + const res = await request.del>( + '/glt/glt-ticket-order/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询送水订单 + */ +export async function getGltTicketOrder(id: number) { + const res = await request.get>( + '/glt/glt-ticket-order/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/glt/gltTicketOrder/model/index.ts b/src/api/glt/gltTicketOrder/model/index.ts new file mode 100644 index 0000000..6e4b75a --- /dev/null +++ b/src/api/glt/gltTicketOrder/model/index.ts @@ -0,0 +1,94 @@ +import type { PageParam } from '@/api'; + +/** + * 送水订单 + */ +export interface GltTicketOrder { + // + id?: number; + // 用户水票ID + userTicketId?: number; + // 订单编号 + orderNo?: string; + // 门店ID + storeId?: number; + // 门店名称 + storeName?: string; + // 门店地址 + storeAddress?: string; + // 门店电话 + storePhone?: string; + // 配送员 + riderId?: number; + // 配送员名称 + riderName?: string; + // 配送员电话 + riderPhone?: string; + // 仓库ID + warehouseId?: number; + // 仓库名称 + warehouseName?: string; + // 仓库地址 + warehouseAddress?: string; + // 关联收货地址 + addressId?: number; + // 收货地址 + address?: string; + // 配送时间 + sendTime?: string; + // 配送开始时间(配送员点击“开始配送”) + sendStartTime?: string; + // 配送结束时间(配送员确认送达) + sendEndTime?: string; + // 配送员送达拍照(选填/必填由后端策略决定) + sendEndImg?: string; + // 发货/配送状态(建议:10待配送 20配送中 30待客户确认 40已完成) + deliveryStatus?: number; + // 客户确认收货时间(客户点击确认收货) + receiveConfirmTime?: string; + // 客户确认方式(建议:10客户手动确认 20配送照片自动确认 30后台超时自动确认) + receiveConfirmType?: number; + // 买家留言 + buyerRemarks?: string; + // 用于统计 + price?: string; + // 购买数量 + totalNum?: number; + // 用户ID + userId?: number; + // 昵称 + nickname?: string; + // 头像 + avatar?: string; + // 手机号码 + phone?: string; + // 排序(数字越小越靠前) + sortNumber?: number; + // 备注 + comments?: string; + // 状态, 0正常, 1冻结 + status?: number; + // 是否删除, 0否, 1是 + deleted?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 送水订单搜索条件 + */ +export interface GltTicketOrderParam extends PageParam { + id?: number; + keywords?: string; + userId?: number; + // 配送员用户ID(用于配送员端查询) + riderId?: number; + // 发货/配送状态(建议与 GltTicketOrder.deliveryStatus 对齐) + deliveryStatus?: number; + // 兼容 ShopOrderParam 的筛选字段(如后端已实现可直接复用) + statusFilter?: number; +} diff --git a/src/api/glt/gltTicketTemplate/index.ts b/src/api/glt/gltTicketTemplate/index.ts new file mode 100644 index 0000000..f145300 --- /dev/null +++ b/src/api/glt/gltTicketTemplate/index.ts @@ -0,0 +1,118 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api'; +import type { GltTicketTemplate, GltTicketTemplateParam } from './model'; + +/** + * 分页查询水票 + */ +export async function pageGltTicketTemplate(params: GltTicketTemplateParam) { + const res = await request.get>>( + '/glt/glt-ticket-template/page', + { + params + } + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询水票列表 + */ +export async function listGltTicketTemplate(params?: GltTicketTemplateParam) { + const res = await request.get>( + '/glt/glt-ticket-template', + { + params + } + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加水票 + */ +export async function addGltTicketTemplate(data: GltTicketTemplate) { + const res = await request.post>( + '/glt/glt-ticket-template', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改水票 + */ +export async function updateGltTicketTemplate(data: GltTicketTemplate) { + const res = await request.put>( + '/glt/glt-ticket-template', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除水票 + */ +export async function removeGltTicketTemplate(id?: number) { + const res = await request.del>( + '/glt/glt-ticket-template/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除水票 + */ +export async function removeBatchGltTicketTemplate(data: (number | undefined)[]) { + const res = await request.del>( + '/glt/glt-ticket-template/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询水票 + */ +export async function getGltTicketTemplate(id: number) { + const res = await request.get>( + '/glt/glt-ticket-template/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据商品ID查询水票模板 + */ +export async function getGltTicketTemplateByGoodsId(id: number) { + const res = await request.get>( + '/glt/glt-ticket-template/getByGoodsId/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/glt/gltTicketTemplate/model/index.ts b/src/api/glt/gltTicketTemplate/model/index.ts new file mode 100644 index 0000000..0e18f2a --- /dev/null +++ b/src/api/glt/gltTicketTemplate/model/index.ts @@ -0,0 +1,55 @@ +import type { PageParam } from '@/api'; + +/** + * 水票 + */ +export interface GltTicketTemplate { + // + id?: number; + // 关联商品ID + goodsId?: number; + // 名称 + name?: string; + // 启用 + enabled?: boolean; + // 单位名称 + unitName?: string; + // 最小购买数量 + minBuyQty?: number; + // 起始发送数量 + startSendQty?: number; + // 买赠:买1送4 => gift_multiplier=4 + giftMultiplier?: number; + // 是否把购买量也计入套票总量(默认仅计入赠送量) + includeBuyQty?: boolean; + // 每期释放数量(默认每月释放10) + monthlyReleaseQty?: number; + // 总共释放多少期(若配置>0,则按期数平均分摊) + releasePeriods?: number; + // 首期释放时机:0=支付成功当刻;1=下个月同日 + firstReleaseMode?: number; + // 用户ID + userId?: number; + // 排序(数字越小越靠前) + sortNumber?: number; + // 备注 + comments?: string; + // 状态, 0正常, 1冻结 + status?: number; + // 是否删除, 0否, 1是 + deleted?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 水票搜索条件 + */ +export interface GltTicketTemplateParam extends PageParam { + id?: number; + keywords?: string; +} diff --git a/src/api/glt/gltUserTicket/index.ts b/src/api/glt/gltUserTicket/index.ts new file mode 100644 index 0000000..c412cc0 --- /dev/null +++ b/src/api/glt/gltUserTicket/index.ts @@ -0,0 +1,170 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api'; +import type { GltUserTicket, GltUserTicketParam } from './model'; + +function normalizeTotal(input: unknown): number { + if (typeof input === 'number' && Number.isFinite(input)) return input; + if (typeof input === 'string') { + const n = Number(input); + if (Number.isFinite(n)) return n; + } + if (input && typeof input === 'object') { + const obj: any = input; + // Common shapes from different backends. + for (const key of ['total', 'count', 'value', 'num', 'ticketTotal', 'totalQty']) { + const v = obj?.[key]; + const n = normalizeTotal(v); + if (n) return n; + } + // Sometimes nested: { data: { total: ... } } / { data: 12 } + if ('data' in obj) { + const n = normalizeTotal(obj.data); + if (n) return n; + } + } + return 0; +} + +/** + * 分页查询我的水票 + */ +export async function pageGltUserTicket(params: GltUserTicketParam) { + const res = await request.get>>( + '/glt/glt-user-ticket/page', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询我的水票列表 + */ +export async function listGltUserTicket(params?: GltUserTicketParam) { + const res = await request.get>( + '/glt/glt-user-ticket', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加我的水票 + */ +export async function addGltUserTicket(data: GltUserTicket) { + const res = await request.post>( + '/glt/glt-user-ticket', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改我的水票 + */ +export async function updateGltUserTicket(data: GltUserTicket) { + const res = await request.put>( + '/glt/glt-user-ticket', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除我的水票 + */ +export async function removeGltUserTicket(id?: number) { + const res = await request.del>( + '/glt/glt-user-ticket/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除我的水票 + */ +export async function removeBatchGltUserTicket(data: (number | undefined)[]) { + const res = await request.del>( + '/glt/glt-user-ticket/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询我的水票 + */ +export async function getGltUserTicket(id: number) { + const res = await request.get>( + '/glt/glt-user-ticket/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 获取我的水票总数 + */ +export async function getMyGltUserTicketTotal(userId?: number) { + const params = userId ? { userId } : undefined + + const extract = (res: any) => { + // Some backends may return a raw number instead of ApiResult. + if (typeof res === 'number' || typeof res === 'string') return normalizeTotal(res) + if (res && typeof res === 'object' && 'code' in res) { + const apiRes = res as ApiResult + if (apiRes.code === 0) return normalizeTotal(apiRes.data) + throw new Error(apiRes.message) + } + return normalizeTotal(res) + } + + // Try both the configured BaseUrl host and the auth-server host. + // If the first one returns 0, keep trying; some tenants deploy GLT on a different host. + const urls = [ + '/glt/glt-user-ticket/my-total' + ] + + let lastError: unknown + let firstTotal: number | undefined + for (const url of urls) { + try { + const res = await request.get(url, params) + if (process.env.NODE_ENV === 'development') { + console.log('[getMyGltUserTicketTotal] response:', { url, res }) + } + const total = extract(res) + if (firstTotal === undefined) firstTotal = total + if (total) return total + } catch (e) { + if (process.env.NODE_ENV === 'development') { + console.warn('[getMyGltUserTicketTotal] failed:', { url, error: e }) + } + lastError = e + } + } + + if (firstTotal !== undefined) return firstTotal + return Promise.reject(lastError instanceof Error ? lastError : new Error('获取水票总数失败')) +} diff --git a/src/api/glt/gltUserTicket/model/index.ts b/src/api/glt/gltUserTicket/model/index.ts new file mode 100644 index 0000000..7de0b48 --- /dev/null +++ b/src/api/glt/gltUserTicket/model/index.ts @@ -0,0 +1,66 @@ +import type { PageParam } from '@/api'; + +/** + * 我的水票 + */ +export interface GltUserTicket { + // + id?: number; + // 模板ID + templateId?: number; + // 模板名称 + templateName?: string; + // 商品ID + goodsId?: number; + // 订单ID + orderId?: number; + // 订单编号 + orderNo?: string; + // 订单商品ID + orderGoodsId?: number; + // 总数量 + totalQty?: number; + // 可用数量 + availableQty?: number; + // 冻结数量 + frozenQty?: number; + // 已使用数量 + usedQty?: number; + // 已释放数量 + releasedQty?: number; + // 用户ID + userId?: number; + // 用户昵称 + nickname?: string; + // 用户头像 + avatar?: string; + // 用户手机号 + phone?: string; + // 排序(数字越小越靠前) + sortNumber?: number; + // 备注 + comments?: string; + // 状态, 0正常, 1冻结 + status?: number; + // 是否删除, 0否, 1是 + deleted?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 我的水票搜索条件 + */ +export interface GltUserTicketParam extends PageParam { + id?: number; + templateId?: number; + userId?: number; + phone?: string; + keywords?: string; + // 状态过滤:0正常,1冻结 + status?: number; +} diff --git a/src/api/glt/gltUserTicketLog/index.ts b/src/api/glt/gltUserTicketLog/index.ts new file mode 100644 index 0000000..87b74d8 --- /dev/null +++ b/src/api/glt/gltUserTicketLog/index.ts @@ -0,0 +1,101 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api'; +import type { GltUserTicketLog, GltUserTicketLogParam } from './model'; + +/** + * 分页查询消费日志 + */ +export async function pageGltUserTicketLog(params: GltUserTicketLogParam) { + const res = await request.get>>( + '/glt/glt-user-ticket-log/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询消费日志列表 + */ +export async function listGltUserTicketLog(params?: GltUserTicketLogParam) { + const res = await request.get>( + '/glt/glt-user-ticket-log', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加消费日志 + */ +export async function addGltUserTicketLog(data: GltUserTicketLog) { + const res = await request.post>( + '/glt/glt-user-ticket-log', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改消费日志 + */ +export async function updateGltUserTicketLog(data: GltUserTicketLog) { + const res = await request.put>( + '/glt/glt-user-ticket-log', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除消费日志 + */ +export async function removeGltUserTicketLog(id?: number) { + const res = await request.del>( + '/glt/glt-user-ticket-log/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除消费日志 + */ +export async function removeBatchGltUserTicketLog(data: (number | undefined)[]) { + const res = await request.del>( + '/glt/glt-user-ticket-log/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询消费日志 + */ +export async function getGltUserTicketLog(id: number) { + const res = await request.get>( + '/glt/glt-user-ticket-log/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/glt/gltUserTicketLog/model/index.ts b/src/api/glt/gltUserTicketLog/model/index.ts new file mode 100644 index 0000000..b974f5b --- /dev/null +++ b/src/api/glt/gltUserTicketLog/model/index.ts @@ -0,0 +1,54 @@ +import type { PageParam } from '@/api'; + +/** + * 消费日志 + */ +export interface GltUserTicketLog { + // + id?: number; + // 用户水票ID + userTicketId?: number; + // 变更类型 + changeType?: number; + // 可更改 + changeAvailable?: number; + // 更改冻结状态 + changeFrozen?: number; + // 已使用更改 + changeUsed?: number; + // 可用后 + availableAfter?: number; + // 冻结后 + frozenAfter?: number; + // 使用后 + usedAfter?: number; + // 订单ID + orderId?: number; + // 订单编号 + orderNo?: string; + // 用户ID + userId?: number; + // 排序(数字越小越靠前) + sortNumber?: number; + // 备注 + comments?: string; + // 状态, 0正常, 1冻结 + status?: number; + // 是否删除, 0否, 1是 + deleted?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 消费日志搜索条件 + */ +export interface GltUserTicketLogParam extends PageParam { + id?: number; + keywords?: string; + userId?: number; +} diff --git a/src/api/glt/gltUserTicketRelease/index.ts b/src/api/glt/gltUserTicketRelease/index.ts new file mode 100644 index 0000000..40b5e7d --- /dev/null +++ b/src/api/glt/gltUserTicketRelease/index.ts @@ -0,0 +1,105 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api'; +import type { GltUserTicketRelease, GltUserTicketReleaseParam } from './model'; + +/** + * 分页查询水票释放 + */ +export async function pageGltUserTicketRelease(params: GltUserTicketReleaseParam) { + const res = await request.get>>( + '/glt/glt-user-ticket-release/page', + { + params + } + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询水票释放列表 + */ +export async function listGltUserTicketRelease(params?: GltUserTicketReleaseParam) { + const res = await request.get>( + '/glt/glt-user-ticket-release', + { + params + } + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加水票释放 + */ +export async function addGltUserTicketRelease(data: GltUserTicketRelease) { + const res = await request.post>( + '/glt/glt-user-ticket-release', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改水票释放 + */ +export async function updateGltUserTicketRelease(data: GltUserTicketRelease) { + const res = await request.put>( + '/glt/glt-user-ticket-release', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除水票释放 + */ +export async function removeGltUserTicketRelease(id?: number) { + const res = await request.del>( + '/glt/glt-user-ticket-release/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除水票释放 + */ +export async function removeBatchGltUserTicketRelease(data: (number | undefined)[]) { + const res = await request.del>( + '/glt/glt-user-ticket-release/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询水票释放 + */ +export async function getGltUserTicketRelease(id: number) { + const res = await request.get>( + '/glt/glt-user-ticket-release/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/glt/gltUserTicketRelease/model/index.ts b/src/api/glt/gltUserTicketRelease/model/index.ts new file mode 100644 index 0000000..14cf1cd --- /dev/null +++ b/src/api/glt/gltUserTicketRelease/model/index.ts @@ -0,0 +1,38 @@ +import type { PageParam } from '@/api'; + +/** + * 水票释放 + */ +export interface GltUserTicketRelease { + // + id?: string; + // 水票ID + userTicketId?: string; + // 用户ID + userId?: number; + // 周期编号 + periodNo?: number; + // 释放数量 + releaseQty?: number; + // 释放时间 + releaseTime?: string; + // 状态 + status?: number; + // 是否删除, 0否, 1是 + deleted?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 水票释放搜索条件 + */ +export interface GltUserTicketReleaseParam extends PageParam { + id?: number; + userId?: number; + keywords?: string; +} diff --git a/src/api/shop/shopDealerCapital/model/index.ts b/src/api/shop/shopDealerCapital/model/index.ts index e6a6bc2..00d29a7 100644 --- a/src/api/shop/shopDealerCapital/model/index.ts +++ b/src/api/shop/shopDealerCapital/model/index.ts @@ -31,5 +31,11 @@ export interface ShopDealerCapital { */ export interface ShopDealerCapitalParam extends PageParam { id?: number; + // 仅查询当前分销商的收益/资金明细 + userId?: number; + // 可选:按订单过滤 + orderId?: number; + // 可选:资金流动类型过滤 + flowType?: number; keywords?: string; } diff --git a/src/api/shop/shopDealerOrder/model/index.ts b/src/api/shop/shopDealerOrder/model/index.ts index 68b4928..03c9512 100644 --- a/src/api/shop/shopDealerOrder/model/index.ts +++ b/src/api/shop/shopDealerOrder/model/index.ts @@ -8,6 +8,9 @@ export interface ShopDealerOrder { id?: number; // 买家用户ID userId?: number; + nickname?: string; + // 订单编号(部分接口会直接返回订单号字符串) + orderNo?: string; // 订单ID orderId?: number; // 订单总金额(不含运费) @@ -28,6 +31,10 @@ export interface ShopDealerOrder { isInvalid?: number; // 佣金结算(0未结算 1已结算) isSettled?: number; + // 佣金解冻(0未解冻 1已解冻) + isUnfreeze?: number; + // 订单状态 + orderStatus?: number; // 结算时间 settleTime?: number; // 商城ID @@ -47,5 +54,7 @@ export interface ShopDealerOrderParam extends PageParam { secondUserId?: number; thirdUserId?: number; userId?: number; + // 数据权限/资源ID(通常传当前登录用户ID) + resourceId?: number; keywords?: string; } diff --git a/src/api/shop/shopDealerUser/index.ts b/src/api/shop/shopDealerUser/index.ts index 0f673a1..df82b27 100644 --- a/src/api/shop/shopDealerUser/index.ts +++ b/src/api/shop/shopDealerUser/index.ts @@ -95,8 +95,9 @@ export async function getShopDealerUser(userId: number) { const res = await request.get>( '/shop/shop-dealer-user/' + userId ); - if (res.code === 0 && res.data) { - return res.data; + if (res.code === 0) { + // 未注册为分销商时,后端可能返回 data=null,这里用 null 表示“没有分销商信息” + return res.data || null; } return Promise.reject(new Error(res.message)); } diff --git a/src/api/shop/shopDealerWithdraw/index.ts b/src/api/shop/shopDealerWithdraw/index.ts index 6968031..398b9be 100644 --- a/src/api/shop/shopDealerWithdraw/index.ts +++ b/src/api/shop/shopDealerWithdraw/index.ts @@ -2,6 +2,21 @@ import request from '@/utils/request'; import type { ApiResult, PageResult } from '@/api'; import type { ShopDealerWithdraw, ShopDealerWithdrawParam } from './model'; +// WeChat transfer v3: backend may return `package_info` for MiniProgram to open the +// "confirm receipt" page via `wx.requestMerchantTransfer`. +export type ShopDealerWithdrawCreateResult = + | string + | { + package_info?: string; + packageInfo?: string; + [k: string]: any; + } + | null + | undefined; + +// When applyStatus=20, user can "receive" (WeChat confirm receipt flow). +export type ShopDealerWithdrawReceiveResult = ShopDealerWithdrawCreateResult; + /** * 分页查询分销商提现明细表 */ @@ -33,11 +48,40 @@ export async function listShopDealerWithdraw(params?: ShopDealerWithdrawParam) { /** * 添加分销商提现明细表 */ -export async function addShopDealerWithdraw(data: ShopDealerWithdraw) { - const res = await request.post>( +export async function addShopDealerWithdraw(data: ShopDealerWithdraw): Promise { + const res = await request.post>( '/shop/shop-dealer-withdraw', data ); + if (res.code === 0) { + // Some backends return `message`, while WeChat transfer flow returns `data.package_info`. + return res.data ?? res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 用户领取(仅当 applyStatus=20 时)- 后台返回 package_info 供小程序调起确认收款页 + */ +export async function receiveShopDealerWithdraw(id: number): Promise { + const res = await request.post>( + '/shop/shop-dealer-withdraw/receive/' + id, + {} + ); + if (res.code === 0) { + return res.data ?? res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 领取成功回调:前端确认收款后通知后台把状态置为 applyStatus=40 + */ +export async function receiveSuccessShopDealerWithdraw(id: number) { + const res = await request.post>( + '/shop/shop-dealer-withdraw/receive-success/' + id, + {} + ); if (res.code === 0) { return res.message; } diff --git a/src/api/shop/shopGift/model/index.ts b/src/api/shop/shopGift/model/index.ts index 93713a6..f3c7450 100644 --- a/src/api/shop/shopGift/model/index.ts +++ b/src/api/shop/shopGift/model/index.ts @@ -1,7 +1,7 @@ import type { PageParam } from '@/api'; /** - * 礼品卡 + * 水票 */ export interface ShopGift { // 礼品卡ID diff --git a/src/api/shop/shopGoods/model/index.ts b/src/api/shop/shopGoods/model/index.ts index ce4328f..977d90f 100644 --- a/src/api/shop/shopGoods/model/index.ts +++ b/src/api/shop/shopGoods/model/index.ts @@ -146,4 +146,7 @@ export interface ShopGoodsParam extends PageParam { isShow?: number; stock?: number; keywords?: string; + recommend?: number; + // 0上架 1下架(以实际后端约定为准) + status?: number; } diff --git a/src/api/shop/shopOrder/index.ts b/src/api/shop/shopOrder/index.ts index ca1e805..8e61482 100644 --- a/src/api/shop/shopOrder/index.ts +++ b/src/api/shop/shopOrder/index.ts @@ -1,4 +1,4 @@ -import request from '@/utils/request'; +import request, { ErrorType, RequestError } from '@/utils/request'; import type { ApiResult, PageResult } from '@/api'; import type { ShopOrder, ShopOrderParam, OrderCreateRequest } from './model'; @@ -113,6 +113,44 @@ export interface WxPayResult { paySign: string; } +/** + * 订单重新发起支付(对“已创建但未支付”的订单生成新的预支付参数,不应重复创建订单) + * + * 说明:不同后端版本可能暴露不同路径,这里做兼容探测;若全部失败,调用方可自行降级处理。 + */ +export interface OrderPrepayRequest { + orderId: number; + payType: number; +} + +export async function prepayShopOrder(data: OrderPrepayRequest) { + const urls = [ + '/shop/shop-order/pay', + '/shop/shop-order/prepay', + '/shop/shop-order/repay' + ]; + + let lastError: unknown; + let businessError: unknown; + for (const url of urls) { + try { + const res = await request.post>(url, data, { showError: false }); + // request.ts 在 code!=0 时会直接 throw;走到这里通常都是 code===0 + if (res.code === 0) return res.data; + } catch (e) { + // 若已命中“业务错误”(例如订单已取消/已支付),优先保留该错误用于向上提示; + // 不要被后续的 404/网络错误覆盖掉,避免调用方误判为“不支持该接口”而降级走创建订单。 + if (!businessError && e instanceof RequestError && e.type === ErrorType.BUSINESS_ERROR) { + businessError = e; + } else { + lastError = e; + } + } + } + + return Promise.reject(businessError || lastError || new Error('发起支付失败')); +} + /** * 创建订单 */ @@ -140,3 +178,18 @@ export async function repairOrder(data: ShopOrder) { } return Promise.reject(new Error(res.message)); } + + +/** + * 申请|同意退款 + */ +export async function refundShopOrder(data: ShopOrder) { + const res = await request.put>( + '/shop/shop-order/refund', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/shop/shopOrder/model/index.ts b/src/api/shop/shopOrder/model/index.ts index 92709f8..ee1320a 100644 --- a/src/api/shop/shopOrder/model/index.ts +++ b/src/api/shop/shopOrder/model/index.ts @@ -1,5 +1,5 @@ import type { PageParam } from '@/api/index'; -import {OrderGoods} from "@/api/system/orderGoods/model"; +import type { ShopOrderGoods } from '@/api/shop/shopOrderGoods/model'; /** * 订单 @@ -27,6 +27,14 @@ export interface ShopOrder { merchantName?: string; // 商户编号 merchantCode?: string; + // 归属门店ID(shop_store.id) + storeId?: number; + // 归属门店名称 + storeName?: string; + // 配送员用户ID(优先级派单) + riderId?: number; + // 发货仓库ID + warehouseId?: number; // 使用的优惠券id couponId?: number; // 使用的会员卡id @@ -61,6 +69,8 @@ export interface ShopOrder { sendStartTime?: string; // 配送结束时间 sendEndTime?: string; + // 配送员送达拍照(选填) + sendEndImg?: string; // 发货店铺id expressMerchantId?: number; // 发货店铺 @@ -83,6 +93,8 @@ export interface ShopOrder { totalNum?: number; // 教练id coachId?: number; + // 商品ID + formId?: number; // 支付的用户id payUserId?: number; // 0余额支付, 1微信支付,102微信Native,2会员卡支付,3支付宝,4现金,5POS机,6VIP月卡,7VIP年卡,8VIP次卡,9IC月卡,10IC年卡,11IC次卡,12免费,13VIP充值卡,14IC充值卡,15积分支付,16VIP季卡,17IC季卡,18代付 @@ -146,7 +158,7 @@ export interface ShopOrder { // 是否已收到赠品 hasTakeGift?: string; // 订单商品项 - orderGoods?: OrderGoods[]; + orderGoods?: ShopOrderGoods[]; } /** @@ -165,6 +177,14 @@ export interface OrderGoodsItem { export interface OrderCreateRequest { // 商品信息列表 goodsItems: OrderGoodsItem[]; + // 归属门店ID(shop_store.id) + storeId?: number; + // 归属门店名称(可选) + storeName?: string; + // 配送员用户ID(优先级派单) + riderId?: number; + // 发货仓库ID + warehouseId?: number; // 收货地址ID addressId?: number; // 支付方式 @@ -173,6 +193,8 @@ export interface OrderCreateRequest { couponId?: number; // 备注 comments?: string; + // 配送开始时间(用于预约/配送时间) + sendStartTime?: string; // 配送方式 0快递 1自提 deliveryType?: number; // 自提店铺ID @@ -197,6 +219,14 @@ export interface OrderGoodsItem { export interface OrderCreateRequest { // 商品信息列表 goodsItems: OrderGoodsItem[]; + // 归属门店ID(shop_store.id) + storeId?: number; + // 归属门店名称(可选) + storeName?: string; + // 配送员用户ID(优先级派单) + riderId?: number; + // 发货仓库ID + warehouseId?: number; // 收货地址ID addressId?: number; // 支付方式 @@ -205,6 +235,8 @@ export interface OrderCreateRequest { couponId?: number; // 备注 comments?: string; + // 配送开始时间(用于预约/配送时间) + sendStartTime?: string; // 配送方式 0快递 1自提 deliveryType?: number; // 自提店铺ID @@ -223,6 +255,12 @@ export interface ShopOrderParam extends PageParam { payType?: number; isInvoice?: boolean; userId?: number; + // 归属门店ID(shop_store.id) + storeId?: number; + // 配送员用户ID + riderId?: number; + // 发货仓库ID + warehouseId?: number; keywords?: string; deliveryStatus?: number; statusFilter?: number; diff --git a/src/api/shop/shopStore/index.ts b/src/api/shop/shopStore/index.ts new file mode 100644 index 0000000..e67303f --- /dev/null +++ b/src/api/shop/shopStore/index.ts @@ -0,0 +1,101 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api'; +import type { ShopStore, ShopStoreParam } from './model'; + +/** + * 分页查询门店 + */ +export async function pageShopStore(params: ShopStoreParam) { + const res = await request.get>>( + '/shop/shop-store/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询门店列表 + */ +export async function listShopStore(params?: ShopStoreParam) { + const res = await request.get>( + '/shop/shop-store', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加门店 + */ +export async function addShopStore(data: ShopStore) { + const res = await request.post>( + '/shop/shop-store', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改门店 + */ +export async function updateShopStore(data: ShopStore) { + const res = await request.put>( + '/shop/shop-store', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除门店 + */ +export async function removeShopStore(id?: number) { + const res = await request.del>( + '/shop/shop-store/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除门店 + */ +export async function removeBatchShopStore(data: (number | undefined)[]) { + const res = await request.del>( + '/shop/shop-store/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询门店 + */ +export async function getShopStore(id: number) { + const res = await request.get>( + '/shop/shop-store/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/shop/shopStore/model/index.ts b/src/api/shop/shopStore/model/index.ts new file mode 100644 index 0000000..3e5bcd1 --- /dev/null +++ b/src/api/shop/shopStore/model/index.ts @@ -0,0 +1,63 @@ +import type { PageParam } from '@/api'; + +/** + * 门店 + */ +export interface ShopStore { + // 自增ID + id?: number; + // 店铺名称 + name?: string; + // 门店地址 + address?: string; + // 手机号码 + phone?: string; + // 邮箱 + email?: string; + // 门店经理 + managerName?: string; + // 门店banner + shopBanner?: string; + // 所在省份 + province?: string; + // 所在城市 + city?: string; + // 所在辖区 + region?: string; + // 经度和纬度 + lngAndLat?: string; + // 位置 + location?:string; + // 区域 + district?: string; + // 轮廓 + points?: string; + // 用户ID + userId?: number; + // 默认仓库ID(shop_warehouse.id) + warehouseId?: number; + // 默认仓库名称(可选) + warehouseName?: string; + // 状态 + status?: number; + // 备注 + comments?: string; + // 排序号 + sortNumber?: number; + // 是否删除 + isDelete?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 门店搜索条件 + */ +export interface ShopStoreParam extends PageParam { + id?: number; + keywords?: string; +} diff --git a/src/api/shop/shopStoreFence/index.ts b/src/api/shop/shopStoreFence/index.ts new file mode 100644 index 0000000..609defa --- /dev/null +++ b/src/api/shop/shopStoreFence/index.ts @@ -0,0 +1,101 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api/index'; +import type { ShopStoreFence, ShopStoreFenceParam } from './model'; + +/** + * 分页查询黄家明_电子围栏 + */ +export async function pageShopStoreFence(params: ShopStoreFenceParam) { + const res = await request.get>>( + '/shop/shop-store-fence/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询黄家明_电子围栏列表 + */ +export async function listShopStoreFence(params?: ShopStoreFenceParam) { + const res = await request.get>( + '/shop/shop-store-fence', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加黄家明_电子围栏 + */ +export async function addShopStoreFence(data: ShopStoreFence) { + const res = await request.post>( + '/shop/shop-store-fence', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改黄家明_电子围栏 + */ +export async function updateShopStoreFence(data: ShopStoreFence) { + const res = await request.put>( + '/shop/shop-store-fence', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除黄家明_电子围栏 + */ +export async function removeShopStoreFence(id?: number) { + const res = await request.del>( + '/shop/shop-store-fence/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除黄家明_电子围栏 + */ +export async function removeBatchShopStoreFence(data: (number | undefined)[]) { + const res = await request.del>( + '/shop/shop-store-fence/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询黄家明_电子围栏 + */ +export async function getShopStoreFence(id: number) { + const res = await request.get>( + '/shop/shop-store-fence/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/shop/shopStoreFence/model/index.ts b/src/api/shop/shopStoreFence/model/index.ts new file mode 100644 index 0000000..99c20dd --- /dev/null +++ b/src/api/shop/shopStoreFence/model/index.ts @@ -0,0 +1,43 @@ +import type { PageParam } from '@/api/index'; + +/** + * 黄家明_电子围栏 + */ +export interface ShopStoreFence { + // 自增ID + id?: number; + // 围栏名称 + name?: string; + // 类型 0圆形 1方形 + type?: number; + // 定位 + location?: string; + // 经度 + longitude?: string; + // 纬度 + latitude?: string; + // 区域 + district?: string; + // 电子围栏轮廓 + points?: string; + // 排序(数字越小越靠前) + sortNumber?: number; + // 备注 + comments?: string; + // 状态, 0正常, 1冻结 + status?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 黄家明_电子围栏搜索条件 + */ +export interface ShopStoreFenceParam extends PageParam { + id?: number; + keywords?: string; +} diff --git a/src/api/shop/shopStoreRider/index.ts b/src/api/shop/shopStoreRider/index.ts new file mode 100644 index 0000000..c411aef --- /dev/null +++ b/src/api/shop/shopStoreRider/index.ts @@ -0,0 +1,101 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api'; +import type { ShopStoreRider, ShopStoreRiderParam } from './model'; + +/** + * 分页查询配送员 + */ +export async function pageShopStoreRider(params: ShopStoreRiderParam) { + const res = await request.get>>( + '/shop/shop-store-rider/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询配送员列表 + */ +export async function listShopStoreRider(params?: ShopStoreRiderParam) { + const res = await request.get>( + '/shop/shop-store-rider', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加配送员 + */ +export async function addShopStoreRider(data: ShopStoreRider) { + const res = await request.post>( + '/shop/shop-store-rider', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改配送员 + */ +export async function updateShopStoreRider(data: ShopStoreRider) { + const res = await request.put>( + '/shop/shop-store-rider', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除配送员 + */ +export async function removeShopStoreRider(id?: number) { + const res = await request.del>( + '/shop/shop-store-rider/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除配送员 + */ +export async function removeBatchShopStoreRider(data: (number | undefined)[]) { + const res = await request.del>( + '/shop/shop-store-rider/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询配送员 + */ +export async function getShopStoreRider(id: number) { + const res = await request.get>( + '/shop/shop-store-rider/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/shop/shopStoreRider/model/index.ts b/src/api/shop/shopStoreRider/model/index.ts new file mode 100644 index 0000000..0b7ed68 --- /dev/null +++ b/src/api/shop/shopStoreRider/model/index.ts @@ -0,0 +1,71 @@ +import type { PageParam } from '@/api'; + +/** + * 配送员 + */ +export interface ShopStoreRider { + // 主键ID + id?: string; + // 配送点ID(shop_dealer.id) + dealerId?: number; + // 骑手编号(可选) + riderNo?: string; + // 姓名 + realName?: string; + // 手机号 + mobile?: string; + // 头像 + avatar?: string; + // 身份证号(可选) + idCardNo?: string; + // 状态:1启用;0禁用 + status?: number; + // 接单状态:0休息/下线;1在线;2忙碌 + workStatus?: number; + // 是否开启自动派单:1是;0否 + autoDispatchEnabled?: number; + // 派单优先级(同小区多骑手时可用,值越大越优先) + dispatchPriority?: number; + // 最大同时配送单数(0表示不限制) + maxOnhandOrders?: number; + // 是否计算工资(提成):1计算;0不计算(如三方配送点可设0) + commissionCalcEnabled?: number; + // 水每桶提成金额(元/桶) + waterBucketUnitFee?: string; + // 其他商品提成方式:1按订单固定金额;2按订单金额比例;3按商品规则(另表) + otherGoodsCommissionType?: number; + // 其他商品提成值:固定金额(元)或比例(%) + otherGoodsCommissionValue?: string; + // 用户ID + userId?: number; + // 经度(配送员当前位置) + longitude?: string; + // 纬度(配送员当前位置) + latitude?: string; + // 备注 + comments?: string; + // 排序号 + sortNumber?: number; + // 是否删除 + isDelete?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 配送员搜索条件 + */ +export interface ShopStoreRiderParam extends PageParam { + id?: number; + keywords?: string; + // 配送点/门店ID(后端可能用 dealerId 或 storeId) + dealerId?: number; + storeId?: number; + status?: number; + workStatus?: number; + autoDispatchEnabled?: number; +} diff --git a/src/api/shop/shopStoreUser/index.ts b/src/api/shop/shopStoreUser/index.ts new file mode 100644 index 0000000..0e500ab --- /dev/null +++ b/src/api/shop/shopStoreUser/index.ts @@ -0,0 +1,101 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api'; +import type { ShopStoreUser, ShopStoreUserParam } from './model'; + +/** + * 分页查询店员 + */ +export async function pageShopStoreUser(params: ShopStoreUserParam) { + const res = await request.get>>( + '/shop/shop-store-user/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询店员列表 + */ +export async function listShopStoreUser(params?: ShopStoreUserParam) { + const res = await request.get>( + '/shop/shop-store-user', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加店员 + */ +export async function addShopStoreUser(data: ShopStoreUser) { + const res = await request.post>( + '/shop/shop-store-user', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改店员 + */ +export async function updateShopStoreUser(data: ShopStoreUser) { + const res = await request.put>( + '/shop/shop-store-user', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除店员 + */ +export async function removeShopStoreUser(id?: number) { + const res = await request.del>( + '/shop/shop-store-user/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除店员 + */ +export async function removeBatchShopStoreUser(data: (number | undefined)[]) { + const res = await request.del>( + '/shop/shop-store-user/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询店员 + */ +export async function getShopStoreUser(id: number) { + const res = await request.get>( + '/shop/shop-store-user/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/shop/shopStoreUser/model/index.ts b/src/api/shop/shopStoreUser/model/index.ts new file mode 100644 index 0000000..46151f5 --- /dev/null +++ b/src/api/shop/shopStoreUser/model/index.ts @@ -0,0 +1,36 @@ +import type { PageParam } from '@/api'; + +/** + * 店员 + */ +export interface ShopStoreUser { + // 主键ID + id?: number; + // 配送点ID(shop_dealer.id) + storeId?: number; + // 用户ID + userId?: number; + // 备注 + comments?: string; + // 排序号 + sortNumber?: number; + // 是否删除 + isDelete?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 店员搜索条件 + */ +export interface ShopStoreUserParam extends PageParam { + id?: number; + keywords?: string; + storeId?: number; + userId?: number; + isDelete?: number; +} diff --git a/src/api/shop/shopStoreWarehouse/index.ts b/src/api/shop/shopStoreWarehouse/index.ts new file mode 100644 index 0000000..898e7b9 --- /dev/null +++ b/src/api/shop/shopStoreWarehouse/index.ts @@ -0,0 +1,101 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api/index'; +import type { ShopStoreWarehouse, ShopStoreWarehouseParam } from './model'; + +/** + * 分页查询仓库 + */ +export async function pageShopStoreWarehouse(params: ShopStoreWarehouseParam) { + const res = await request.get>>( + '/shop/shop-store-warehouse/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询仓库列表 + */ +export async function listShopStoreWarehouse(params?: ShopStoreWarehouseParam) { + const res = await request.get>( + '/shop/shop-store-warehouse', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加仓库 + */ +export async function addShopStoreWarehouse(data: ShopStoreWarehouse) { + const res = await request.post>( + '/shop/shop-store-warehouse', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改仓库 + */ +export async function updateShopStoreWarehouse(data: ShopStoreWarehouse) { + const res = await request.put>( + '/shop/shop-store-warehouse', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除仓库 + */ +export async function removeShopStoreWarehouse(id?: number) { + const res = await request.del>( + '/shop/shop-store-warehouse/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除仓库 + */ +export async function removeBatchShopStoreWarehouse(data: (number | undefined)[]) { + const res = await request.del>( + '/shop/shop-store-warehouse/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询仓库 + */ +export async function getShopStoreWarehouse(id: number) { + const res = await request.get>( + '/shop/shop-store-warehouse/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/shop/shopStoreWarehouse/model/index.ts b/src/api/shop/shopStoreWarehouse/model/index.ts new file mode 100644 index 0000000..90d5d84 --- /dev/null +++ b/src/api/shop/shopStoreWarehouse/model/index.ts @@ -0,0 +1,53 @@ +import type { PageParam } from '@/api/index'; + +/** + * 仓库 + */ +export interface ShopStoreWarehouse { + // 自增ID + id?: number; + // 仓库名称 + name?: string; + // 唯一标识 + code?: string; + // 类型 中心仓,区域仓,门店仓 + type?: string; + // 仓库地址 + address?: string; + // 真实姓名 + realName?: string; + // 联系电话 + phone?: string; + // 所在省份 + province?: string; + // 所在城市 + city?: string; + // 所在辖区 + region?: string; + // 经纬度 + lngAndLat?: string; + // 用户ID + userId?: number; + // 状态 + status?: number; + // 备注 + comments?: string; + // 排序号 + sortNumber?: number; + // 是否删除 + isDelete?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 仓库搜索条件 + */ +export interface ShopStoreWarehouseParam extends PageParam { + id?: number; + keywords?: string; +} diff --git a/src/api/shop/shopUserAddress/model/index.ts b/src/api/shop/shopUserAddress/model/index.ts index 9bf7f50..85696b9 100644 --- a/src/api/shop/shopUserAddress/model/index.ts +++ b/src/api/shop/shopUserAddress/model/index.ts @@ -38,6 +38,8 @@ export interface ShopUserAddress { tenantId?: number; // 注册时间 createTime?: string; + // 更新时间 + updateTime?: string; } /** diff --git a/src/api/system/file/index.ts b/src/api/system/file/index.ts index 18a06dd..a1b1b19 100644 --- a/src/api/system/file/index.ts +++ b/src/api/system/file/index.ts @@ -21,7 +21,7 @@ export async function uploadOssByPath(filePath: string) { let stsExpired = Taro.getStorageSync('stsExpiredAt'); if (!sts || (stsExpired && dayjs().isBefore(dayjs(stsExpired)))) { // @ts-ignore - const {data: {data: {credentials}}} = await request.get(`https://server.websoft.top/api/oss/getSTSToken`) + const {data: {data: {credentials}}} = await request.get(`https://gle-server.websoft.top/api/oss/getSTSToken`) Taro.setStorageSync('sts', credentials) Taro.setStorageSync('stsExpiredAt', credentials.expiration) sts = credentials @@ -49,7 +49,7 @@ export async function uploadOssByPath(filePath: string) { }) } -const computeSignature = (accessKeySecret, canonicalString) => { +const computeSignature = (accessKeySecret: string, canonicalString: string): string => { return crypto.enc.Base64.stringify(crypto.HmacSHA1(canonicalString, accessKeySecret)); } @@ -66,7 +66,7 @@ export async function uploadFile() { const tempFilePath = res.tempFilePaths[0]; // 上传图片到OSS Taro.uploadFile({ - url: 'https://server.websoft.top/api/oss/upload', + url: 'https://glt-server.websoft.top/api/oss/upload', filePath: tempFilePath, name: 'file', header: { diff --git a/src/api/system/userRole/index.ts b/src/api/system/userRole/index.ts index 3dfa2e1..ecaefcc 100644 --- a/src/api/system/userRole/index.ts +++ b/src/api/system/userRole/index.ts @@ -30,3 +30,18 @@ export async function updateUserRole(data: UserRole) { } return Promise.reject(new Error(res.message)); } + +/** + * 新增用户角色 + * 说明:部分后端实现为 POST 新增、PUT 修改;这里补齐 API 以便新用户无角色时可以创建默认角色。 + */ +export async function addUserRole(data: UserRole) { + const res = await request.post>( + SERVER_API_URL + '/system/user-role', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/user/index.ts b/src/api/user/index.ts index 7189f58..8f8b3c6 100644 --- a/src/api/user/index.ts +++ b/src/api/user/index.ts @@ -43,6 +43,15 @@ export interface UserOrderStats { total: number } +// 用户卡片统计(个人中心头部:余额/积分/优惠券/水票) +export interface UserCardStats { + balance: string + points: number + coupons: number + giftCards: number + lastUpdateTime?: string +} + // 用户完整数据 export interface UserDashboard { balance: UserBalance @@ -108,6 +117,17 @@ export async function getUserOrderStats() { return Promise.reject(new Error(res.message)) } +/** + * 获取用户卡片统计(一次性返回余额/积分/可用优惠券/未使用礼品卡数量) + */ +export async function getUserCardStats() { + const res = await request.get>('/user/card/stats') + if (res.code === 0 && res.data) { + return res.data + } + return Promise.reject(new Error(res.message)) +} + /** * 获取用户完整仪表板数据(一次性获取所有数据) */ diff --git a/src/app.config.ts b/src/app.config.ts index 78df1ad..f0e520f 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -54,10 +54,15 @@ export default { "wallet/wallet", "coupon/index", "points/points", - "gift/index", - "gift/redeem", - "gift/detail", + "ticket/index", + "ticket/use", + "ticket/orders/index", + // "gift/index", + // "gift/redeem", + // "gift/detail", + // "gift/add", "store/verification", + "store/orders/index", "theme/index", "poster/poster", "chat/conversation/index", @@ -73,6 +78,7 @@ export default { "apply/add", "withdraw/index", "orders/index", + "capital/index", "team/index", "qrcode/index", "invite-stats/index", @@ -90,6 +96,21 @@ export default { 'comments/index', 'search/index'] }, + { + "root": "store", + "pages": [ + "index", + "orders/index" + ] + }, + { + "root": "rider", + "pages": [ + "index", + "orders/index", + "ticket/verification/index" + ] + }, { "root": "admin", "pages": [ @@ -116,12 +137,6 @@ export default { selectedIconPath: "assets/tabbar/home-active.png", text: "首页", }, - { - pagePath: "pages/category/index", - iconPath: "assets/tabbar/category.png", - selectedIconPath: "assets/tabbar/category-active.png", - text: "基地生活", - }, { pagePath: "pages/cart/cart", iconPath: "assets/tabbar/cart.png", @@ -144,6 +159,9 @@ export default { permission: { "scope.userLocation": { "desc": "你的位置信息将用于小程序位置接口的效果展示" + }, + "scope.writePhotosAlbum": { + "desc": "用于保存小程序码到相册,方便分享给好友" } } } diff --git a/src/app.scss b/src/app.scss index ae872ba..272310c 100644 --- a/src/app.scss +++ b/src/app.scss @@ -87,6 +87,10 @@ button[open-type="chooseAvatar"] { justify-content: center; height: 80px; } + .cart-buy-only{ + border-radius: 20px; + flex: 1; + } } image { diff --git a/src/cms/category/index.tsx b/src/cms/category/index.tsx index fe81623..75a784b 100644 --- a/src/cms/category/index.tsx +++ b/src/cms/category/index.tsx @@ -44,7 +44,7 @@ function Category() { useShareAppMessage(() => { return { - title: `${nav?.categoryName}_时里院子市集`, + title: `${nav?.categoryName}_桂乐淘`, path: `/shop/category/index?id=${categoryId}`, success: function () { console.log('分享成功'); diff --git a/src/components/AddCartBar.tsx b/src/components/AddCartBar.tsx index c095208..c2e807e 100644 --- a/src/components/AddCartBar.tsx +++ b/src/components/AddCartBar.tsx @@ -5,6 +5,7 @@ import {getUserInfo} from "@/api/layout"; import {useEffect, useState} from "react"; import {getCmsArticle} from "@/api/cms/cmsArticle"; import {CmsArticle} from "@/api/cms/cmsArticle/model"; +import { goToRegister } from '@/utils/auth' function AddCartBar() { const { router } = getCurrentInstance(); @@ -13,13 +14,8 @@ function AddCartBar() { const [IsLogin, setIsLogin] = useState(false) const onPay = () => { if (!IsLogin) { - Taro.showToast({title: `请先登录`, icon: 'error'}) setTimeout(() => { - Taro.switchTab( - { - url: '/pages/user/user', - }, - ) + goToRegister({ redirect: '/pages/user/user' }) }, 1000) return false; } diff --git a/src/components/GiftCard.tsx b/src/components/GiftCard.tsx index b6f89e6..55a9824 100644 --- a/src/components/GiftCard.tsx +++ b/src/components/GiftCard.tsx @@ -24,7 +24,7 @@ export interface GiftCardProps { faceValue?: string /** 商品原价 */ originalPrice?: string - /** 礼品卡类型:10-实物礼品卡 20-虚拟礼品卡 30-服务礼品卡 */ + /** 礼品卡类型:10-礼品劵 20-虚拟礼品卡 30-服务礼品卡 */ type?: number /** 状态:0-未使用 1-已使用 2-失效 */ status?: number @@ -112,10 +112,10 @@ const GiftCard: React.FC = ({ // 获取礼品卡类型文本 const getTypeText = () => { switch (type) { - case 10: return '实物礼品卡' + case 10: return '礼品劵' case 20: return '虚拟礼品卡' case 30: return '服务礼品卡' - default: return '礼品卡' + default: return '水票' } } diff --git a/src/components/GiftCardGuide.tsx b/src/components/GiftCardGuide.tsx index 6b1db57..b1e7eb1 100644 --- a/src/components/GiftCardGuide.tsx +++ b/src/components/GiftCardGuide.tsx @@ -51,7 +51,7 @@ const GiftCardGuide: React.FC = ({ title: '礼品卡类型说明', icon: , content: [ - '🎁 实物礼品卡:需到指定地址领取商品', + '🎁 礼品劵:需到指定地址领取商品', '💻 虚拟礼品卡:自动发放到账户余额', '🛎️ 服务礼品卡:联系客服预约服务', '⏰ 注意查看有效期,过期无法使用' diff --git a/src/components/GiftCardShare.tsx b/src/components/GiftCardShare.tsx index ea4e217..0d29302 100644 --- a/src/components/GiftCardShare.tsx +++ b/src/components/GiftCardShare.tsx @@ -28,10 +28,10 @@ const GiftCardShare: React.FC = ({ // 获取礼品卡类型文本 const getTypeText = () => { switch (giftCard.type) { - case 10: return '实物礼品卡' + case 10: return '礼品劵' case 20: return '虚拟礼品卡' case 30: return '服务礼品卡' - default: return '礼品卡' + default: return '水票' } } diff --git a/src/components/PaymentCountdown.md b/src/components/PaymentCountdown.md index abe4137..b4c7d9a 100644 --- a/src/components/PaymentCountdown.md +++ b/src/components/PaymentCountdown.md @@ -1,6 +1,6 @@ # PaymentCountdown 支付倒计时组件 -基于订单创建时间的支付倒计时组件,支持静态显示和实时更新两种模式。 +基于订单过期时间(`expirationTime`)的支付倒计时组件,支持静态显示和实时更新两种模式。 ## 功能特性 @@ -19,7 +19,7 @@ import PaymentCountdown from '@/components/PaymentCountdown'; // 订单列表页 - 静态显示 { - const timeLeft = usePaymentCountdown( - order.createTime, - order.payStatus, - true, // 实时更新 - 24 // 24小时超时 - ); + const timeLeft = usePaymentCountdown({ + expirationTime: order.expirationTime, + createTime: order.createTime, // expirationTime 缺失时回退 + payStatus: order.payStatus, + realTime: true, + timeoutHours: 24 + }); const countdownText = formatCountdownText(timeLeft, true); diff --git a/src/components/PaymentCountdown.tsx b/src/components/PaymentCountdown.tsx index c576c9e..2d6048e 100644 --- a/src/components/PaymentCountdown.tsx +++ b/src/components/PaymentCountdown.tsx @@ -11,6 +11,8 @@ import './PaymentCountdown.scss'; export interface PaymentCountdownProps { /** 订单创建时间 */ createTime?: string; + /** 订单过期时间(推荐直接传后端返回的 expirationTime) */ + expirationTime?: string; /** 支付状态 */ payStatus?: boolean; /** 是否实时更新(详情页用true,列表页用false) */ @@ -29,18 +31,25 @@ export interface PaymentCountdownProps { const PaymentCountdown: React.FC = ({ createTime, + expirationTime, payStatus = false, realTime = false, - timeoutHours = 1, + timeoutHours = 24, showSeconds = false, className = '', onExpired, mode = 'badge' }) => { - const timeLeft = usePaymentCountdown(createTime, payStatus, realTime, timeoutHours); + const timeLeft = usePaymentCountdown({ + createTime, + expirationTime, + payStatus, + realTime, + timeoutHours + }); - // 如果已支付或没有创建时间,不显示倒计时 - if (payStatus || !createTime) { + // 如果已支付或没有可计算的截止时间,不显示倒计时 + if (payStatus || (!expirationTime && !createTime)) { return null; } diff --git a/src/components/SimpleQRCodeModal.tsx b/src/components/SimpleQRCodeModal.tsx index 1acde23..c429754 100644 --- a/src/components/SimpleQRCodeModal.tsx +++ b/src/components/SimpleQRCodeModal.tsx @@ -81,7 +81,7 @@ const SimpleQRCodeModal: React.FC = ({ {qrContent ? ( 二维码 = ({ setTimeout(() => { Taro.showModal({ title: '核销成功', - content: '是否继续扫码核销其他礼品卡?', + content: '是否继续扫码核销其他水票/礼品卡?', success: (res) => { if (res.confirm) { handleClick(); // 递归调用继续扫码 diff --git a/src/dealer/apply/add.config.ts b/src/dealer/apply/add.config.ts index ac37521..71f30bd 100644 --- a/src/dealer/apply/add.config.ts +++ b/src/dealer/apply/add.config.ts @@ -1,4 +1,4 @@ export default definePageConfig({ - navigationBarTitleText: '邀请注册', + navigationBarTitleText: '注册成为会员', navigationBarTextStyle: 'black' }) diff --git a/src/dealer/apply/add.tsx b/src/dealer/apply/add.tsx index f862bf3..a6aea7f 100644 --- a/src/dealer/apply/add.tsx +++ b/src/dealer/apply/add.tsx @@ -10,7 +10,9 @@ 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"; +import {addUserRole, listUserRole, updateUserRole} from "@/api/system/userRole"; +import { listRoles } from "@/api/system/role"; +import type { UserRole } from "@/api/system/userRole/model"; // 类型定义 interface ChooseAvatarEvent { @@ -26,7 +28,7 @@ interface InputEvent { } const AddUserAddress = () => { - const {user, loginUser} = useUser() + const {user, loginUser, fetchUserInfo} = useUser() const [loading, setLoading] = useState(true) const [FormData, setFormData] = useState() const formRef = useRef(null) @@ -59,7 +61,7 @@ const AddUserAddress = () => { setFormData(tempFormData) Taro.uploadFile({ - url: 'https://server.websoft.top/api/oss/upload', + url: 'https://glt-server.websoft.top/api/oss/upload', filePath: detail.avatarUrl, name: 'file', header: { @@ -127,7 +129,7 @@ const AddUserAddress = () => { } // 提交表单 - const submitSucceed = async (values: any) => { + const submitSucceed = async (values: User) => { try { // 验证必填字段 if (!values.phone && !FormData?.phone) { @@ -142,8 +144,8 @@ const AddUserAddress = () => { const nickname = values.realName || FormData?.nickname || ''; if (!nickname || nickname.trim() === '') { Taro.showToast({ - title: '请填写昵称', - icon: 'error' + title: '请上传头像和填写昵称', + icon: 'none' }); return; } @@ -176,12 +178,27 @@ const AddUserAddress = () => { } console.log(values,FormData) - const roles = await listUserRole({userId: user?.userId}) - console.log(roles, 'roles...') + if (!user?.userId) { + Taro.showToast({ + title: '用户信息缺失,请先登录', + icon: 'error' + }); + return; + } + + let roles: UserRole[] = []; + try { + roles = await listUserRole({userId: user.userId}) + console.log(roles, 'roles...') + } catch (e) { + // 新用户/权限限制时可能查不到角色列表,不影响基础注册流程 + console.warn('查询用户角色失败,将尝试直接写入默认角色:', e) + roles = [] + } // 准备提交的数据 await updateUser({ - userId: user?.userId, + userId: user.userId, nickname: values.realName || FormData?.nickname, phone: values.phone || FormData?.phone, avatar: values.avatar || FormData?.avatar, @@ -189,17 +206,55 @@ const AddUserAddress = () => { }); await addShopDealerUser({ - userId: user?.userId, + userId: user.userId, realName: values.realName || FormData?.nickname, mobile: values.phone || FormData?.phone, - refereeId: values.refereeId || FormData?.refereeId + refereeId: Number(values.refereeId) || Number(FormData?.refereeId) }) - if (roles.length > 0) { - await updateUserRole({ - ...roles[0], - roleId: 1848 - }) + // 通知其他页面(如“我的”页、分销中心页)刷新经销商信息 + Taro.eventCenter.trigger('dealerUser:changed') + + // 角色为空时这里会导致“注册成功但没有角色”,这里做一次兜底写入默认 user 角色 + try { + // 1) 先尝试通过 roleCode=user 查询角色ID(避免硬编码) + // 2) 取不到就回退到旧的默认ID(1848) + let userRoleId: number | undefined; + try { + // 注意:当前 request.get 的封装不支持 axios 风格的 { params: ... }, + // 某些自动生成的 API 可能无法按参数过滤;这里直接取全量再本地查找更稳。 + const roleList = await listRoles(); + userRoleId = roleList?.find(r => r.roleCode === 'user')?.roleId; + } catch (_) { + // ignore + } + if (!userRoleId) userRoleId = 1848; + + const baseRolePayload = { + userId: user.userId, + tenantId: Number(TenantId), + roleId: userRoleId + }; + + // 后端若已创建 user-role 记录则更新;否则尝试“无id更新”触发创建(多数实现会 upsert) + if (roles.length > 0) { + await updateUserRole({ + ...roles[0], + roleId: userRoleId + }); + } else { + try { + await addUserRole(baseRolePayload); + } catch (_) { + // 兼容后端仅支持 PUT upsert 的情况 + await updateUserRole(baseRolePayload); + } + } + + // 刷新一次用户信息,确保 roles 写回本地缓存,避免“我的”页显示为空/不一致 + await fetchUserInfo(); + } catch (e) { + console.warn('写入默认角色失败(不影响注册成功):', e) } @@ -209,7 +264,8 @@ const AddUserAddress = () => { }); setTimeout(() => { - Taro.navigateBack(); + // “我的”是 tabBar 页面,注册完成后直接切到“我的” + Taro.switchTab({ url: '/pages/user/user' }); }, 1000); } catch (error) { @@ -241,7 +297,7 @@ const AddUserAddress = () => { success: (loginRes) => { if (code) { Taro.request({ - url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone', + url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone', method: 'POST', data: { authCode: loginRes.code, @@ -382,9 +438,9 @@ const AddUserAddress = () => { > - - - + {/**/} + {/* */} + {/**/} { + const {dealerUser} = useDealerUser() + + const [loading, setLoading] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [currentPage, setCurrentPage] = useState(1) + const [hasMore, setHasMore] = useState(true) + const [records, setRecords] = useState([]) + + const getFlowTypeText = (flowType?: number) => { + switch (flowType) { + case 10: + return '佣金收入' + case 20: + return '提现支出' + case 30: + return '转账支出' + case 40: + return '转账收入' + default: + return '资金变动' + } + } + + const getFlowTypeTag = (flowType?: number) => { + // 收入:success;支出:danger;其它:default + if (flowType === 10 || flowType === 40) return 'success' + if (flowType === 20 || flowType === 30) return 'danger' + return 'default' + } + + const formatMoney = (flowType?: number, money?: string) => { + const isIncome = flowType === 10 || flowType === 40 + const isExpense = flowType === 20 || flowType === 30 + const sign = isIncome ? '+' : isExpense ? '-' : '' + return `${sign}${money || '0.00'}` + } + + const fetchRecords = useCallback(async (page: number = 1, isRefresh: boolean = false) => { + if (!dealerUser?.userId) return + + try { + if (isRefresh) { + setRefreshing(true) + } else if (page === 1) { + setLoading(true) + } else { + setLoadingMore(true) + } + + const result = await pageShopDealerCapital({ + page, + limit: PAGE_SIZE, + // 只显示与当前登录用户相关的收益明细 + userId: dealerUser.userId + }) + + const list = result?.list || [] + if (page === 1) { + setRecords(list) + } else { + setRecords(prev => [...prev, ...list]) + } + + setHasMore(list.length === PAGE_SIZE) + setCurrentPage(page) + } catch (error) { + console.error('获取收益明细失败:', error) + Taro.showToast({ + title: '获取收益明细失败', + icon: 'error' + }) + } finally { + setLoading(false) + setRefreshing(false) + setLoadingMore(false) + } + }, [dealerUser?.userId]) + + const handleRefresh = async () => { + await fetchRecords(1, true) + } + + const handleLoadMore = async () => { + if (!loadingMore && hasMore) { + await fetchRecords(currentPage + 1) + } + } + + useEffect(() => { + if (dealerUser?.userId) { + fetchRecords(1) + } + }, [fetchRecords, dealerUser?.userId]) + + if (!dealerUser) { + return ( + + + 加载中... + + ) + } + + return ( + + + + + {loading && records.length === 0 ? ( + + + 加载中... + + ) : records.length > 0 ? ( + <> + {records.map((item) => ( + + + + {item.describe || '收益明细'} + + + {getFlowTypeText(item.flowType)} + + + + + + 佣金收入 + + + {formatMoney(item.flowType, item.money)} + + + + + + {/*用户:{item.userId ?? '-'}*/} + + + {item.createTime || '-'} + + + + ))} + + {loadingMore && ( + + + 加载更多... + + )} + {!hasMore && records.length > 0 && ( + + 没有更多数据了 + + )} + + ) : ( + + )} + + + + + ) +} + +export default DealerCapital diff --git a/src/dealer/index.config.ts b/src/dealer/index.config.ts index d456dbd..babaa66 100644 --- a/src/dealer/index.config.ts +++ b/src/dealer/index.config.ts @@ -1,3 +1,3 @@ export default definePageConfig({ - navigationBarTitleText: '分销中心' + navigationBarTitleText: '账户管理中心' }) diff --git a/src/dealer/index.tsx b/src/dealer/index.tsx index 06c514f..ca1f6d7 100644 --- a/src/dealer/index.tsx +++ b/src/dealer/index.tsx @@ -108,7 +108,7 @@ const DealerIndex: React.FC = () => { - ID: {dealerUser.userId} | 推荐人: {dealerUser.refereeId || '无'} + ID: {dealerUser.userId} @@ -129,26 +129,26 @@ const DealerIndex: React.FC = () => { {dealerUser && ( - 佣金统计 + 资金统计 - + }} onClick={() => navigateToPage('/dealer/withdraw/index')}> {formatMoney(dealerUser.money)} 可提现 - {formatMoney(dealerUser.freezeMoney)} - 冻结中 + 待使用 - @@ -217,7 +217,7 @@ const DealerIndex: React.FC = () => { - navigateToPage('/dealer/withdraw/index')}> + navigateToPage('/dealer/withdraw/index')}> @@ -225,7 +225,7 @@ const DealerIndex: React.FC = () => { - navigateToPage('/dealer/team/index')}> + navigateToPage('/dealer/team/index')}> @@ -233,7 +233,7 @@ const DealerIndex: React.FC = () => { - navigateToPage('/dealer/qrcode/index')}> + navigateToPage('/user/userVerify/index')}> diff --git a/src/dealer/orders/index.tsx b/src/dealer/orders/index.tsx index f3b6bbf..419586c 100644 --- a/src/dealer/orders/index.tsx +++ b/src/dealer/orders/index.tsx @@ -24,7 +24,8 @@ const DealerOrders: React.FC = () => { // 获取订单数据 const fetchOrders = useCallback(async (page: number = 1, isRefresh: boolean = false) => { - if (!dealerUser?.userId) return + // 需要当前登录用户ID(用于 resourceId 参数) + if (!dealerUser || !dealerUser.userId) return try { if (isRefresh) { @@ -37,14 +38,17 @@ const DealerOrders: React.FC = () => { const result = await pageShopDealerOrder({ page, - limit: 10 + limit: 10, + // 后端需要 resourceId=当前登录用户ID 才能正确过滤分销订单 + resourceId: dealerUser.userId }) if (result?.list) { const newOrders = result.list.map(order => ({ ...order, - orderNo: `${order.orderId}`, - customerName: `用户${order.userId}`, + // 优先使用接口返回的订单号;没有则降级展示 orderId + orderNo: order.orderNo ?? (order.orderId != null ? String(order.orderId) : undefined), + customerName: `${order.nickname}${order.userId}`, userCommission: order.firstMoney || '0.00' })) @@ -90,44 +94,53 @@ const DealerOrders: React.FC = () => { } }, [fetchOrders]) - const getStatusText = (isSettled?: number, isInvalid?: number) => { + const getStatusText = (isSettled?: number, isInvalid?: number, isUnfreeze?: number, orderStatus?: number) => { + if (orderStatus === 2 || orderStatus === 5 || orderStatus === 6) return '已取消' if (isInvalid === 1) return '已失效' + if (isUnfreeze === 1) return '已解冻' if (isSettled === 1) return '已结算' return '待结算' } - const getStatusColor = (isSettled?: number, isInvalid?: number) => { + const getStatusColor = (isSettled?: number, isInvalid?: number, isUnfreeze?: number, orderStatus?: number) => { + if (orderStatus === 2 || orderStatus === 5 || orderStatus === 6) return 'default' if (isInvalid === 1) return 'danger' - if (isSettled === 1) return 'success' + if (isUnfreeze === 1) return 'success' + if (isSettled === 1) return 'info' return 'warning' } + const handleGoCapital = () => { + Taro.navigateTo({url: '/dealer/capital/index'}) + } + const renderOrderItem = (order: OrderWithDetails) => ( - + - 订单号:{order.orderNo} + 订单号:{order.orderNo || '-'} - - {getStatusText(order.isSettled, order.isInvalid)} + + {getStatusText(order.isSettled, order.isInvalid, order.isUnfreeze,order.orderStatus)} - - - 订单金额:¥{order.orderPrice || '0.00'} - - - 我的佣金:¥{order.userCommission} - - + {/**/} + {/* */} + {/* 订单金额:¥{order.orderPrice || '0.00'}*/} + {/* */} + {/**/} - 客户:{order.customerName} + {order.createTime} - {order.createTime} + 订单金额:¥{order.orderPrice || '0.00'} diff --git a/src/dealer/qrcode/index.config.ts b/src/dealer/qrcode/index.config.ts index b075b21..7abe843 100644 --- a/src/dealer/qrcode/index.config.ts +++ b/src/dealer/qrcode/index.config.ts @@ -1,3 +1,6 @@ export default definePageConfig({ - navigationBarTitleText: '推广二维码' + navigationBarTitleText: '账户管理中心', + // Enable "Share to friends" and "Share to Moments" (timeline) for this page. + enableShareAppMessage: true, + enableShareTimeline: true }) diff --git a/src/dealer/qrcode/index.tsx b/src/dealer/qrcode/index.tsx index 90edb58..a721389 100644 --- a/src/dealer/qrcode/index.tsx +++ b/src/dealer/qrcode/index.tsx @@ -2,7 +2,7 @@ import React, {useState, useEffect} from 'react' import {View, Text, Image} from '@tarojs/components' import {Button, Loading} from '@nutui/nutui-react-taro' import {Download, QrCode} from '@nutui/icons-react-taro' -import Taro from '@tarojs/taro' +import Taro, {useShareAppMessage} from '@tarojs/taro' import {useDealerUser} from '@/hooks/useDealerUser' import {generateInviteCode} from '@/api/invite' // import type {InviteStats} from '@/api/invite' @@ -10,10 +10,44 @@ import {businessGradients} from '@/styles/gradients' const DealerQrcode: React.FC = () => { const [miniProgramCodeUrl, setMiniProgramCodeUrl] = useState('') - const [loading, setLoading] = useState(false) + const [codeLoading, setCodeLoading] = useState(false) + const [saving, setSaving] = useState(false) // const [inviteStats, setInviteStats] = useState(null) // const [statsLoading, setStatsLoading] = useState(false) - const {dealerUser} = useDealerUser() + const {dealerUser, loading: dealerLoading, error, refresh} = useDealerUser() + + // Enable "转发给朋友" + "分享到朋友圈" items in the share panel/menu. + useEffect(() => { + // Some clients require explicit call to show both share entries. + Taro.showShareMenu({ + withShareTicket: true, + showShareItems: ['shareAppMessage', 'shareTimeline'] + }).catch(() => {}) + }, []) + + // 转发给朋友(分享小程序链接) + useShareAppMessage(() => { + const inviterRaw = dealerUser?.userId ?? Taro.getStorageSync('UserId') + const inviter = Number(inviterRaw) + const hasInviter = Number.isFinite(inviter) && inviter > 0 + + const user = Taro.getStorageSync('User') || {} + const nickname = (user && (user.nickname || user.realName || user.username)) || '' + const title = hasInviter ? `${nickname || '我'}邀请你加入桂乐淘伙伴计划` : '桂乐淘伙伴计划' + + return { + title, + path: hasInviter + ? `/pages/index/index?inviter=${inviter}&source=dealer_qrcode&t=${Date.now()}` + : `/pages/index/index`, + success: function () { + Taro.showToast({title: '分享成功', icon: 'success', duration: 2000}) + }, + fail: function () { + Taro.showToast({title: '分享失败', icon: 'none', duration: 2000}) + } + } + }) // 生成小程序码 const generateMiniProgramCode = async () => { @@ -22,7 +56,7 @@ const DealerQrcode: React.FC = () => { } try { - setLoading(true) + setCodeLoading(true) // 生成邀请小程序码 const codeUrl = await generateInviteCode(dealerUser.userId) @@ -40,7 +74,7 @@ const DealerQrcode: React.FC = () => { // 清空之前的二维码 setMiniProgramCodeUrl('') } finally { - setLoading(false) + setCodeLoading(false) } } @@ -67,6 +101,66 @@ const DealerQrcode: React.FC = () => { } }, [dealerUser?.userId]) + const isAlbumAuthError = (errMsg?: string) => { + if (!errMsg) return false + // WeChat uses variants like: "saveImageToPhotosAlbum:fail auth deny", + // "saveImageToPhotosAlbum:fail auth denied", "authorize:fail auth deny" + return ( + errMsg.includes('auth deny') || + errMsg.includes('auth denied') || + errMsg.includes('authorize') || + errMsg.includes('scope.writePhotosAlbum') + ) + } + + const ensureWriteAlbumPermission = async (): Promise => { + try { + const setting = await Taro.getSetting() + if (setting?.authSetting?.['scope.writePhotosAlbum']) return true + + await Taro.authorize({scope: 'scope.writePhotosAlbum'}) + return true + } catch (error: any) { + const modal = await Taro.showModal({ + title: '提示', + content: '需要您授权保存图片到相册,请在设置中开启相册权限', + confirmText: '去设置' + }) + if (modal.confirm) { + await Taro.openSetting() + } + return false + } + } + + const downloadImageToLocalPath = async (url: string): Promise => { + // saveImageToPhotosAlbum must receive a local temp path (e.g. `http://tmp/...` or `wxfile://...`). + // Some environments may return a non-existing temp path from getImageInfo, so we verify. + if (url.startsWith('http://tmp/') || url.startsWith('wxfile://')) { + return url + } + + const token = Taro.getStorageSync('access_token') + const tenantId = Taro.getStorageSync('TenantId') + const header: Record = {} + if (token) header.Authorization = token + if (tenantId) header.TenantId = tenantId + + // 先下载到本地临时文件再保存到相册 + const res = await Taro.downloadFile({url, header}) + if (res.statusCode !== 200 || !res.tempFilePath) { + throw new Error(`图片下载失败(${res.statusCode || 'unknown'})`) + } + + // Double-check file exists to avoid: saveImageToPhotosAlbum:fail no such file or directory + try { + await Taro.getFileInfo({filePath: res.tempFilePath}) + } catch (_) { + throw new Error('图片临时文件不存在,请重试') + } + return res.tempFilePath + } + // 保存小程序码到相册 const saveMiniProgramCode = async () => { if (!miniProgramCodeUrl) { @@ -78,39 +172,64 @@ const DealerQrcode: React.FC = () => { } try { - // 先下载图片到本地 - const res = await Taro.downloadFile({ - url: miniProgramCodeUrl - }) + if (saving) return + setSaving(true) + Taro.showLoading({title: '保存中...'}) - if (res.statusCode === 200) { - // 保存到相册 - await Taro.saveImageToPhotosAlbum({ - filePath: res.tempFilePath - }) + const hasPermission = await ensureWriteAlbumPermission() + if (!hasPermission) return - Taro.showToast({ - title: '保存成功', - icon: 'success' - }) + let filePath = await downloadImageToLocalPath(miniProgramCodeUrl) + try { + await Taro.saveImageToPhotosAlbum({filePath}) + } catch (e: any) { + const msg = e?.errMsg || e?.message || '' + // Fallback: some devices/clients may fail to save directly from a temp path. + if ( + msg.includes('no such file or directory') && + (filePath.startsWith('http://tmp/') || filePath.startsWith('wxfile://')) + ) { + const saved = (await Taro.saveFile({tempFilePath: filePath})) as unknown as { savedFilePath?: string } + if (saved?.savedFilePath) { + filePath = saved.savedFilePath + } + await Taro.saveImageToPhotosAlbum({filePath}) + } else { + throw e + } } + + Taro.showToast({ + title: '保存成功', + icon: 'success' + }) } catch (error: any) { - if (error.errMsg?.includes('auth deny')) { - Taro.showModal({ + const errMsg = error?.errMsg || error?.message + if (errMsg?.includes('cancel')) { + Taro.showToast({title: '已取消', icon: 'none'}) + return + } + + if (isAlbumAuthError(errMsg)) { + const modal = await Taro.showModal({ title: '提示', content: '需要您授权保存图片到相册', - success: (res) => { - if (res.confirm) { - Taro.openSetting() - } - } + confirmText: '去设置' }) + if (modal.confirm) { + await Taro.openSetting() + } } else { - Taro.showToast({ + // Prefer a modal so we can show the real reason (e.g. domain whitelist / network error). + await Taro.showModal({ title: '保存失败', - icon: 'error' + content: errMsg || '保存失败,请稍后重试', + showCancel: false }) } + } finally { + Taro.hideLoading() + setSaving(false) } } @@ -126,7 +245,7 @@ const DealerQrcode: React.FC = () => { // // const inviteText = `🎉 邀请您加入我的团队! // -// 扫描小程序码或搜索"时里院子市集"小程序,即可享受优质商品和服务! +// 扫描小程序码或搜索"桂乐淘"小程序,即可享受优质商品和服务! // // 💰 成为我的团队成员,一起赚取丰厚佣金 // 🎁 新用户专享优惠等你来拿 @@ -162,7 +281,7 @@ const DealerQrcode: React.FC = () => { // }) // } - if (!dealerUser) { + if (dealerLoading) { return ( @@ -171,6 +290,33 @@ const DealerQrcode: React.FC = () => { ) } + if (error) { + return ( + + 加载失败 + {error} + + + ) + } + + // 未成为分销商时给出明确引导,避免一直停留在“加载中” + if (!dealerUser) { + return ( + + 你还不是分销商 + 申请成为分销商后即可生成分享码 + + + ) + } + return ( {/* 头部卡片 */} @@ -185,9 +331,9 @@ const DealerQrcode: React.FC = () => { }}> - 我的邀请小程序码 + 我的分享码 - 分享小程序码邀请好友,获得丰厚佣金奖励 + 与好友“共享福利 一起省、一起赚” @@ -196,7 +342,7 @@ const DealerQrcode: React.FC = () => { {/* 小程序码展示区 */} - {loading ? ( + {codeLoading ? ( 生成中... @@ -239,10 +385,10 @@ const DealerQrcode: React.FC = () => { )} - 扫码加入我的团队 + 桂乐淘伙伴计划 - 好友扫描小程序码即可直接进入小程序并建立邀请关系 + 自购省 | 分享赚 | 好友惠 @@ -258,34 +404,12 @@ const DealerQrcode: React.FC = () => { block icon={} onClick={saveMiniProgramCode} - disabled={!miniProgramCodeUrl || loading} + disabled={!miniProgramCodeUrl || codeLoading || saving} > 保存小程序码到相册 - {/**/} - {/* }*/} - {/* onClick={copyInviteInfo}*/} - {/* disabled={!dealerUser?.userId || loading}*/} - {/* >*/} - {/* 复制邀请信息*/} - {/* */} - {/**/} - {/**/} - {/* }*/} - {/* onClick={shareMiniProgramCode}*/} - {/* disabled={!dealerUser?.userId || loading}*/} - {/* >*/} - {/* 分享给好友*/} - {/* */} - {/**/} + {/* 推广说明 */} diff --git a/src/dealer/team/index.tsx b/src/dealer/team/index.tsx index af7d8e7..cc22373 100644 --- a/src/dealer/team/index.tsx +++ b/src/dealer/team/index.tsx @@ -325,7 +325,7 @@ const DealerTeam: React.FC = () => { {/* 显示手机号(仅本级可见) */} {showPhone && member.phone && ( - { + { e.stopPropagation(); makePhoneCall(member.phone || ''); }}> diff --git a/src/dealer/withdraw/__tests__/withdraw.test.tsx b/src/dealer/withdraw/__tests__/withdraw.test.tsx deleted file mode 100644 index c3aeab9..0000000 --- a/src/dealer/withdraw/__tests__/withdraw.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -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 -const mockAddShopDealerWithdraw = withdrawAPI.addShopDealerWithdraw as jest.MockedFunction -const mockPageShopDealerWithdraw = withdrawAPI.pageShopDealerWithdraw as jest.MockedFunction - -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() - expect(getByText('10000.00')).toBeInTheDocument() - expect(getByText('可提现余额')).toBeInTheDocument() - }) - - test('应该验证最低提现金额', async () => { - mockAddShopDealerWithdraw.mockResolvedValue('success') - - const { getByPlaceholderText, getByText } = render() - - // 输入低于最低金额的数值 - 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() - - // 输入超过可用余额的金额 - 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() - - // 输入有效金额 - 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() - - // 输入有效金额 - 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() - - // 点击快捷金额按钮 - const quickAmountButton = getByText('500') - fireEvent.click(quickAmountButton) - - // 验证金额输入框的值 - const amountInput = getByPlaceholderText('请输入提现金额') as HTMLInputElement - expect(amountInput.value).toBe('500') - }) - - test('全部按钮应该设置为可用余额', () => { - const { getByText, getByPlaceholderText } = render() - - // 点击全部按钮 - const allButton = getByText('全部') - fireEvent.click(allButton) - - // 验证金额输入框的值 - const amountInput = getByPlaceholderText('请输入提现金额') as HTMLInputElement - expect(amountInput.value).toBe('10000.00') - }) -}) diff --git a/src/dealer/withdraw/index.tsx b/src/dealer/withdraw/index.tsx index 04a3cef..e8274a9 100644 --- a/src/dealer/withdraw/index.tsx +++ b/src/dealer/withdraw/index.tsx @@ -1,13 +1,11 @@ import React, {useState, useRef, useEffect, useCallback} from 'react' import {View, Text} from '@tarojs/components' import { - Cell, Space, Button, Form, Input, CellGroup, - Radio, Tabs, Tag, Empty, @@ -18,32 +16,109 @@ 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 {myUserVerify} from '@/api/system/userVerify' +import {goTo} from '@/utils/navigation' +import { + pageShopDealerWithdraw, + addShopDealerWithdraw, + receiveShopDealerWithdraw, + receiveSuccessShopDealerWithdraw +} from '@/api/shop/shopDealerWithdraw' import type {ShopDealerWithdraw} from '@/api/shop/shopDealerWithdraw/model' interface WithdrawRecordWithDetails extends ShopDealerWithdraw { accountDisplay?: string + // Backend may include these fields for WeChat "confirm receipt" flow after approval. + package_info?: string + packageInfo?: string + package?: string +} + +const extractPackageInfo = (result: unknown): string | null => { + if (typeof result === 'string') return result + if (!result || typeof result !== 'object') return null + const r = result as any + return ( + r.package_info ?? + r.packageInfo ?? + r.package ?? + null + ) +} + +const canRequestMerchantTransferConfirm = (): boolean => { + try { + if (typeof (Taro as any).getEnv === 'function' && (Taro as any).ENV_TYPE) { + const env = (Taro as any).getEnv() + if (env !== (Taro as any).ENV_TYPE.WEAPP) return false + } + + const api = + (globalThis as any).wx?.requestMerchantTransfer || + (Taro as any).requestMerchantTransfer + + return typeof api === 'function' + } catch { + return false + } +} + +const requestMerchantTransferConfirm = (packageInfo: string): Promise => { + if (!canRequestMerchantTransferConfirm()) { + return Promise.reject(new Error('请在微信小程序内完成收款确认')) + } + + // Backend may wrap/format base64 with newlines; WeChat API requires a clean string. + const cleanPackageInfo = String(packageInfo).replace(/\s+/g, '') + + const api = + (globalThis as any).wx?.requestMerchantTransfer || + (Taro as any).requestMerchantTransfer + + if (typeof api !== 'function') { + return Promise.reject(new Error('当前环境不支持商家转账收款确认(缺少 requestMerchantTransfer)')) + } + + return new Promise((resolve, reject) => { + api({ + // WeChat API uses `package`, backend returns `package_info`. + package: cleanPackageInfo, + mchId: '1737910695', + appId: 'wxad831ba00ad6a026', + success: (res: any) => resolve(res), + fail: (err: any) => reject(err) + }) + }) +} + +// Some backends may return money fields as number; keep internal usage always as string. +const normalizeMoneyString = (money: unknown) => { + if (money === null || money === undefined || money === '') return '0.00' + return typeof money === 'string' ? money : String(money) } const DealerWithdraw: React.FC = () => { - const [activeTab, setActiveTab] = useState('0') - const [selectedAccount, setSelectedAccount] = useState('') + const [activeTab, setActiveTab] = useState('0') const [loading, setLoading] = useState(false) const [refreshing, setRefreshing] = useState(false) const [submitting, setSubmitting] = useState(false) + const [claimingId, setClaimingId] = useState(null) const [availableAmount, setAvailableAmount] = useState('0.00') const [withdrawRecords, setWithdrawRecords] = useState([]) const formRef = useRef(null) const {dealerUser} = useDealerUser() + const [verifyStatus, setVerifyStatus] = useState<'unknown' | 'verified' | 'unverified' | 'pending' | 'rejected'>('unknown') + const [verifyStatusText, setVerifyStatusText] = useState('') // Tab 切换处理函数 const handleTabChange = (value: string | number) => { console.log('Tab切换到:', value) - setActiveTab(value) + const next = String(value) + setActiveTab(next) // 如果切换到提现记录页面,刷新数据 - if (String(value) === '1') { + if (next === '1') { fetchWithdrawRecords() } } @@ -52,7 +127,7 @@ const DealerWithdraw: React.FC = () => { const fetchBalance = useCallback(async () => { console.log(dealerUser, 'dealerUser...') try { - setAvailableAmount(dealerUser?.money || '0.00') + setAvailableAmount(normalizeMoneyString(dealerUser?.money)) } catch (error) { console.error('获取余额失败:', error) } @@ -115,12 +190,63 @@ const DealerWithdraw: React.FC = () => { } }, [fetchBalance, fetchWithdrawRecords]) + // 判断实名认证状态:提现前必须完成实名认证(已通过) + const fetchVerifyStatus = useCallback(async () => { + // Fast path: some pages store this flag after login. + if (String(Taro.getStorageSync('Certification')) === '1') { + setVerifyStatus('verified') + setVerifyStatusText('已实名认证') + return + } + + try { + const r = await myUserVerify({}) + if (!r) { + setVerifyStatus('unverified') + setVerifyStatusText('未实名认证') + return + } + + const s = Number((r as any).status) + const st = String((r as any).statusText || '') + + // Common convention in this project: 0审核中/待审核, 1已通过, 2已驳回 + if (s === 1) { + setVerifyStatus('verified') + setVerifyStatusText(st || '已实名认证') + return + } + if (s === 0) { + setVerifyStatus('pending') + setVerifyStatusText(st || '审核中') + return + } + if (s === 2) { + setVerifyStatus('rejected') + setVerifyStatusText(st || '已驳回') + return + } + + setVerifyStatus('unverified') + setVerifyStatusText(st || '未实名认证') + } catch (e) { + console.warn('获取实名认证状态失败,将按未认证处理:', e) + setVerifyStatus('unverified') + setVerifyStatusText('未实名认证') + } + }, []) + + useEffect(() => { + if (!dealerUser?.userId) return + fetchVerifyStatus().then() + }, [dealerUser?.userId, fetchVerifyStatus]) + const getStatusText = (status?: number) => { switch (status) { case 40: return '已到账' case 20: - return '审核通过' + return '待领取' case 10: return '待审核' case 30: @@ -135,7 +261,7 @@ const DealerWithdraw: React.FC = () => { case 40: return 'success' case 20: - return 'success' + return 'info' case 10: return 'warning' case 30: @@ -154,17 +280,17 @@ const DealerWithdraw: React.FC = () => { return } - if (!values.accountType) { + if (verifyStatus !== 'verified') { Taro.showToast({ - title: '请选择提现方式', - icon: 'error' + title: '请先完成实名认证', + icon: 'none' }) return } // 验证提现金额 - const amount = parseFloat(values.amount) - const available = parseFloat(availableAmount.replace(/,/g, '')) + const amount = parseFloat(String(values.amount)) + const available = parseFloat(normalizeMoneyString(availableAmount).replace(/,/g, '')) if (isNaN(amount) || amount <= 0) { Taro.showToast({ @@ -175,72 +301,41 @@ const DealerWithdraw: React.FC = () => { } if (amount < 100) { - Taro.showToast({ - title: '最低提现金额为100元', - icon: 'error' - }) - return + // Taro.showToast({ + // title: '最低提现金额为100元', + // icon: 'error' + // }) + // return } if (amount > available) { Taro.showToast({ title: '提现金额超过可用余额', - icon: 'error' + icon: 'none' }) 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) const withdrawData: ShopDealerWithdraw = { userId: dealerUser.userId, money: values.amount, - payType: values.accountType === 'wechat' ? 10 : - values.accountType === 'alipay' ? 20 : 30, - applyStatus: 10, // 待审核 + // Only support WeChat wallet withdrawals. + payType: 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 || '银行卡' - } - + // Security flow: + // 1) user submits => applyStatus=10 (待审核) + // 2) backend审核通过 => applyStatus=20 (待领取) + // 3) user goes to records to "领取" => applyStatus=40 (已到账) await addShopDealerWithdraw(withdrawData) - - Taro.showToast({ - title: '提现申请已提交', - icon: 'success' - }) + Taro.showToast({title: '提现申请已提交,等待审核', icon: 'success'}) // 重置表单 formRef.current?.resetFields() - setSelectedAccount('') // 刷新数据 await handleRefresh() @@ -259,6 +354,65 @@ const DealerWithdraw: React.FC = () => { } } + const handleClaim = async (record: WithdrawRecordWithDetails) => { + if (!record?.id) { + Taro.showToast({title: '记录不存在', icon: 'error'}) + return + } + + if (record.applyStatus !== 20) { + Taro.showToast({title: '当前状态不可领取', icon: 'none'}) + return + } + + if (record.payType !== 10) { + Taro.showToast({title: '仅支持微信提现领取', icon: 'none'}) + return + } + + if (claimingId !== null) return + + try { + setClaimingId(record.id) + + if (!canRequestMerchantTransferConfirm()) { + throw new Error('当前环境不支持微信收款确认,请在微信小程序内操作') + } + + const receiveResult = await receiveShopDealerWithdraw(record.id) + const packageInfo = extractPackageInfo(receiveResult) + if (!packageInfo) { + throw new Error('后台未返回 package_info,无法领取,请联系管理员') + } + + try { + await requestMerchantTransferConfirm(packageInfo) + } catch (e: any) { + const msg = String(e?.errMsg || e?.message || '') + if (/cancel/i.test(msg)) { + Taro.showToast({title: '已取消领取', icon: 'none'}) + return + } + throw new Error(msg || '领取失败,请稍后重试') + } + + try { + await receiveSuccessShopDealerWithdraw(record.id) + Taro.showToast({title: '领取成功', icon: 'success'}) + } catch (e: any) { + console.warn('领取成功,但状态同步失败:', e) + Taro.showToast({title: '已收款,状态更新失败,请稍后刷新', icon: 'none'}) + } finally { + await handleRefresh() + } + } catch (e: any) { + console.error('领取失败:', e) + Taro.showToast({title: e?.message || '领取失败', icon: 'error'}) + } finally { + setClaimingId(null) + } + } + const quickAmounts = ['100', '300', '500', '1000'] const setQuickAmount = (amount: string) => { @@ -266,17 +420,37 @@ const DealerWithdraw: React.FC = () => { } const setAllAmount = () => { - formRef.current?.setFieldsValue({amount: availableAmount.replace(/,/g, '')}) + formRef.current?.setFieldsValue({amount: normalizeMoneyString(availableAmount).replace(/,/g, '')}) } // 格式化金额 - const formatMoney = (money?: string) => { - if (!money) return '0.00' - return parseFloat(money).toFixed(2) + const formatMoney = (money?: unknown) => { + const n = parseFloat(normalizeMoneyString(money).replace(/,/g, '')) + return Number.isFinite(n) ? n.toFixed(2) : '0.00' + } + + const goVerify = () => { + goTo('/user/userVerify/index') } const renderWithdrawForm = () => ( + {(verifyStatus === 'unverified' || verifyStatus === 'pending' || verifyStatus === 'rejected') && ( + + + + 提现前请先完成实名认证 + {verifyStatusText ? ( + 当前状态:{verifyStatusText} + ) : null} + + + 去认证 + + + + )} + {/* 余额卡片 */} { borderTop: '1px solid rgba(255, 255, 255, 0.3)' }}> - 最低提现金额:¥100 | 手续费:免费 + 手续费:免费 @@ -314,18 +488,10 @@ const DealerWithdraw: React.FC = () => { labelPosition="top" > - + { - // 实时验证提现金额 - const amount = parseFloat(value) - const available = parseFloat(availableAmount.replace(/,/g, '')) - if (!isNaN(amount) && amount > available) { - // 可以在这里添加实时提示,但不阻止输入 - } - }} /> @@ -353,54 +519,14 @@ const DealerWithdraw: React.FC = () => { - - setSelectedAccount}> - - - 微信钱包 - - - 支付宝 - - - 银行卡 - - - - - - {selectedAccount === 'alipay' && ( - <> - - - - - - - - )} - - {selectedAccount === 'bank' && ( - <> - - - - - - - - - - - )} - - {selectedAccount === 'wechat' && ( - - - 微信钱包提现将直接转入您的微信零钱 - - - )} + + + 注意事项: + 1. 提取佣金必须完成实名认证。 + 2. 佣金非自动到账,在您提取佣金申请通过后,请手动到我的申请记录点击领取。 + 3. 桂乐淘温馨提示,请您依法依规申报所得,缴税相关税费。 + + @@ -409,7 +535,7 @@ const DealerWithdraw: React.FC = () => { type="primary" nativeType="submit" loading={submitting} - disabled={submitting || !selectedAccount} + disabled={submitting || verifyStatus !== 'verified'} > {submitting ? '提交中...' : '申请提现'} @@ -433,35 +559,53 @@ const DealerWithdraw: React.FC = () => { 加载中... ) : withdrawRecords.length > 0 ? ( - withdrawRecords.map(record => ( - - - - - 提现金额:¥{record.money} - - - 提现账户:{record.accountDisplay} - - - - {getStatusText(record.applyStatus)} - - + withdrawRecords.map(record => ( + + + + + 提现金额:¥{record.money} + + {/**/} + {/* 提现账户:{record.accountDisplay}*/} + {/**/} + + + {getStatusText(record.applyStatus)} + + - - 申请时间:{record.createTime} - {record.auditTime && ( - - 审核时间:{new Date(record.auditTime).toLocaleString()} - + + {record.applyStatus === 20 && record.payType === 10 && ( + + + )} - {record.rejectReason && ( - - 驳回原因:{record.rejectReason} - - )} - + + + + 创建时间:{record.createTime} + {record.auditTime && ( + + 审核时间:{record.auditTime} + + )} + {record.rejectReason && ( + + 驳回原因:{record.rejectReason} + + )} + + + )) ) : ( @@ -485,13 +629,12 @@ const DealerWithdraw: React.FC = () => { - {renderWithdrawForm()} - {renderWithdrawRecords()} + {activeTab === '0' ? renderWithdrawForm() : renderWithdrawRecords()} ) } diff --git a/src/hooks/useDealerUser.ts b/src/hooks/useDealerUser.ts index 062777d..3195eb6 100644 --- a/src/hooks/useDealerUser.ts +++ b/src/hooks/useDealerUser.ts @@ -1,5 +1,5 @@ import {useState, useEffect, useCallback} from 'react' -import Taro from '@tarojs/taro' +import Taro, { useDidShow } from '@tarojs/taro' import {getShopDealerUser} from '@/api/shop/shopDealerUser' import type {ShopDealerUser} from '@/api/shop/shopDealerUser/model' @@ -22,17 +22,20 @@ export interface UseDealerUserReturn { */ export const useDealerUser = (): UseDealerUserReturn => { const [dealerUser, setDealerUser] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) + const rawUserId = Taro.getStorageSync('UserId') + const userId = Number(rawUserId) + const hasUser = Number.isFinite(userId) && userId > 0 - const userId = Taro.getStorageSync('UserId'); + // If user is logged in, start in loading state to avoid "click too fast" mis-routing. + const [loading, setLoading] = useState(hasUser) + const [error, setError] = useState(null) // 获取经销商用户数据 const fetchDealerData = useCallback(async () => { - if (!userId) { - console.log('🔍 用户未登录,提前返回') + if (!hasUser) { setDealerUser(null) + setLoading(false) return } @@ -55,7 +58,7 @@ export const useDealerUser = (): UseDealerUserReturn => { } finally { setLoading(false) } - }, [userId]) + }, [hasUser, userId]) // 刷新数据 const refresh = useCallback(async () => { @@ -64,13 +67,31 @@ export const useDealerUser = (): UseDealerUserReturn => { // 初始化加载数据 useEffect(() => { - if (userId) { - console.log('🔍 调用 fetchDealerData') + if (hasUser) { fetchDealerData() } else { - console.log('🔍 用户ID不存在,不调用 fetchDealerData') + setDealerUser(null) + setError(null) + setLoading(false) } - }, [fetchDealerData, userId]) + }, [fetchDealerData, hasUser]) + + // 页面返回/切换到前台时刷新一次,避免“注册成为经销商后,页面不更新” + useDidShow(() => { + fetchDealerData() + }) + + // 允许业务侧通过事件主动触发刷新(例如:注册成功后触发) + useEffect(() => { + const handler = () => { + fetchDealerData() + } + // 事件名尽量语义化;后续可在注册成功处 trigger + Taro.eventCenter.on('dealerUser:changed', handler) + return () => { + Taro.eventCenter.off('dealerUser:changed', handler) + } + }, [fetchDealerData]) return { dealerUser, diff --git a/src/hooks/useOrderStats.ts b/src/hooks/useOrderStats.ts index a26c29e..75baa07 100644 --- a/src/hooks/useOrderStats.ts +++ b/src/hooks/useOrderStats.ts @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; -import { UserOrderStats } from '@/api/user'; +import { getUserOrderStats, UserOrderStats } from '@/api/user'; import Taro from '@tarojs/taro'; -import {pageShopOrder} from "@/api/shop/shopOrder"; /** * 订单统计Hook @@ -31,20 +30,17 @@ export const useOrderStats = () => { if(!Taro.getStorageSync('UserId')){ return false; } - // TODO 读取订单数量 - const pending = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 0}) - const paid = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 1}) - const shipped = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 3}) - const completed = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 5}) - const refund = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 6}) - const total = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId')}) + + // 聚合接口:一次请求返回各状态数量(后台按用户做了缓存) + const stats = await getUserOrderStats(); + setOrderStats({ - pending: pending?.count || 0, - paid: paid?.count || 0, - shipped: shipped?.count || 0, - completed: completed?.count || 0, - refund: refund?.count || 0, - total: total?.count || 0 + pending: stats?.pending || 0, + paid: stats?.paid || 0, + shipped: stats?.shipped || 0, + completed: stats?.completed || 0, + refund: stats?.refund || 0, + total: stats?.total || 0 }) if (showToast) { diff --git a/src/hooks/usePaymentCountdown.ts b/src/hooks/usePaymentCountdown.ts index a4caa5f..0377f65 100644 --- a/src/hooks/usePaymentCountdown.ts +++ b/src/hooks/usePaymentCountdown.ts @@ -13,19 +13,30 @@ export interface CountdownTime { totalMinutes: number; // 总剩余分钟数 } +export interface UsePaymentCountdownParams { + /** 订单创建时间(用于兼容:当 expirationTime 缺失时按 createTime + timeoutHours 计算) */ + createTime?: string; + /** 订单过期时间(推荐直接传后端返回的 expirationTime) */ + expirationTime?: string; + /** 支付状态 */ + payStatus?: boolean; + /** 是否实时更新(详情页用true,列表页用false) */ + realTime?: boolean; + /** 超时小时数,默认24小时(仅在 expirationTime 缺失时生效) */ + timeoutHours?: number; +} + /** * 支付倒计时Hook - * @param createTime 订单创建时间 - * @param payStatus 支付状态 - * @param realTime 是否实时更新(详情页用true,列表页用false) - * @param timeoutHours 超时小时数,默认24小时 + * 优先使用 expirationTime;当 expirationTime 缺失时回退到 createTime + timeoutHours。 */ -export const usePaymentCountdown = ( - createTime?: string, - payStatus?: boolean, - realTime: boolean = false, - timeoutHours: number = 24 -): CountdownTime => { +export const usePaymentCountdown = ({ + createTime, + expirationTime, + payStatus, + realTime = false, + timeoutHours = 24 +}: UsePaymentCountdownParams): CountdownTime => { const [timeLeft, setTimeLeft] = useState({ hours: 0, minutes: 0, @@ -37,7 +48,7 @@ export const usePaymentCountdown = ( // 计算剩余时间的函数 const calculateTimeLeft = useMemo(() => { return (): CountdownTime => { - if (!createTime || payStatus) { + if (payStatus || (!expirationTime && !createTime)) { return { hours: 0, minutes: 0, @@ -47,8 +58,27 @@ export const usePaymentCountdown = ( }; } - const createTimeObj = dayjs(createTime); - const expireTime = createTimeObj.add(timeoutHours, 'hour'); + // 优先使用后端过期时间;如果无法解析,再回退到 createTime + timeoutHours + const expireTimeFromExpiration = expirationTime ? dayjs(expirationTime) : null; + const expireTimeFromCreate = + createTime ? dayjs(createTime).add(timeoutHours, 'hour') : null; + const expireTime = + expireTimeFromExpiration?.isValid() + ? expireTimeFromExpiration + : expireTimeFromCreate?.isValid() + ? expireTimeFromCreate + : null; + + if (!expireTime) { + return { + hours: 0, + minutes: 0, + seconds: 0, + isExpired: true, + totalMinutes: 0 + }; + } + const now = dayjs(); const diff = expireTime.diff(now); @@ -76,10 +106,10 @@ export const usePaymentCountdown = ( totalMinutes }; }; - }, [createTime, payStatus, timeoutHours]); + }, [createTime, expirationTime, payStatus, timeoutHours]); useEffect(() => { - if (!createTime || payStatus) { + if (payStatus || (!expirationTime && !createTime)) { setTimeLeft({ hours: 0, minutes: 0, @@ -111,7 +141,7 @@ export const usePaymentCountdown = ( }, 1000); return () => clearInterval(timer); - }, [createTime, payStatus, realTime, calculateTimeLeft]); + }, [createTime, expirationTime, payStatus, realTime, calculateTimeLeft]); return timeLeft; }; diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index f6684da..0621f86 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { gradientThemes, GradientTheme, gradientUtils } from '@/styles/gradients' +import { useState, useEffect, useCallback } from 'react' +import { gradientThemes, type GradientTheme, gradientUtils } from '@/styles/gradients' import Taro from '@tarojs/taro' export interface UseThemeReturn { @@ -14,28 +14,42 @@ export interface UseThemeReturn { * 提供主题切换和状态管理功能 */ export const useTheme = (): UseThemeReturn => { - const [currentTheme, setCurrentTheme] = useState(gradientThemes[0]) - const [isAutoTheme, setIsAutoTheme] = useState(true) - - // 获取当前主题 - const getCurrentTheme = (): GradientTheme => { - const savedTheme = Taro.getStorageSync('user_theme') || 'auto' - - if (savedTheme === 'auto') { - // 自动主题:根据用户ID生成 - const userId = Taro.getStorageSync('userId') || '1' - return gradientUtils.getThemeByUserId(userId) - } else { - // 手动选择的主题 - return gradientThemes.find(t => t.name === savedTheme) || gradientThemes[0] + const getSavedThemeName = useCallback((): string => { + try { + return Taro.getStorageSync('user_theme') || 'nature' + } catch { + return 'nature' } - } + }, []) + + const getStoredUserId = useCallback((): number => { + try { + const raw = Taro.getStorageSync('UserId') ?? Taro.getStorageSync('userId') + const asNumber = typeof raw === 'number' ? raw : parseInt(String(raw || '1'), 10) + return Number.isFinite(asNumber) ? asNumber : 1 + } catch { + return 1 + } + }, []) + + const resolveTheme = useCallback( + (themeName: string): GradientTheme => { + if (themeName === 'auto') { + return gradientUtils.getThemeByUserId(getStoredUserId()) + } + return gradientThemes.find(t => t.name === themeName) || gradientUtils.getThemeByName('nature') || gradientThemes[0] + }, + [getStoredUserId] + ) + + const [isAutoTheme, setIsAutoTheme] = useState(() => getSavedThemeName() === 'auto') + const [currentTheme, setCurrentTheme] = useState(() => resolveTheme(getSavedThemeName())) // 初始化主题 useEffect(() => { - const savedTheme = Taro.getStorageSync('user_theme') || 'auto' + const savedTheme = getSavedThemeName() setIsAutoTheme(savedTheme === 'auto') - setCurrentTheme(getCurrentTheme()) + setCurrentTheme(resolveTheme(savedTheme)) }, []) // 设置主题 @@ -43,7 +57,7 @@ export const useTheme = (): UseThemeReturn => { try { Taro.setStorageSync('user_theme', themeName) setIsAutoTheme(themeName === 'auto') - setCurrentTheme(getCurrentTheme()) + setCurrentTheme(resolveTheme(themeName)) } catch (error) { console.error('保存主题失败:', error) } @@ -51,7 +65,7 @@ export const useTheme = (): UseThemeReturn => { // 刷新主题(用于自动主题模式下用户信息变更时) const refreshTheme = () => { - setCurrentTheme(getCurrentTheme()) + setCurrentTheme(resolveTheme(getSavedThemeName())) } return { diff --git a/src/hooks/useUnifiedQRScan.ts b/src/hooks/useUnifiedQRScan.ts index 5468046..f2a2b8d 100644 --- a/src/hooks/useUnifiedQRScan.ts +++ b/src/hooks/useUnifiedQRScan.ts @@ -5,6 +5,7 @@ import { parseQRContent } from '@/api/passport/qr-login'; import { getShopGiftByCode, updateShopGift, decryptQrData } from "@/api/shop/shopGift"; +import { getGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket'; import { useUser } from "@/hooks/useUser"; import { isValidJSON } from "@/utils/jsonUtils"; import dayjs from 'dayjs'; @@ -29,6 +30,15 @@ export enum ScanType { UNKNOWN = 'unknown' // 未知类型 } +type VerificationBusinessType = 'gift' | 'ticket'; + +interface TicketVerificationPayload { + userTicketId: number; + qty?: number; + userId?: number; + t?: number; +} + /** * 统一扫码结果 */ @@ -73,7 +83,11 @@ export function useUnifiedQRScan() { // 1. 检查是否为JSON格式(核销二维码) if (isValidJSON(scanResult)) { const json = JSON.parse(scanResult); - if (json.businessType === 'gift' && json.token && json.data) { + if ((json.businessType === 'gift' || json.businessType === 'ticket') && json.token && json.data) { + return ScanType.VERIFICATION; + } + // Allow plaintext (non-encrypted) ticket verification payload for debugging/internal use. + if (json.userTicketId) { return ScanType.VERIFICATION; } } @@ -130,35 +144,79 @@ export function useUnifiedQRScan() { throw new Error('您没有核销权限'); } - let code = ''; + let businessType: VerificationBusinessType = 'gift'; + let decryptedOrRaw = ''; // 判断是否为加密的JSON格式 if (isValidJSON(scanResult)) { const json = JSON.parse(scanResult); - if (json.businessType === 'gift' && json.token && json.data) { - // 解密获取核销码 + if ((json.businessType === 'gift' || json.businessType === 'ticket') && json.token && json.data) { + businessType = json.businessType; + // 解密获取核销内容 const decryptedData = await decryptQrData({ token: json.token, encryptedData: json.data }); if (decryptedData) { - code = decryptedData.toString(); + decryptedOrRaw = decryptedData.toString(); } else { throw new Error('解密失败'); } + } else if (json.userTicketId) { + businessType = 'ticket'; + decryptedOrRaw = scanResult.trim(); } } else { - // 直接使用扫码结果作为核销码 - code = scanResult.trim(); + // 直接使用扫码结果作为核销内容 + decryptedOrRaw = scanResult.trim(); } - if (!code) { + if (!decryptedOrRaw) { throw new Error('无法获取有效的核销码'); } - // 验证核销码 - const gift = await getShopGiftByCode(code); + if (businessType === 'ticket') { + if (!isValidJSON(decryptedOrRaw)) { + throw new Error('水票核销信息格式错误'); + } + const payload = JSON.parse(decryptedOrRaw) as TicketVerificationPayload; + const userTicketId = Number(payload.userTicketId); + const qty = Math.max(1, Number(payload.qty || 1)); + if (!Number.isFinite(userTicketId) || userTicketId <= 0) { + throw new Error('水票核销信息无效'); + } + + const ticket = await getGltUserTicket(userTicketId); + if (!ticket) throw new Error('水票不存在'); + if (ticket.status === 1) throw new Error('该水票已冻结'); + const available = Number(ticket.availableQty || 0); + const used = Number(ticket.usedQty || 0); + if (available < qty) throw new Error('水票可用次数不足'); + + await updateGltUserTicket({ + ...ticket, + availableQty: available - qty, + usedQty: used + qty + }); + + return { + type: ScanType.VERIFICATION, + data: { + businessType: 'ticket', + ticket: { + ...ticket, + availableQty: available - qty, + usedQty: used + qty + }, + qty + }, + message: `核销成功(已使用${qty}次)` + }; + } + + // 验证礼品卡核销码 + const gift = await getShopGiftByCode(decryptedOrRaw); if (!gift) { throw new Error('核销码无效'); @@ -187,7 +245,7 @@ export function useUnifiedQRScan() { return { type: ScanType.VERIFICATION, - data: gift, + data: { businessType: 'gift', gift }, message: '核销成功' }; }, [isAdmin]); @@ -213,7 +271,14 @@ export function useUnifiedQRScan() { } }, fail: (err) => { - reject(new Error(err.errMsg || '扫码失败')); + const msg = (err as any)?.errMsg || ''; + // `scanCode:fail cancel` is a user-driven cancel; don't treat it as an error toast. + if (typeof msg === 'string' && msg.toLowerCase().includes('cancel')) { + cancelRef.current = true; + reject(new Error('取消扫码')); + return; + } + reject(new Error(msg || '扫码失败')); } }); }); @@ -265,6 +330,11 @@ export function useUnifiedQRScan() { return result; } catch (err: any) { + // User cancelled scanning (e.g. `scanCode:fail cancel`). + if (cancelRef.current) { + reset(); + return null; + } if (!cancelRef.current) { setState(UnifiedScanState.ERROR); const errorMessage = err.message || '处理失败'; diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index 479a0f3..df5c040 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -3,7 +3,7 @@ import Taro from '@tarojs/taro'; import { User } from '@/api/system/user/model'; import { getUserInfo, updateUserInfo, loginByOpenId } from '@/api/layout'; import { TenantId } from '@/config/app'; -import {getStoredInviteParams, handleInviteRelation} from '@/utils/invite'; +import { handleInviteRelation } from '@/utils/invite'; // 用户Hook export const useUser = () => { @@ -44,15 +44,10 @@ export const useUser = () => { 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' - }); - } + // 登录失败(通常是新用户尚未注册/未绑定手机号等)。 + // 这里不做任何“自动跳转”,避免用户点击「我的」时被强制带到分销/申请页,体验割裂。 + // 需要登录的页面请使用 utils/auth 的 ensureLoggedIn / goToRegister 做显式跳转。 + reject(new Error('autoLoginByOpenId failed')); }); }, fail: reject @@ -60,7 +55,11 @@ export const useUser = () => { }); return res; } catch (error) { - console.error('自动登录失败:', error); + const msg = error instanceof Error ? error.message : String(error); + // 新用户首次进入、未绑定手机号等场景属于“预期失败”,避免刷屏报错。 + if (msg !== 'autoLoginByOpenId failed') { + console.error('自动登录失败:', error); + } return null; } }; @@ -280,11 +279,14 @@ export const useUser = () => { // 检查用户是否是管理员 const isAdmin = () => { - return user?.isAdmin === true; + // Some backends use `1/0` (or `1/2`) instead of boolean. + const v: any = (user as any)?.isAdmin; + return v === true || v === 1 || v === '1'; }; const isSuperAdmin = () => { - return user?.isSuperAdmin === true; + const v: any = (user as any)?.isSuperAdmin; + return v === true || v === 1 || v === '1'; }; // 获取用户余额 diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts index fcc0423..4c5e462 100644 --- a/src/hooks/useUserData.ts +++ b/src/hooks/useUserData.ts @@ -1,12 +1,10 @@ import { useState, useEffect, useCallback } from 'react' -import {pageShopUserCoupon} from "@/api/shop/shopUserCoupon"; -import {pageShopGift} from "@/api/shop/shopGift"; import {useUser} from "@/hooks/useUser"; import Taro from '@tarojs/taro' -import {getUserInfo} from "@/api/layout"; +import { getUserCardStats } from '@/api/user' interface UserData { - balance: number + balance: string points: number coupons: number giftCards: number @@ -24,7 +22,7 @@ interface UseUserDataReturn { loading: boolean error: string | null refresh: () => Promise - updateBalance: (newBalance: number) => void + updateBalance: (newBalance: string) => void updatePoints: (newPoints: number) => void } @@ -43,18 +41,14 @@ export const useUserData = (): UseUserDataReturn => { return; } - // 并发请求所有数据 - const [userDataRes, couponsRes, giftCardsRes] = await Promise.all([ - getUserInfo(), - pageShopUserCoupon({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), status: 0}), - pageShopGift({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), status: 0}) - ]) + // 聚合接口:一次请求返回余额/积分/优惠券/礼品卡统计(后端可按用户做缓存) + const stats = await getUserCardStats() const newData: UserData = { - balance: userDataRes?.balance || 0.00, - points: userDataRes?.points || 0, - coupons: couponsRes?.count || 0, - giftCards: giftCardsRes?.count || 0, + balance: stats?.balance || '0.00', + points: stats?.points || 0, + coupons: stats?.coupons || 0, + giftCards: stats?.giftCards || 0, orders: { pending: 0, paid: 0, @@ -78,7 +72,7 @@ export const useUserData = (): UseUserDataReturn => { }, [fetchUserData]) // 更新余额(本地更新,避免频繁请求) - const updateBalance = useCallback((newBalance: number) => { + const updateBalance = useCallback((newBalance: string) => { setData(prev => prev ? { ...prev, balance: newBalance } : null) }, []) diff --git a/src/pages/cart/cart.tsx b/src/pages/cart/cart.tsx index e415a03..ae5ffdb 100644 --- a/src/pages/cart/cart.tsx +++ b/src/pages/cart/cart.tsx @@ -10,10 +10,11 @@ import { Divider, ConfigProvider } from '@nutui/nutui-react-taro'; -import {ArrowLeft, Del} from '@nutui/icons-react-taro'; +import {Del} from '@nutui/icons-react-taro'; import {View} from '@tarojs/components'; import {CartItem, useCart} from "@/hooks/useCart"; import './cart.scss'; +import { ensureLoggedIn } from '@/utils/auth' function Cart() { const [statusBarHeight, setStatusBarHeight] = useState(0); @@ -41,7 +42,7 @@ function Cart() { useShareAppMessage(() => { return { - title: '购物车 - 时里院子市集', + title: '购物车 - 桂乐淘', success: function () { console.log('分享成功'); }, @@ -150,6 +151,9 @@ function Cart() { // 将选中的商品信息存储到本地,供结算页面使用 Taro.setStorageSync('checkout_items', JSON.stringify(selectedCartItems)); + // 未登录则引导去注册/登录;登录后回到购物车结算页 + if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return + // 跳转到购物车结算页面 Taro.navigateTo({ url: '/shop/orderConfirmCart/index' @@ -171,7 +175,6 @@ function Cart() { Taro.navigateBack()}/>} right={ cartItems.length > 0 && ( + ) : null + } + > + {getTenantName()} + + + setStorePopupVisible(false)} + > + + + 选择门店 + setStorePopupVisible(false)} + > + 关闭 + + + + + {userLocation ? '已获取定位,按距离排序' : '未获取定位,可手动选择门店'} + + + + {[...stores] + .sort((a, b) => (getStoreDistance(a) ?? Number.POSITIVE_INFINITY) - (getStoreDistance(b) ?? Number.POSITIVE_INFINITY)) + .map((s) => { + const d = getStoreDistance(s) + const isActive = !!selectedStore?.id && selectedStore.id === s.id + return ( + + {s.name || `门店${s.id}`} + {d !== undefined && {formatDistance(d)}} + + } + description={s.address || ''} + onClick={async () => { + let storeToSave = s + if (s?.id) { + try { + const full = await getShopStore(s.id) + if (full) storeToSave = full + } catch (_e) { + // keep base item + } + } + setSelectedStore(storeToSave) + saveSelectedStoreToStorage(storeToSave) + setStorePopupVisible(false) + Taro.showToast({title: '门店已切换', icon: 'success'}) + }} + /> + ) + })} + - { - }} - left={ - !IsLogin ? ( - - - - ) : ( - - - {getWebsiteName()} - - - )}> - {/**/} - - + ) } diff --git a/src/pages/index/Login.tsx b/src/pages/index/Login.tsx index 9c88ef7..c3e3459 100644 --- a/src/pages/index/Login.tsx +++ b/src/pages/index/Login.tsx @@ -49,7 +49,7 @@ const Login = (props: LoginProps) => { success: function () { if (code) { Taro.request({ - url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone', + url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone', method: 'POST', data: { code, diff --git a/src/pages/index/MySearch.tsx b/src/pages/index/MySearch.tsx index 3c6d9a5..2015ee6 100644 --- a/src/pages/index/MySearch.tsx +++ b/src/pages/index/MySearch.tsx @@ -30,7 +30,7 @@ function MySearch(props: any) { return ( -
+
(false) - // Tabs粘性状态 - const [_, setTabsStickyStatus] = useState(false) + const [activeTabKey, setActiveTabKey] = useState('recommend') + const [goodsList, setGoodsList] = useState([]) + const [ticketTotal, setTicketTotal] = useState(0) useShareAppMessage(() => { // 获取当前用户ID,用于生成邀请链接 const userId = Taro.getStorageSync('UserId'); + const user = Taro.getStorageSync('User') || {}; + const nickname = + (user && (user.nickname || user.realName || user.username)) || ''; return { - title: '🏠 首页 🏠', + title: (nickname || '') + '超值推荐', path: userId ? `/pages/index/index?inviter=${userId}&source=share&t=${Date.now()}` : `/pages/index/index`, success: function () { console.log('首页分享成功'); @@ -85,14 +91,30 @@ function Home() { // } // 处理Tabs粘性状态变化 - const handleTabsStickyChange = (isSticky: boolean) => { - setTabsStickyStatus(isSticky) - } + // const handleTabsStickyChange = (isSticky: boolean) => {} const reload = () => { - + const token = Taro.getStorageSync('access_token') + const userIdRaw = Taro.getStorageSync('UserId') + const userId = Number(userIdRaw) + const hasUserId = Number.isFinite(userId) && userId > 0 + if (!token && !hasUserId) { + setTicketTotal(0) + return + } + getMyGltUserTicketTotal(hasUserId ? userId : undefined) + .then((total) => setTicketTotal(typeof total === 'number' ? total : 0)) + .catch((err) => { + console.error('首页水票总数加载失败:', err) + setTicketTotal(0) + }) }; + // 回到首页/首次进入时都刷新一次(避免依赖 scope.userInfo 导致不触发 reload) + useDidShow(() => { + reload() + }) + useEffect(() => { // 获取站点信息 getShopInfo().then(() => { @@ -135,7 +157,6 @@ function Home() { if (res.authSetting['scope.userInfo']) { // 用户已经授权过,可以直接获取用户信息 console.log('用户已经授权过,可以直接获取用户信息') - reload(); } else { // 用户未授权,需要弹出授权窗口 console.log('用户未授权,需要弹出授权窗口') @@ -147,21 +168,210 @@ function Home() { Taro.getUserInfo({ success: (res) => { const avatar = res.userInfo.avatarUrl; + // Keep WeChat display name in storage so share title can use it. console.log(avatar, 'avatarUrl') } }); }, []); + const tabs = useMemo< + Array<{ key: string; title: string; params: Partial }> + >( + () => [ + { key: 'recommend', title: '推荐', params: { recommend: 1 } }, + { key: '4476', title: '桶装水', params: { categoryId: 4476 } }, + { key: '4556', title: '水票套餐', params: { categoryId: 4556 } }, + // { key: '4557', title: '购机套餐', params: { categoryId: 4557 } }, + // { key: '4477', title: '饮水设备', params: { categoryId: 4477 } }, + ], + [] + ) + + useEffect(() => { + const tab = tabs.find((t) => t.key === activeTabKey) || tabs[0] + if (!tab) return + + pageShopGoods({ ...tab.params, status: 0 }) + .then((res) => setGoodsList((res?.list || []).filter((g) => g?.status === 0))) + .catch((err) => { + console.error('首页商品列表加载失败:', err) + setGoodsList([]) + }) + }, [activeTabKey, tabs]) + + const shortcuts = useMemo< + Array<{ key: string; title: string; icon: ReactNode; onClick: () => void }> + >( + () => [ + { + key: 'ticket', + title: '我的水票', + icon: , + onClick: () => { + if (!ensureLoggedIn('/user/ticket/index')) return + Taro.navigateTo({ url: '/user/ticket/index' }) + }, + }, + { + key: 'order', + title: '立即送水', + icon: , + onClick: () => { + if (!ensureLoggedIn('/user/ticket/use?goodsId=10074')) return + Taro.navigateTo({ url: '/user/ticket/use?goodsId=10074' }) + }, + }, + { + key: 'order', + title: '送水订单', + icon: , + onClick: () => { + if (!ensureLoggedIn('/user/ticket/index')) return + Taro.navigateTo({ url: '/user/ticket/index' }) + }, + }, + { + key: 'invite', + title: '邀请有礼', + icon: , + onClick: () => { + if (!ensureLoggedIn('/dealer/qrcode/index')) return + Taro.navigateTo({ url: '/dealer/qrcode/index' }) + }, + }, + // { + // key: 'coupon', + // title: '领券中心', + // icon: , + // onClick: () => Taro.navigateTo({ url: '/coupon/index' }), + // }, + ], + [] + ) + + const visibleGoods = useMemo(() => { + // 先按效果图展示两列卡片,数据不够时也保持布局稳定 + const list = goodsList || [] + if (list.length <= 6) return list + return list.slice(0, 6) + }, [goodsList]) + return ( <> - {/* Header区域 - 现在由Header组件内部处理吸顶逻辑 */} -
+ {/* Header区域 */} + {/*
*/} -
- - - -
+ + {/* 顶部活动主视觉:使用 Banner 组件 */} + + + {/* 电子水票 */} + + + 电子水票 + + 您还有 {ticketTotal} 张水票 + + + + + + {shortcuts.map((item) => ( + + {item.icon} + {item.title} + + ))} + + + + + Taro.navigateTo({ url: `/shop/category/index?id=4560` })}> + + 政企采购专区 + + + + + Taro.navigateTo({ url: `/shop/category/index?id=4556` })}> + + 桂乐淘·福利惊爆区 + + + + + {/*分类Tabs*/} + + + {tabs.map((tab) => { + const active = tab.key === activeTabKey + return ( + setActiveTabKey(tab.key)} + > + {tab.title} + + ) + })} + + + {/* 商品列表 */} + + {visibleGoods.map((item) => ( + + + + Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${item.goodsId}` }) + } + /> + + + + {item.name} + + 已购:{item.sales || 0}人 + + + {item.buyingPrice} + + + + + {/* {*/} + {/* if (!ensureLoggedIn('/shop/orderConfirm/index?goodsId=10074')) return*/} + {/* Taro.navigateTo({ url: '/shop/orderConfirm/index?goodsId=10074' })*/} + {/* }}*/} + {/*>*/} + {/* 买水票更优惠*/} + {/**/} + + Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${item.goodsId}` }) + } + > + 立即购买 + + + + + ))} + + + ) } diff --git a/src/pages/user/components/IsDealer.tsx b/src/pages/user/components/IsDealer.tsx index a5fd05e..88d3008 100644 --- a/src/pages/user/components/IsDealer.tsx +++ b/src/pages/user/components/IsDealer.tsx @@ -6,12 +6,13 @@ import {useUser} from '@/hooks/useUser' import {useDealerUser} from "@/hooks/useDealerUser"; import {useThemeStyles} from "@/hooks/useTheme"; import { useConfig } from "@/hooks/useConfig"; // 使用新的自定义Hook +import Taro from '@tarojs/taro' const IsDealer = () => { const themeStyles = useThemeStyles(); const { config } = useConfig(); // 使用新的Hook const {isSuperAdmin} = useUser(); - const {dealerUser} = useDealerUser() + const {dealerUser, loading: dealerLoading} = useDealerUser() /** * 管理中心 @@ -51,7 +52,7 @@ const IsDealer = () => { {config?.vipText || '入驻申请'} + className={'pl-3 text-orange-100 font-medium'}>{config?.vipText || '账户管理中心'} {/*门店核销*/} } @@ -75,12 +76,18 @@ const IsDealer = () => { title={ - {config?.vipText || '开通VIP'} - {config?.vipComments || '享优惠'} + {config?.vipText || '账户管理中心'} + {config?.vipComments || ''} } extra={} - onClick={() => navTo('/dealer/apply/add', true)} + onClick={() => { + if (dealerLoading) { + Taro.showToast({ title: '正在加载信息,请稍等...', icon: 'none' }) + return + } + navTo('/dealer/apply/add', true) + }} /> diff --git a/src/pages/user/components/UserCard.tsx b/src/pages/user/components/UserCard.tsx index b2e2dc8..93c76b4 100644 --- a/src/pages/user/components/UserCard.tsx +++ b/src/pages/user/components/UserCard.tsx @@ -8,33 +8,94 @@ import navTo from "@/utils/common"; import {TenantId} from "@/config/app"; import {useUser} from "@/hooks/useUser"; import {useUserData} from "@/hooks/useUserData"; -import {getStoredInviteParams} from "@/utils/invite"; +import {checkAndHandleInviteRelation, getStoredInviteParams, hasPendingInvite} from "@/utils/invite"; import UnifiedQRButton from "@/components/UnifiedQRButton"; import {useThemeStyles} from "@/hooks/useTheme"; +import {getRootDomain} from "@/utils/domain"; +import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket' +import { saveStorageByLoginUser } from '@/utils/server' const UserCard = forwardRef((_, ref) => { const {data, refresh} = useUserData() - const {getDisplayName, getRoleName} = useUser(); + const {loadUserFromStorage} = useUser(); const [IsLogin, setIsLogin] = useState(false) const [userInfo, setUserInfo] = useState() + const [ticketTotal, setTicketTotal] = useState(0) const themeStyles = useThemeStyles(); + const canShowScanButton = (() => { + const v: any = (userInfo as any)?.isAdmin + return v === true || v === 1 || v === '1' + })() + + const getDisplayName = () => { + if (!userInfo) return IsLogin ? '用户' : '点击登录' + return userInfo.nickname || (userInfo as any).realName || (userInfo as any).username || (IsLogin ? '用户' : '点击登录') + } + + // 角色名称:优先取用户 roles 数组的第一个角色名称 + const getRoleName = () => { + return userInfo?.roles?.[0]?.roleName || userInfo?.roleName || '注册用户' + } // 下拉刷新 - const handleRefresh = async () => { + const reloadStats = async (showToast = false) => { await refresh() - Taro.showToast({ - title: '刷新成功', - icon: 'success' - }) + reloadTicketTotal() + if (showToast) { + Taro.showToast({ + title: '刷新成功', + icon: 'success' + }) + } + } + + const syncUserToStorage = (u: User) => { + // Keep storage up-to-date for other places that read user info synchronously. + Taro.setStorageSync('User', u) + if (u?.userId) Taro.setStorageSync('UserId', u.userId) + if (u?.nickname) Taro.setStorageSync('WxNickName', u.nickname) + } + + const reloadUserInfo = async () => { + try { + const u = await getUserInfo() + if (u) { + setUserInfo(u) + setIsLogin(true) + syncUserToStorage(u) + // Refresh this hook instance's state from storage (defensive). + await loadUserFromStorage() + + // 获取openId(不阻塞 UI 刷新) + if (!u.openid) { + Taro.login({ + success: (res) => { + getWxOpenId({code: res.code}).catch(() => {}) + } + }) + } + } + } catch (e) { + // Not logged in / token expired: keep UI in "not login" state. + // Other error handling is done in request interceptor / callers. + } } // 暴露方法给父组件 useImperativeHandle(ref, () => ({ - handleRefresh + handleRefresh: async () => { + await reloadUserInfo() + await reloadStats(true) + }, + reloadStats, + reloadUserInfo })) useEffect(() => { + // 独立于用户信息授权:只要有登录 token,就可以拉取水票总数 + reloadTicketTotal() + // Taro.getSetting:获取用户的当前设置。返回值中只会出现小程序已经向用户请求过的权限。 Taro.getSetting({ success: (res) => { @@ -51,6 +112,23 @@ const UserCard = forwardRef((_, ref) => { }); }, []); + const reloadTicketTotal = () => { + const token = Taro.getStorageSync('access_token') + const userIdRaw = Taro.getStorageSync('UserId') + const userId = Number(userIdRaw) + const hasUserId = Number.isFinite(userId) && userId > 0 + if (!token && !hasUserId) { + setTicketTotal(0) + return + } + getMyGltUserTicketTotal(hasUserId ? userId : undefined) + .then((total) => setTicketTotal(typeof total === 'number' ? total : 0)) + .catch((err) => { + console.error('个人中心水票总数加载失败:', err) + setTicketTotal(0) + }) + } + const reload = () => { Taro.getUserInfo({ success: (res) => { @@ -60,25 +138,15 @@ const UserCard = forwardRef((_, ref) => { nickname: res.userInfo.nickName, sexName: res.userInfo.gender == 1 ? '男' : '女' }) - getUserInfo().then((data) => { - if (data) { - setUserInfo(data) - setIsLogin(true); - Taro.setStorageSync('UserId', data.userId) - - // 获取openId - if (!data.openid) { - Taro.login({ - success: (res) => { - getWxOpenId({code: res.code}).then(() => { - }) - } - }) - } - } - }).catch(() => { - console.log('未登录') - }); + reloadUserInfo() + .then(() => { + // 登录态已就绪后刷新卡片统计(余额/积分/券/水票) + refresh().then() + reloadTicketTotal() + }) + .catch(() => { + console.log('未登录') + }) } }); }; @@ -133,7 +201,7 @@ const UserCard = forwardRef((_, ref) => { success: function () { if (code) { Taro.request({ - url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone', + url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone', method: 'POST', data: { code, @@ -158,10 +226,19 @@ const UserCard = forwardRef((_, ref) => { return false; } // 登录成功 - Taro.setStorageSync('access_token', res.data.data.access_token) - Taro.setStorageSync('UserId', res.data.data.user.userId) + saveStorageByLoginUser(res.data.data.access_token, res.data.data.user) setUserInfo(res.data.data.user) setIsLogin(true) + // 登录态已就绪后刷新卡片统计(余额/积分/券/水票) + refresh().then() + reloadTicketTotal() + + // 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定 + if (hasPendingInvite()) { + checkAndHandleInviteRelation().catch((e) => { + console.error('个人中心登录后处理邀请关系失败:', e) + }) + } } }) } else { @@ -189,7 +266,9 @@ const UserCard = forwardRef((_, ref) => { /> {getDisplayName() || '点击登录'} - {getRoleName()} + {getRootDomain() && ( + {getRoleName()} + )} @@ -209,33 +288,62 @@ const UserCard = forwardRef((_, ref) => { )} - - {/*统一扫码入口 - 支持登录和核销*/} - { - console.log('统一扫码成功:', result); - // 根据扫码类型给出不同的提示 - if (result.type === 'verification') { - // 核销成功,可以显示更多信息或跳转到详情页 - Taro.showModal({ - title: '核销成功', - content: `已成功核销的品类:${result.data.goodsName || '礼品卡'},面值¥${result.data.faceValue}` - }); - } - }} - onError={(error) => { - console.error('统一扫码失败:', error); - }} - /> - + {/*统一扫码入口 - 仅管理员可见*/} + {canShowScanButton && ( + + { + console.log('统一扫码成功:', result); + // 根据扫码类型给出不同的提示 + if (result.type === 'verification') { + const businessType = result?.data?.businessType; + if (businessType === 'gift' && result?.data?.gift) { + const gift = result.data.gift; + Taro.showModal({ + title: '核销成功', + content: `已成功核销:${gift.goodsName || gift.name || '礼品'},面值¥${gift.faceValue}` + }); + return; + } + if (businessType === 'ticket' && result?.data?.ticket) { + const ticket = result.data.ticket; + const qty = result.data.qty || 1; + Taro.showModal({ + title: '核销成功', + content: `已成功核销:${ticket.templateName || '水票'},本次使用${qty}次,剩余可用${ticket.availableQty ?? 0}次` + }); + return; + } + Taro.showModal({ + title: '核销成功', + content: '已成功核销' + }); + } + }} + onError={(error) => { + console.error('统一扫码失败:', error); + }} + /> + + )} + navTo('/user/ticket/index', true)}> + 水票 + {ticketTotal} + + navTo('/user/coupon/index', true)}> + 优惠券 + {data?.coupons || 0} + navTo('/user/wallet/wallet', true)}> 余额 @@ -245,16 +353,6 @@ const UserCard = forwardRef((_, ref) => { 积分 {data?.points || 0} - navTo('/user/coupon/index', true)}> - 优惠券 - {data?.coupons || 0} - - navTo('/user/gift/index', true)}> - 礼品卡 - {data?.giftCards || 0} - diff --git a/src/pages/user/components/UserCell.tsx b/src/pages/user/components/UserCell.tsx index 46a0882..9354b40 100644 --- a/src/pages/user/components/UserCell.tsx +++ b/src/pages/user/components/UserCell.tsx @@ -55,7 +55,7 @@ const UserCell = () => { title={ - 收货地址 + 配送地址 } align="center" diff --git a/src/pages/user/components/UserFooter.tsx b/src/pages/user/components/UserFooter.tsx index fb74b71..a021a0f 100644 --- a/src/pages/user/components/UserFooter.tsx +++ b/src/pages/user/components/UserFooter.tsx @@ -47,8 +47,9 @@ const UserFooter = () => { return ( <>
-
当前版本:{Version}
-
Copyright © { new Date().getFullYear() } {Copyright}
+ {/*
当前版本:{Version}
*/} + {/*
Copyright © { new Date().getFullYear() } {Copyright}
*/} +
{Copyright}
{ - const {logoutUser} = useUser(); + const {logoutUser, hasRole} = useUser(); const onLogout = () => { Taro.showModal({ @@ -38,7 +39,7 @@ const UserCell = () => { return ( <> - 我的服务 + 桂乐淘服务中心 { border: 'none' } as React.CSSProperties} > - navTo('/user/poster/poster', true)}> + + {hasRole('store') && ( + navTo('/store/index', true)}> + + + + + + + )} + + {hasRole('rider') && ( + navTo('/rider/index', true)}> + + + + + + + )} + + {(hasRole('staff') || hasRole('admin')) && ( + navTo('/user/store/orders/index', true)}> + + + + + + + )} + + navTo('/user/address/index', true)}> - - + + + + + + + navTo('/user/help/index')}> + + + @@ -71,14 +111,6 @@ const UserCell = () => { - navTo('/user/address/index', true)}> - - - - - - - navTo('/user/userVerify/index', true)}> @@ -111,13 +143,6 @@ const UserCell = () => { {/* */} {/**/} - navTo('/user/help/index')}> - - - - - - navTo('/user/about/index')}> @@ -189,4 +214,3 @@ const UserCell = () => { ) } export default UserCell - diff --git a/src/pages/user/components/UserOrder.tsx b/src/pages/user/components/UserOrder.tsx index 0a17847..56da5cd 100644 --- a/src/pages/user/components/UserOrder.tsx +++ b/src/pages/user/components/UserOrder.tsx @@ -26,7 +26,7 @@ function UserOrder() { }} > - 我的订单 + 商城订单 navTo('/user/order/order', true)} diff --git a/src/pages/user/user.tsx b/src/pages/user/user.tsx index 336348d..d9ff840 100644 --- a/src/pages/user/user.tsx +++ b/src/pages/user/user.tsx @@ -1,33 +1,41 @@ -import {useEffect, useRef} from 'react' +import {useEffect, useRef, useState} from 'react' import {PullToRefresh} from '@nutui/nutui-react-taro' import UserCard from "./components/UserCard"; import UserOrder from "./components/UserOrder"; import UserFooter from "./components/UserFooter"; -import {useUserData} from "@/hooks/useUserData"; import {View} from '@tarojs/components'; import './user.scss' import IsDealer from "./components/IsDealer"; import {useThemeStyles} from "@/hooks/useTheme"; import UserGrid from "@/pages/user/components/UserGrid"; +import { useDidShow } from '@tarojs/taro' function User() { - const {refresh} = useUserData() const userCardRef = useRef() const themeStyles = useThemeStyles(); + // TabBar 页在小程序里通常不会销毁;从“注册/申请”页返回时需要触发子组件重新初始化/拉取最新状态。 + const [dealerViewKey, setDealerViewKey] = useState(0) // 下拉刷新处理 const handleRefresh = async () => { - await refresh() - // 如果 UserCard 组件有自己的刷新方法,也可以调用 if (userCardRef.current?.handleRefresh) { await userCardRef.current.handleRefresh() } + setDealerViewKey(v => v + 1) } useEffect(() => { }, []); + // 每次进入/切回个人中心都刷新一次统计(包含水票数量) + useDidShow(() => { + userCardRef.current?.reloadStats?.() + // 个人资料(头像/昵称)可能在其它页面被修改,这里确保返回时立刻刷新 + userCardRef.current?.reloadUserInfo?.() + setDealerViewKey(v => v + 1) + }) + return ( - + diff --git a/src/passport/agreement.tsx b/src/passport/agreement.tsx index 5ce26d1..fbbae42 100644 --- a/src/passport/agreement.tsx +++ b/src/passport/agreement.tsx @@ -1,28 +1,47 @@ -import {useEffect, useState} from "react"; +import { useEffect, useState } from 'react' import Taro from '@tarojs/taro' -import {View, RichText} from '@tarojs/components' +import { Loading } from '@nutui/nutui-react-taro' +import { RichText, View } from '@tarojs/components' +import { getByCode } from '@/api/cms/cmsArticle' +import { wxParse } from '@/utils/common' const Agreement = () => { + const [loading, setLoading] = useState(true) + const [content, setContent] = useState('') - const [content, setContent] = useState('') - const reload = () => { - Taro.hideTabBar() - setContent('

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

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

暂无协议内容

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

协议内容加载失败

') + Taro.showToast({ title: '协议加载失败', icon: 'none' }) + } finally { + setLoading(false) + } } useEffect(() => { reload() }, []) + if (loading) { + return 加载中 + } + return ( <> - + ) diff --git a/src/passport/register.config.ts b/src/passport/register.config.ts index 77ed0bd..8018733 100644 --- a/src/passport/register.config.ts +++ b/src/passport/register.config.ts @@ -1,4 +1,5 @@ export default definePageConfig({ - navigationBarTitleText: '注册账号', + navigationBarTitleText: '注册/登录', navigationBarTextStyle: 'black' }) + diff --git a/src/passport/register.tsx b/src/passport/register.tsx index 553e0e0..2569e44 100644 --- a/src/passport/register.tsx +++ b/src/passport/register.tsx @@ -1,47 +1,295 @@ -import {useEffect, useState} from "react"; +import { useEffect, useMemo, useState } from 'react' import Taro from '@tarojs/taro' -import {Input, Radio, Button} from '@nutui/nutui-react-taro' +import { Button, Radio } from '@nutui/nutui-react-taro' +import { TenantId } from '@/config/app' +import { getUserInfo, getWxOpenId } from '@/api/layout' +import { saveStorageByLoginUser } from '@/utils/server' +import { + getStoredInviteParams, + parseInviteParams, + saveInviteParams, + trackInviteSource, + checkAndHandleInviteRelation, +} from '@/utils/invite' + +interface GetPhoneNumberDetail { + code?: string + encryptedData?: string + iv?: string + errMsg?: string +} + +interface GetPhoneNumberEvent { + detail: GetPhoneNumberDetail +} + +interface LoginResponse { + data: { + code?: number + message?: string + data?: { + access_token: string + user: any + } + } +} + +async function getWeappLoginCode(): Promise { + try { + const res = await new Promise<{ code?: string }>((resolve, reject) => { + Taro.login({ + success: (r) => resolve(r as any), + fail: (e) => reject(e), + }) + }) + return res?.code + } catch (_e) { + return undefined + } +} + +async function ensureWxOpenIdSaved(opts: { user?: any; wxLoginCode?: string }) { + // JSAPI 微信支付必须有 openid;注册/登录后立刻补齐,避免后续创建支付单失败。 + try { + if (Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) return + } catch (_e) { + if (process.env.TARO_ENV !== 'weapp') return + } + + if (opts.user?.openid) return + + const code = opts.wxLoginCode || (await getWeappLoginCode()) + if (!code) return + + // 该接口一般会在服务端把 openid 绑定到当前登录用户;返回值并不一定包含 openid。 + await getWxOpenId({ code }) + + // 同步本地 User(让后续页面/逻辑能直接读到 openid) + try { + const fresh = await getUserInfo() + if (fresh) Taro.setStorageSync('User', fresh) + } catch (_e) { + // ignore: openid 已在服务端绑定,本地不同步也不影响后端创建支付订单 + } +} + +function safeDecodeMaybeEncoded(input?: string): string { + if (!input) return '' + try { + // Taro 路由参数通常是 URL 编码过的字符串 + return decodeURIComponent(input) + } catch (_e) { + return input + } +} + +function isTabBarUrl(url: string) { + const pure = url.split('?')[0] + return ( + pure === '/pages/index/index' || + pure === '/pages/cart/cart' || + pure === '/pages/user/user' || + pure === '/pages/category/index' + ) +} const Register = () => { const [isAgree, setIsAgree] = useState(false) - const reload = () => { - Taro.hideTabBar() - } + const [loading, setLoading] = useState(false) + + // 短信验证码登录仅在非微信小程序端展示 + const isWeapp = useMemo(() => { + try { + return Taro.getEnv() === Taro.ENV_TYPE.WEAPP + } catch (_e) { + return process.env.TARO_ENV === 'weapp' + } + }, []) + + const router = Taro.getCurrentInstance().router useEffect(() => { - reload() + // 注册/登录页不需要展示 tabBar + Taro.hideTabBar() }, []) + const redirectUrl = useMemo(() => { + const raw = (router?.params as any)?.redirect as string | undefined + const decoded = safeDecodeMaybeEncoded(raw) + if (!decoded) return '' + return decoded.startsWith('/') ? decoded : `/${decoded}` + }, [router?.params]) + + // 如果从分享/二维码直接进入注册页(携带 inviter/source/t),先暂存邀请信息 + useEffect(() => { + try { + const inviteParams = parseInviteParams({ query: router?.params }) + if (inviteParams?.inviter) { + saveInviteParams(inviteParams) + trackInviteSource(inviteParams.source || 'qrcode', parseInt(inviteParams.inviter, 10)) + } + } catch (e) { + console.error('注册页处理邀请参数失败:', e) + } + }, [router?.params]) + + const navigateAfterLogin = async () => { + if (!redirectUrl) { + await Taro.reLaunch({ url: '/pages/index/index' }) + return + } + + if (isTabBarUrl(redirectUrl)) { + // switchTab 不支持携带 query,这里按纯路径跳转 + await Taro.switchTab({ url: redirectUrl.split('?')[0] }) + return + } + + // 替换当前注册页,避免返回栈里再回到注册页 + await Taro.redirectTo({ url: redirectUrl }) + } + + const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => { + if (!isAgree) { + Taro.showToast({ title: '请先勾选同意协议', icon: 'none' }) + return + } + if (loading) return + + const { code: phoneCode, encryptedData, iv, errMsg } = detail || {} + if (!phoneCode || (errMsg && errMsg.includes('fail'))) { + Taro.showToast({ title: '未授权手机号', icon: 'none' }) + return + } + + try { + setLoading(true) + + // 获取存储的邀请参数(推荐人ID) + const inviteParams = getStoredInviteParams() + const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0 + + // 获取小程序登录 code(用于后续绑定 openid) + const wxLoginCode = await getWeappLoginCode() + + const res = (await Taro.request({ + url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone', + method: 'POST', + data: { + code: phoneCode, + encryptedData, + iv, + notVerifyPhone: true, + refereeId: refereeId, + sceneType: 'save_referee', + tenantId: TenantId, + }, + header: { + 'content-type': 'application/json', + TenantId, + }, + })) as unknown as LoginResponse + + if ((res as any)?.data?.code === 1) { + Taro.showToast({ title: res.data.message || '登录失败', icon: 'none' }) + return + } + + const token = res?.data?.data?.access_token + const user = res?.data?.data?.user + if (!token || !user?.userId) { + Taro.showToast({ title: '登录失败,请重试', icon: 'none' }) + return + } + + saveStorageByLoginUser(token, user) + + // 注册/登录成功后,立即补齐 openid(JSAPI 支付必需) + try { + await ensureWxOpenIdSaved({ user, wxLoginCode }) + } catch (e) { + console.error('注册页绑定 openid 失败:', e) + } + + // 登录成功后尝试绑定推荐关系(如果有待处理 inviter,会自动处理并清理参数) + try { + await checkAndHandleInviteRelation() + } catch (e) { + console.error('注册页登录后处理邀请关系失败:', e) + } + + Taro.showToast({ title: '登录成功', icon: 'success' }) + setTimeout(() => { + navigateAfterLogin().catch((e) => console.error('登录后跳转失败:', e)) + }, 800) + } catch (e: any) { + console.error('注册/登录失败:', e) + Taro.showToast({ title: e?.message || '登录失败', icon: 'none' }) + } finally { + setLoading(false) + } + } + + const goSmsLogin = () => { + const inviteParams = getStoredInviteParams() + const inviter = inviteParams?.inviter + const source = inviteParams?.source + const t = inviteParams?.t + + const params: Record = {} + if (redirectUrl) params.redirect = redirectUrl + // 兜底:把 inviter 带过去,避免“先点注册再进入”时丢失 + if (inviter) params.inviter = inviter + if (source) params.source = source + if (t) params.t = t + + const qs = Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&') + Taro.navigateTo({ url: `/passport/sms-login${qs ? `?${qs}` : ''}` }) + } + return ( <> -
-
免费试用14天,快速上手独立站
-
建站、选品、营销、支付、物流,全部搞定
-
- WebSoft为您提供独立站的解决方案,提供专业、高效、安全的运营服务。 +
+
注册/登录
+ +
+ + + {!isWeapp && ( + + )}
-
- + +
+ setIsAgree(!isAgree)} /> + setIsAgree(!isAgree)}> + 勾选表示您已阅读并同意 + + Taro.navigateTo({ url: '/passport/agreement' })} + className={'text-blue-600'} + > + 《服务协议及隐私政策》 +
-
- -
-
- -
-
- -
-
- setIsAgree(!isAgree)}> - setIsAgree(!isAgree)}>勾选表示您已阅读并同意 - Taro.navigateTo({url: '/passport/agreement'})} className={'text-blue-600'}>《服务协议及隐私政策》 -
-
- ) } + export default Register diff --git a/src/passport/sms-login.tsx b/src/passport/sms-login.tsx index 62da43d..7b3dad1 100644 --- a/src/passport/sms-login.tsx +++ b/src/passport/sms-login.tsx @@ -3,6 +3,7 @@ import Taro from '@tarojs/taro' import {Input, Button} from '@nutui/nutui-react-taro' import {loginBySms, sendSmsCaptcha} from "@/api/passport/login"; import {LoginParam} from "@/api/passport/login/model"; +import {checkAndHandleInviteRelation, hasPendingInvite, parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite"; const SmsLogin = () => { const [loading, setLoading] = useState(false) @@ -13,6 +14,46 @@ const SmsLogin = () => { code: '' }) + const router = Taro.getCurrentInstance().router + const redirectParam = (router?.params as any)?.redirect as string | undefined + + const safeDecodeMaybeEncoded = (input?: string) => { + if (!input) return '' + try { + return decodeURIComponent(input) + } catch (_e) { + return input + } + } + + const redirectUrl = (() => { + const decoded = safeDecodeMaybeEncoded(redirectParam) + if (!decoded) return '' + return decoded.startsWith('/') ? decoded : `/${decoded}` + })() + + const isTabBarUrl = (url: string) => { + const pure = url.split('?')[0] + return ( + pure === '/pages/index/index' || + pure === '/pages/cart/cart' || + pure === '/pages/user/user' || + pure === '/pages/category/index' + ) + } + + const navigateAfterLogin = async () => { + if (!redirectUrl) { + await Taro.reLaunch({ url: '/pages/index/index' }) + return + } + if (isTabBarUrl(redirectUrl)) { + await Taro.switchTab({ url: redirectUrl.split('?')[0] }) + return + } + await Taro.redirectTo({ url: redirectUrl }) + } + const reload = () => { Taro.hideTabBar() } @@ -21,6 +62,19 @@ const SmsLogin = () => { reload() }, []) + // 如果从分享/二维码链接进入短信登录页,先暂存邀请信息 + useEffect(() => { + try { + const inviteParams = parseInviteParams({ query: router?.params }) + if (inviteParams?.inviter) { + saveInviteParams(inviteParams) + trackInviteSource(inviteParams.source || 'share', parseInt(inviteParams.inviter, 10)) + } + } catch (e) { + console.error('短信登录页处理邀请参数失败:', e) + } + }, [router?.params]) + // 倒计时效果 useEffect(() => { let timer: NodeJS.Timeout @@ -131,6 +185,15 @@ const SmsLogin = () => { code: formData.code }) + // 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定 + if (hasPendingInvite()) { + try { + await checkAndHandleInviteRelation() + } catch (e) { + console.error('短信登录后处理邀请关系失败:', e) + } + } + Taro.showToast({ title: '登录成功', icon: 'success' @@ -138,8 +201,9 @@ const SmsLogin = () => { // 延迟跳转到首页 setTimeout(() => { - Taro.reLaunch({ - url: '/pages/index/index' + navigateAfterLogin().catch((e) => { + console.error('短信登录后跳转失败:', e) + Taro.reLaunch({ url: '/pages/index/index' }) }) }, 1500) diff --git a/src/passport/unified-qr/index.tsx b/src/passport/unified-qr/index.tsx index 9e87c7f..be5cda1 100644 --- a/src/passport/unified-qr/index.tsx +++ b/src/passport/unified-qr/index.tsx @@ -43,7 +43,7 @@ const UnifiedQRPage: React.FC = () => { setTimeout(() => { Taro.showModal({ title: '核销成功', - content: '是否继续扫码核销其他礼品卡?', + content: '是否继续扫码核销其他水票/礼品卡?', success: (res) => { if (res.confirm) { handleStartScan(); @@ -179,7 +179,7 @@ const UnifiedQRPage: React.FC = () => { {scanType === ScanType.LOGIN ? '正在确认登录' : - scanType === ScanType.VERIFICATION ? '正在核销礼品卡' : '正在处理'} + scanType === ScanType.VERIFICATION ? '正在核销' : '正在处理'} )} @@ -192,12 +192,29 @@ const UnifiedQRPage: React.FC = () => { {result.type === ScanType.VERIFICATION && result.data && ( - - 礼品卡:{result.data.goodsName || '未知商品'} - - - 面值:¥{result.data.faceValue} - + {result.data.businessType === 'gift' && result.data.gift && ( + <> + + 礼品:{result.data.gift.goodsName || result.data.gift.name || '未知'} + + + 面值:¥{result.data.gift.faceValue} + + + )} + {result.data.businessType === 'ticket' && result.data.ticket && ( + <> + + 水票:{result.data.ticket.templateName || '水票'} + + + 本次核销:{result.data.qty || 1} 次 + + + 剩余可用:{result.data.ticket.availableQty ?? 0} 次 + + + )} )} @@ -278,9 +295,14 @@ const UnifiedQRPage: React.FC = () => { {record.success ? record.message : record.error} - {record.success && record.type === ScanType.VERIFICATION && record.data && ( + {record.success && record.type === ScanType.VERIFICATION && record.data?.businessType === 'gift' && record.data?.gift && ( - {record.data.goodsName} - ¥{record.data.faceValue} + {record.data.gift.goodsName || record.data.gift.name} - ¥{record.data.gift.faceValue} + + )} + {record.success && record.type === ScanType.VERIFICATION && record.data?.businessType === 'ticket' && record.data?.ticket && ( + + {record.data.ticket.templateName || '水票'} - 本次核销 {record.data.qty || 1} 次 )} @@ -304,7 +326,7 @@ const UnifiedQRPage: React.FC = () => { • 登录二维码:自动确认网页端登录 - • 核销二维码:门店核销用户礼品卡 + • 核销二维码:核销用户水票/礼品卡 • 系统会自动识别二维码类型并执行相应操作 diff --git a/src/rider/index.config.ts b/src/rider/index.config.ts new file mode 100644 index 0000000..1293fcb --- /dev/null +++ b/src/rider/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '配送中心' +}) diff --git a/src/rider/index.scss b/src/rider/index.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/rider/index.tsx b/src/rider/index.tsx new file mode 100644 index 0000000..a77aef6 --- /dev/null +++ b/src/rider/index.tsx @@ -0,0 +1,304 @@ +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, + Scan +} 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 ( + + + {error} + + + + ) + } + + return ( + + + {/*头部信息*/} + {dealerUser && ( + + {/* 装饰性背景元素 - 小程序兼容版本 */} + + + + + } + className="mr-4" + style={{ + border: '2px solid rgba(255, 255, 255, 0.3)' + }} + /> + + + {dealerUser?.realName || '分销商'} + + + ID: {dealerUser.userId} + + + + 加入时间 + + {formatTime(dealerUser.createTime)} + + + + + )} + + {/* 佣金统计卡片 */} + {dealerUser && ( + + + 配送提成 + + + + + {formatMoney(dealerUser.money)} + + 本月配送佣金 + + + + {formatMoney(dealerUser.freezeMoney)} + + 桶数 + + + + {formatMoney(dealerUser.totalMoney)} + + 累计收入 + + + + )} + + {/* 团队统计 */} + {dealerUser && ( + + + 我的邀请 + navigateToPage('/dealer/team/index')} + > + 查看详情 + + + + + + + {dealerUser.firstNum || 0} + + 一级成员 + + + + {dealerUser.secondNum || 0} + + 二级成员 + + + + {dealerUser.thirdNum || 0} + + 三级成员 + + + + )} + + {/* 功能导航 */} + + 配送工具 + + + navigateToPage('/rider/orders/index')}> + + + + + + + + navigateToPage('/rider/withdraw/index')}> + + + + + + + + navigateToPage('/rider/team/index')}> + + + + + + + + navigateToPage('/rider/qrcode/index')}> + + + + + + + + navigateToPage('/rider/ticket/verification/index?auto=1')}> + + + + + + + + + {/* 第二行功能 */} + {/**/} + {/* navigateToPage('/dealer/invite-stats/index')}>*/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + + {/* /!* 预留其他功能位置 *!/*/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/**/} + + + + + {/* 底部安全区域 */} + + + ) +} + +export default DealerIndex diff --git a/src/rider/orders/index.config.ts b/src/rider/orders/index.config.ts new file mode 100644 index 0000000..4a1a611 --- /dev/null +++ b/src/rider/orders/index.config.ts @@ -0,0 +1,4 @@ +export default { + navigationBarTitleText: '送水订单', + navigationBarTextStyle: 'black' +} diff --git a/src/rider/orders/index.tsx b/src/rider/orders/index.tsx new file mode 100644 index 0000000..5ad11eb --- /dev/null +++ b/src/rider/orders/index.tsx @@ -0,0 +1,610 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { + Tabs, + TabPane, + Cell, + Space, + Button, + Dialog, + Radio, + RadioGroup, + Image, + Empty, + InfiniteLoading, + PullToRefresh, + Loading +} from '@nutui/nutui-react-taro' +import { View, Text } from '@tarojs/components' +import dayjs from 'dayjs' +import { pageGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder' +import type { GltTicketOrder, GltTicketOrderParam } from '@/api/glt/gltTicketOrder/model' +import { uploadFile } from '@/api/system/file' + +export default function RiderOrders() { + const PAGE_SIZE = 10 + + const riderId = useMemo(() => { + const v = Number(Taro.getStorageSync('UserId')) + return Number.isFinite(v) && v > 0 ? v : undefined + }, []) + + const pageRef = useRef(1) + const listRef = useRef([]) + const [tabIndex, setTabIndex] = useState(0) + const [list, setList] = useState([]) + const [hasMore, setHasMore] = useState(true) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const [deliverDialogVisible, setDeliverDialogVisible] = useState(false) + const [deliverSubmitting, setDeliverSubmitting] = useState(false) + const [deliverOrder, setDeliverOrder] = useState(null) + const [deliverImg, setDeliverImg] = useState(undefined) + + type DeliverConfirmMode = 'photoComplete' | 'waitCustomerConfirm' + const [deliverConfirmMode, setDeliverConfirmMode] = useState('photoComplete') + + const riderTabs = useMemo( + () => [ + { index: 0, title: '全部' }, + { index: 1, title: '待配送', deliveryStatus: 10 }, + { index: 2, title: '配送中', deliveryStatus: 20 }, + { index: 3, title: '待确认', deliveryStatus: 30 }, + { index: 4, title: '已完成', deliveryStatus: 40 } + ], + [] + ) + + const getOrderStatusText = (order: GltTicketOrder) => { + if (order.status === 1) return '已冻结' + + const deliveryStatus = order.deliveryStatus + if (deliveryStatus === 40) return '已完成' + if (deliveryStatus === 30) return '待客户确认' + if (deliveryStatus === 20) return '配送中' + if (deliveryStatus === 10) return '待配送' + + // 兼容:如果后端暂未下发 deliveryStatus,就用时间字段推断 + if (order.receiveConfirmTime) return '已完成' + if (order.sendEndTime) return '待客户确认' + if (order.sendStartTime) return '配送中' + if (order.riderId) return '待配送' + return '待派单' + } + + const getOrderStatusColor = (order: GltTicketOrder) => { + const text = getOrderStatusText(order) + if (text === '已完成') return 'text-green-600' + if (text === '待客户确认') return 'text-purple-600' + if (text === '配送中') return 'text-blue-600' + if (text === '待配送') return 'text-amber-600' + if (text === '已冻结') return 'text-orange-600' + return 'text-gray-500' + } + + const canStartDeliver = (order: GltTicketOrder) => { + if (!order.id) return false + if (order.status === 1) return false + if (!riderId || order.riderId !== riderId) return false + if (order.deliveryStatus && order.deliveryStatus !== 10) return false + return !order.sendStartTime && !order.sendEndTime + } + + const canConfirmDelivered = (order: GltTicketOrder) => { + if (!order.id) return false + if (order.status === 1) return false + if (!riderId || order.riderId !== riderId) return false + if (order.receiveConfirmTime) return false + if (order.deliveryStatus === 40) return false + if (order.sendEndTime) return false + + // 只允许在“配送中”阶段确认送达 + if (typeof order.deliveryStatus === 'number') return order.deliveryStatus === 20 + return !!order.sendStartTime + } + + const canCompleteByPhoto = (order: GltTicketOrder) => { + if (!order.id) return false + if (order.status === 1) return false + if (!riderId || order.riderId !== riderId) return false + if (order.receiveConfirmTime) return false + if (order.deliveryStatus === 40) return false + // 已送达但未完成:允许补传照片并直接完成 + return !!order.sendEndTime + } + + const filterByTab = useCallback( + (orders: GltTicketOrder[]) => { + if (tabIndex === 0) return orders + + const current = riderTabs.find(t => t.index === tabIndex) + const status = current?.deliveryStatus + if (!status) return orders + + // 如果后端已实现 deliveryStatus 筛选,这里基本不会再过滤;否则用兼容逻辑兜底。 + return orders.filter(o => { + const ds = o.deliveryStatus + if (typeof ds === 'number') return ds === status + if (status === 10) return !!o.riderId && !o.sendStartTime && !o.sendEndTime + if (status === 20) return !!o.sendStartTime && !o.sendEndTime + if (status === 30) return !!o.sendEndTime && !o.receiveConfirmTime + if (status === 40) return !!o.receiveConfirmTime + return true + }) + }, + [riderTabs, tabIndex] + ) + + const reload = useCallback( + async (resetPage = false) => { + if (!riderId) return + if (loading) return + setLoading(true) + setError(null) + + const currentPage = resetPage ? 1 : pageRef.current + const currentTab = riderTabs.find(t => t.index === tabIndex) + const params: GltTicketOrderParam = { + page: currentPage, + limit: PAGE_SIZE, + riderId, + deliveryStatus: currentTab?.deliveryStatus + } + + try { + const res = await pageGltTicketOrder(params as any) + const incomingAll = (res?.list || []) as GltTicketOrder[] + + // 兼容:后端若暂未实现 riderId 过滤,前端兜底过滤掉非本人的订单 + const incoming = incomingAll.filter(o => o?.deleted !== 1 && o?.riderId === riderId) + + const prev = resetPage ? [] : listRef.current + const next = resetPage ? incoming : prev.concat(incoming) + listRef.current = next + setList(next) + + const total = typeof res?.count === 'number' ? res.count : undefined + const filteredOut = incomingAll.length - incoming.length + if (typeof total === 'number' && filteredOut === 0) { + setHasMore(next.length < total) + } else { + setHasMore(incomingAll.length >= PAGE_SIZE) + } + + pageRef.current = currentPage + 1 + } catch (e) { + console.error('加载配送订单失败:', e) + setError('加载失败,请重试') + setHasMore(false) + } finally { + setLoading(false) + } + }, + [PAGE_SIZE, loading, riderId, riderTabs, tabIndex] + ) + + const reloadMore = useCallback(async () => { + if (loading || !hasMore) return + await reload(false) + }, [hasMore, loading, reload]) + + const openDeliverDialog = (order: GltTicketOrder, opts?: { mode?: DeliverConfirmMode }) => { + setDeliverOrder(order) + setDeliverImg(order.sendEndImg) + setDeliverConfirmMode(opts?.mode || (order.sendEndImg ? 'photoComplete' : 'waitCustomerConfirm')) + setDeliverDialogVisible(true) + } + + const handleChooseDeliverImg = async () => { + try { + const file = await uploadFile() + setDeliverImg(file?.url) + } catch (e) { + console.error('上传送达照片失败:', e) + Taro.showToast({ title: '上传失败,请重试', icon: 'none' }) + } + } + + const handleStartDeliver = async (order: GltTicketOrder) => { + if (!order?.id) return + if (!canStartDeliver(order)) return + try { + await updateGltTicketOrder({ + id: order.id, + deliveryStatus: 20, + sendStartTime: dayjs().format('YYYY-MM-DD HH:mm:ss') + }) + Taro.showToast({ title: '已开始配送', icon: 'success' }) + pageRef.current = 1 + listRef.current = [] + setList([]) + setHasMore(true) + await reload(true) + } catch (e) { + console.error('开始配送失败:', e) + Taro.showToast({ title: '开始配送失败', icon: 'none' }) + } + } + + const handleConfirmDelivered = async () => { + if (!deliverOrder?.id) return + if (deliverSubmitting) return + if (deliverConfirmMode === 'photoComplete' && !deliverImg) { + Taro.showToast({ title: '请先拍照/上传送达照片', icon: 'none' }) + return + } + setDeliverSubmitting(true) + try { + const now = dayjs().format('YYYY-MM-DD HH:mm:ss') + // 送达时间:首次“确认送达”写入;补传照片时不要覆盖原送达时间 + const deliveredAt = deliverOrder.sendEndTime || now + // - waitCustomerConfirm:只标记“已送达”,进入待客户确认 + // - photoComplete:拍照留档后可直接完成(是否允许由后端策略决定) + const payload: GltTicketOrder = + deliverConfirmMode === 'photoComplete' + ? { + id: deliverOrder.id, + deliveryStatus: 40, + sendEndTime: deliveredAt, + sendEndImg: deliverImg, + receiveConfirmTime: now, + receiveConfirmType: 20 + } + : { + id: deliverOrder.id, + deliveryStatus: 30, + sendEndTime: deliveredAt, + sendEndImg: deliverImg + } + + await updateGltTicketOrder(payload) + + Taro.showToast({ title: '已确认送达', icon: 'success' }) + setDeliverDialogVisible(false) + setDeliverOrder(null) + setDeliverImg(undefined) + setDeliverConfirmMode('photoComplete') + pageRef.current = 1 + listRef.current = [] + setList([]) + setHasMore(true) + await reload(true) + } catch (e) { + console.error('确认送达失败:', e) + Taro.showToast({ title: '确认送达失败', icon: 'none' }) + } finally { + setDeliverSubmitting(false) + } + } + + useEffect(() => { + listRef.current = list + }, [list]) + + useDidShow(() => { + pageRef.current = 1 + listRef.current = [] + setList([]) + setHasMore(true) + void reload(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }) + + useEffect(() => { + pageRef.current = 1 + listRef.current = [] + setList([]) + setHasMore(true) + void reload(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tabIndex, riderId]) + + if (!riderId) { + return ( + + 请先登录 + + ) + } + + const displayList = filterByTab(list) + + return ( + + + + setTabIndex(Number(paneKey))} + > + {riderTabs.map(t => ( + + ))} + + + { + pageRef.current = 1 + listRef.current = [] + setList([]) + setHasMore(true) + await reload(true) + }} + headHeight={60} + > + + {error ? ( + + {error} + + + ) : ( + + + 加载中... + + } + loadMoreText={ + displayList.length === 0 ? ( + + ) : ( + 没有更多了 + ) + } + > + {displayList.map(o => { + const timeText = o.createTime ? dayjs(o.createTime).format('YYYY-MM-DD HH:mm') : '-' + const addr = o.address || (o.addressId ? `地址ID:${o.addressId}` : '-') + const remark = o.buyerRemarks || o.comments || '' + const qty = Number(o.totalNum || 0) + + const flow1Done = !!o.riderId + const flow2Done = !!o.sendStartTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 20) + const flow3Done = !!o.sendEndTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 30) + const flow4Done = !!o.receiveConfirmTime || o.deliveryStatus === 40 + + const phoneToCall = o.phone + const storePhone = o.storePhone + const pickupName = o.warehouseName || o.storeName + const pickupAddr = o.warehouseAddress || o.storeAddress + + return ( + + + + + {o.userTicketId ? `票号#${o.userTicketId}` : '送水订单'} + + {getOrderStatusText(o)} + + + 下单时间:{timeText} + + + + 客户: + {o.nickname || '-'} {o.phone ? `(${o.phone})` : ''} + + + 收货地址: + {addr} + + {!!remark && ( + + 买家留言: + {remark} + + )} + + + 数量: + {Number.isFinite(qty) ? qty : '-'} + 金额: + ¥{o.price || '-'} + + + + 配送时间: + {o.sendTime ? dayjs(o.sendTime).format('YYYY-MM-DD HH:mm') : '-'} + + + + 取水点: + {pickupName || '-'} + + + 取水地址: + {pickupAddr || '-'} + + + {!!o.sendStartTime && ( + + 开始配送: + {dayjs(o.sendStartTime).format('YYYY-MM-DD HH:mm')} + + )} + {!!o.sendEndTime && ( + + 送达时间: + {dayjs(o.sendEndTime).format('YYYY-MM-DD HH:mm')} + + )} + {!!o.receiveConfirmTime && ( + + 完成时间: + {dayjs(o.receiveConfirmTime).format('YYYY-MM-DD HH:mm')} + + )} + + {o.sendEndImg ? ( + + 送达照片: + + + + + ) : null} + + + {/* 配送流程 */} + + 流程: + 1 派单 + {'>'} + 2 配送中 + {'>'} + 3 送达留档 + {'>'} + 4 完成 + + + + + {!!phoneToCall && ( + + )} + {!!addr && addr !== '-' && ( + + )} + {!!storePhone && ( + + )} + {canStartDeliver(o) && ( + + )} + {canConfirmDelivered(o) && ( + + )} + {canCompleteByPhoto(o) && ( + + )} + + + + + ) + })} + + )} + + + + + { + if (deliverSubmitting) return + setDeliverDialogVisible(false) + setDeliverOrder(null) + setDeliverImg(undefined) + setDeliverConfirmMode('photoComplete') + }} + > + + 到达收货点后,可选择“拍照留档直接完成”或“等待客户确认收货”。 + + + setDeliverConfirmMode(v as DeliverConfirmMode)}> + 拍照留档(直接完成) + 客户确认收货(可不拍照) + + + + + + {deliverImg && ( + + + + + + + )} + + 说明:如选择“客户确认收货”,订单进入“待客户确认”;客户在用户端确认收货或超时自动确认(需后端支持)。 + + + + + ) +} diff --git a/src/rider/ticket/verification/index.config.ts b/src/rider/ticket/verification/index.config.ts new file mode 100644 index 0000000..afd29c5 --- /dev/null +++ b/src/rider/ticket/verification/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationBarTitleText: '水票核销' +}) + diff --git a/src/rider/ticket/verification/index.tsx b/src/rider/ticket/verification/index.tsx new file mode 100644 index 0000000..545e4ac --- /dev/null +++ b/src/rider/ticket/verification/index.tsx @@ -0,0 +1,280 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { View, Text } from '@tarojs/components' +import Taro, { useDidShow, useRouter } from '@tarojs/taro' +import { Button, Card, ConfigProvider } from '@nutui/nutui-react-taro' +import { Scan, Success, Failure, Tips } from '@nutui/icons-react-taro' + +import { decryptQrData } from '@/api/shop/shopGift' +import { getGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket' +import type { GltUserTicket } from '@/api/glt/gltUserTicket/model' +import { isValidJSON } from '@/utils/jsonUtils' +import { useUser } from '@/hooks/useUser' + +type TicketPayload = { + userTicketId: number + qty?: number + userId?: number + t?: number +} + +type VerifyRecord = { + id: number + time: string + success: boolean + message: string + ticketName?: string + userInfo?: string + qty?: number +} + +const RiderTicketVerificationPage: React.FC = () => { + const { hasRole, isAdmin } = useUser() + const router = useRouter() + const [loading, setLoading] = useState(false) + const [lastTicket, setLastTicket] = useState(null) + const [lastQty, setLastQty] = useState(1) + const [records, setRecords] = useState([]) + + const autoScanOnceRef = useRef(false) + + const canVerify = useMemo(() => { + return ( + hasRole('rider') || + hasRole('store') || + hasRole('staff') || + hasRole('admin') || + isAdmin() + ) + }, [hasRole, isAdmin]) + + const autoScanEnabled = useMemo(() => { + const p: any = router?.params || {} + return p.auto === '1' || p.auto === 'true' + }, [router]) + + const addRecord = (rec: Omit) => { + const item: VerifyRecord = { + id: Date.now(), + time: new Date().toLocaleString(), + ...rec + } + setRecords(prev => [item, ...prev].slice(0, 10)) + } + + const parsePayload = (raw: string): TicketPayload => { + const trimmed = raw.trim() + if (!isValidJSON(trimmed)) throw new Error('无效的水票核销信息') + const payload = JSON.parse(trimmed) as TicketPayload + const userTicketId = Number(payload.userTicketId) + const qty = Math.max(1, Number(payload.qty || 1)) + if (!Number.isFinite(userTicketId) || userTicketId <= 0) { + throw new Error('水票核销信息无效') + } + return { ...payload, userTicketId, qty } + } + + const extractPayloadFromScanResult = async (scanResult: string): Promise => { + const trimmed = scanResult.trim() + + // 1) 加密二维码:{ businessType, token, data } + if (isValidJSON(trimmed)) { + const json = JSON.parse(trimmed) as any + if (json?.businessType && json?.token && json?.data) { + if (json.businessType !== 'ticket') { + throw new Error('请扫描水票核销码') + } + const decrypted = await decryptQrData({ + token: String(json.token), + encryptedData: String(json.data) + }) + return parsePayload(String(decrypted || '')) + } + + // 2) 明文 payload(内部调试/非加密二维码) + if (json?.userTicketId) { + return parsePayload(trimmed) + } + } + + throw new Error('无效的水票核销码') + } + + const verifyTicket = async (payload: TicketPayload) => { + const userTicketId = Number(payload.userTicketId) + const qty = Math.max(1, Number(payload.qty || 1)) + + const ticket = await getGltUserTicket(userTicketId) + if (!ticket) throw new Error('水票不存在') + if (ticket.status === 1) throw new Error('该水票已冻结') + const available = Number(ticket.availableQty || 0) + const used = Number(ticket.usedQty || 0) + if (available < qty) throw new Error('水票可用次数不足') + + const lines: string[] = [] + lines.push(`水票:${ticket.templateName || '水票'}`) + lines.push(`本次核销:${qty} 次`) + lines.push(`剩余可用:${available - qty} 次`) + if (ticket.phone) lines.push(`用户手机号:${ticket.phone}`) + if (ticket.nickname) lines.push(`用户昵称:${ticket.nickname}`) + + const modalRes = await Taro.showModal({ + title: '确认核销', + content: lines.join('\n') + }) + if (!modalRes.confirm) return + + await updateGltUserTicket({ + ...ticket, + availableQty: available - qty, + usedQty: used + qty + }) + + setLastTicket({ + ...ticket, + availableQty: available - qty, + usedQty: used + qty + }) + setLastQty(qty) + + addRecord({ + success: true, + message: `核销成功(${qty}次)`, + ticketName: ticket.templateName || '水票', + userInfo: [ticket.nickname, ticket.phone].filter(Boolean).join(' / ') || undefined, + qty + }) + Taro.showToast({ title: '核销成功', icon: 'success' }) + } + + const handleScan = async () => { + if (loading) return + if (!canVerify) { + Taro.showToast({ title: '您没有核销权限', icon: 'none' }) + return + } + + try { + setLoading(true) + const res = await Taro.scanCode({}) + const scanResult = res?.result + if (!scanResult) throw new Error('未识别到二维码内容') + + const payload = await extractPayloadFromScanResult(scanResult) + await verifyTicket(payload) + } catch (e: any) { + const msg = e?.message || '核销失败' + addRecord({ success: false, message: msg }) + Taro.showToast({ title: msg, icon: 'none' }) + } finally { + setLoading(false) + } + } + + // If navigated in "auto" mode, open scan on first show when user has permission. + useDidShow(() => { + // Reset the flag when user manually re-enters the page via navigation again. + // (This runs on every show; only the first show with auto enabled will trigger scan.) + if (!autoScanEnabled) autoScanOnceRef.current = false + }) + + useEffect(() => { + if (!autoScanEnabled) return + if (autoScanOnceRef.current) return + if (!canVerify) return + autoScanOnceRef.current = true + // Defer to ensure page is fully mounted before opening camera. + setTimeout(() => { + handleScan().catch(() => {}) + }, 80) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoScanEnabled, canVerify]) + + return ( + + + + + + 水票核销 + + + 扫描用户出示的“水票核销码”完成核销 + + + + + + + + + + + {lastTicket && ( + + + 最近一次核销 + 使用 {lastQty} 次 + + + + {lastTicket.templateName || '水票'}(剩余 {lastTicket.availableQty ?? 0} 次) + + + + )} + + + + + 核销记录 + 仅保留最近10条 + + {records.length === 0 ? ( + + 暂无记录 + + ) : ( + + {records.map(r => ( + + + + {r.success ? ( + + ) : ( + + )} + {r.message} + + + + {r.time} + {r.ticketName ? ` · ${r.ticketName}` : ''} + {typeof r.qty === 'number' ? ` · ${r.qty}次` : ''} + + + {r.userInfo && ( + + {r.userInfo} + + )} + + + ))} + + )} + + + + ) +} + +export default RiderTicketVerificationPage diff --git a/src/shop/category/components/GoodsList.scss b/src/shop/category/components/GoodsList.scss index e69de29..de245e8 100644 --- a/src/shop/category/components/GoodsList.scss +++ b/src/shop/category/components/GoodsList.scss @@ -0,0 +1,122 @@ + +.goods-grid { + margin-top: 18rpx; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; +} + +.goods-card { + border-radius: 22rpx; + overflow: hidden; + background: #ffffff; + box-shadow: 0 18rpx 36rpx rgba(0, 0, 0, 0.06); +} + +.goods-card__imgWrap { + padding: 18rpx 18rpx 0; +} + +.goods-card__img { + width: 100%; + height: 280rpx; + border-radius: 18rpx; + background: #f4f4f4; +} + +.goods-card__body { + padding: 18rpx 18rpx 20rpx; +} + +.goods-card__title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 26rpx; + font-weight: 700; + color: #1c1c1c; + min-height: 72rpx; +} + +.goods-card__meta { + margin-top: 10rpx; + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 10rpx; +} + +.goods-card__sold { + font-size: 22rpx; + color: #9a9a9a; + white-space: nowrap; +} + +.goods-card__price { + display: flex; + align-items: baseline; + gap: 4rpx; + color: #27c86b; + white-space: nowrap; +} + +.goods-card__priceUnit { + font-size: 22rpx; + font-weight: 800; +} + +.goods-card__priceValue { + font-size: 36rpx; + font-weight: 900; +} + +.goods-card__actions { + margin-top: 16rpx; + display: flex; + gap: 14rpx; +} + +.goods-card__btn { + flex: 1; + height: 64rpx; + border-radius: 999rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.goods-card__btn--ghost { + border: 2rpx solid rgba(32, 194, 106, 0.7); + background: #ffffff; +} + +.goods-card__btn--primary { + background: linear-gradient(90deg, #24d34c 0%, #6df09a 100%); +} + +.goods-card__btnText { + font-size: 24rpx; + font-weight: 700; + color: #18b85a; + white-space: nowrap; +} + +.goods-card__btnText--primary { + color: #ffffff; +} + +.buy-btn{ + height: 70px; + background: linear-gradient(to bottom, #1cd98a, #24ca94); + border-radius: 100px; + color: #ffffff; + display: flex; + align-items: center; + justify-content: space-around; + .cart-icon{ + background: linear-gradient(to bottom, #bbe094, #4ee265); + border-radius: 100px 0 0 100px; + height: 70px; + } +} diff --git a/src/shop/category/components/GoodsList.tsx b/src/shop/category/components/GoodsList.tsx index f84ae9e..8066e7a 100644 --- a/src/shop/category/components/GoodsList.tsx +++ b/src/shop/category/components/GoodsList.tsx @@ -1,51 +1,57 @@ import {Image} from '@nutui/nutui-react-taro' -import {Share} from '@nutui/icons-react-taro' import {View, Text} from '@tarojs/components' import Taro from '@tarojs/taro' import './GoodsList.scss' +import {ShopGoods} from "@/api/shop/shopGoods/model"; const GoodsList = (props: any) => { return ( <> - - - {props.data?.map((item: any, index: number) => { - return ( - - Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}/> - - - {item.name} - - {item.comments} - 已售 {item.sales} - - - - - {item.price} - 会员价 - ¥{item.salePrice} - - - - Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}/> - - Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}>购买 - - - + + + + {props.data?.map((item: ShopGoods) => ( + + + + Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${item.goodsId}` }) + } + /> + + + + {item.name} + + 已购:{item.sales || 0}人 + + + {item.buyingPrice} + + + + + + Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${item.goodsId}` }) + } + > + 立即购买 - ) - })} + + ))} diff --git a/src/shop/category/index.tsx b/src/shop/category/index.tsx index 4f81fd7..74845c3 100644 --- a/src/shop/category/index.tsx +++ b/src/shop/category/index.tsx @@ -1,7 +1,7 @@ import Taro from '@tarojs/taro' import GoodsList from './components/GoodsList' import {useShareAppMessage} from "@tarojs/taro" -import {Loading} from '@nutui/nutui-react-taro' +import {Loading,Empty} from '@nutui/nutui-react-taro' import {useEffect, useState} from "react" import {useRouter} from '@tarojs/taro' import './index.scss' @@ -21,7 +21,7 @@ function Category() { // 1.加载远程数据 const id = Number(params.id) const nav = await getCmsNavigation(id) - const shopGoods = await pageShopGoods({categoryId: id}) + const shopGoods = await pageShopGoods({categoryId: id, status: 0}) // 2.处理业务逻辑 setCategoryId(id) @@ -42,7 +42,7 @@ function Category() { useShareAppMessage(() => { return { - title: `${nav?.categoryName}_时里院子市集`, + title: `${nav?.categoryName}_桂乐淘`, path: `/shop/category/index?id=${categoryId}`, success: function () { console.log('分享成功'); @@ -59,6 +59,12 @@ function Category() { ) } + if(list.length == 0){ + return ( + + ) + } + return ( <>
diff --git a/src/shop/gift/index.config.ts b/src/shop/gift/index.config.ts new file mode 100644 index 0000000..5cf4860 --- /dev/null +++ b/src/shop/gift/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationBarTitleText: '立即送水', + navigationBarTextStyle: 'black' +}) diff --git a/src/shop/gift/index.tsx b/src/shop/gift/index.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/shop/goodsDetail/index.tsx b/src/shop/goodsDetail/index.tsx index 37a6630..bdbf3d2 100644 --- a/src/shop/goodsDetail/index.tsx +++ b/src/shop/goodsDetail/index.tsx @@ -15,6 +15,10 @@ import SpecSelector from "@/components/SpecSelector"; import "./index.scss"; import {useCart} from "@/hooks/useCart"; import {useConfig} from "@/hooks/useConfig"; +import {parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite"; +import { ensureLoggedIn } from '@/utils/auth' +import {getGltTicketTemplateByGoodsId} from "@/api/glt/gltTicketTemplate"; +import type {GltTicketTemplate} from "@/api/glt/gltTicketTemplate/model"; const GoodsDetail = () => { const [statusBarHeight, setStatusBarHeight] = useState(44); @@ -30,6 +34,9 @@ const GoodsDetail = () => { title: '', content: '' }) + // 水票套票模板:存在时该商品不允许加入购物车(购物车无法支付此类商品) + const [ticketTemplate, setTicketTemplate] = useState(null) + const [ticketTemplateChecked, setTicketTemplateChecked] = useState(false) // const [selectedSku, setSelectedSku] = useState(null); const [loading, setLoading] = useState(false); const router = Taro.getCurrentInstance().router; @@ -39,17 +46,49 @@ const GoodsDetail = () => { const {cartCount, addToCart} = useCart() const {config} = useConfig() + // 如果从分享链接进入(携带 inviter/source/t),且当前未登录,则暂存邀请信息用于注册后绑定关系 + useEffect(() => { + try { + const currentUserId = Taro.getStorageSync('UserId') + if (currentUserId) return + + const inviteParams = parseInviteParams({query: router?.params}) + if (inviteParams?.inviter) { + saveInviteParams(inviteParams) + trackInviteSource(inviteParams.source || 'share', parseInt(inviteParams.inviter)) + } + } catch (e) { + // 邀请参数解析/存储失败不影响正常浏览商品 + console.error('商品详情页处理邀请参数失败:', e) + } + // router 在 Taro 中可能不稳定;这里仅在 goodsId 变化时尝试处理一次即可 + }, [goodsId]) + // 处理加入购物车 - const handleAddToCart = () => { + const handleAddToCart = async () => { if (!goods) return; - if (!Taro.getStorageSync('UserId')) { - return Taro.showToast({ - title: '请先登录', - icon: 'none', - duration: 2000 - }); + // 水票套票商品:不允许加入购物车(购物车无法支付) + // 优先使用已加载的 ticketTemplate;若尚未加载则补一次查询 + let tpl = ticketTemplate + let checked = ticketTemplateChecked + if (!tpl && goods?.goodsId) { + try { + tpl = await getGltTicketTemplateByGoodsId(Number(goods.goodsId)) + setTicketTemplate(tpl) + setTicketTemplateChecked(true) + checked = true + } catch (_e) { + tpl = null + setTicketTemplateChecked(true) + checked = true + } } + if (!checked || tpl) { + return + } + + if (!ensureLoggedIn(`/shop/goodsDetail/index?id=${goods.goodsId}`)) return // 如果有规格,显示规格选择器 if (specs.length > 0) { @@ -71,13 +110,7 @@ const GoodsDetail = () => { const handleBuyNow = () => { if (!goods) return; - if (!Taro.getStorageSync('UserId')) { - return Taro.showToast({ - title: '请先登录', - icon: 'none', - duration: 2000 - }); - } + if (!ensureLoggedIn(`/shop/orderConfirm/index?goodsId=${goods.goodsId}`)) return // 如果有规格,显示规格选择器 if (specs.length > 0) { @@ -91,11 +124,30 @@ const GoodsDetail = () => { }; // 规格选择确认回调 - const handleSpecConfirm = (sku: ShopGoodsSku, quantity: number, action?: 'cart' | 'buy') => { + const handleSpecConfirm = async (sku: ShopGoodsSku, quantity: number, action?: 'cart' | 'buy') => { // setSelectedSku(sku); setShowSpecSelector(false); if (action === 'cart') { + // 水票套票商品:不允许加入购物车(购物车无法支付) + let tpl = ticketTemplate + let checked = ticketTemplateChecked + if (!tpl && goods?.goodsId) { + try { + tpl = await getGltTicketTemplateByGoodsId(Number(goods.goodsId)) + setTicketTemplate(tpl) + setTicketTemplateChecked(true) + checked = true + } catch (_e) { + tpl = null + setTicketTemplateChecked(true) + checked = true + } + } + if (!checked || tpl) { + return + } + // 加入购物车 addToCart({ goodsId: goods!.goodsId!, @@ -135,14 +187,19 @@ const GoodsDetail = () => { } useEffect(() => { + let alive = true Taro.getSystemInfo({ success: (res) => { + if (!alive) return setWindowWidth(res.windowWidth) setStatusBarHeight(Number(res.statusBarHeight) + 5) }, }); if (goodsId) { setLoading(true); + // 切换商品时先重置套票模板,避免复用上一个商品状态 + setTicketTemplate(null) + setTicketTemplateChecked(false) // 加载商品详情 getShopGoods(Number(goodsId)) @@ -151,6 +208,7 @@ const GoodsDetail = () => { if (res.content) { res.content = wxParse(res.content); } + if (!alive) return setGoods(res); if (res.files) { const arr = JSON.parse(res.files); @@ -161,12 +219,27 @@ const GoodsDetail = () => { console.error("Failed to fetch goods detail:", error); }) .finally(() => { + if (!alive) return setLoading(false); }); + // 查询商品是否绑定水票模板(失败/无数据时不影响正常浏览) + getGltTicketTemplateByGoodsId(Number(goodsId)) + .then((tpl) => { + if (!alive) return + setTicketTemplate(tpl) + setTicketTemplateChecked(true) + }) + .catch((_e) => { + if (!alive) return + setTicketTemplate(null) + setTicketTemplateChecked(true) + }) + // 加载商品规格 listShopGoodsSpec({goodsId: Number(goodsId)} as any) .then((data) => { + if (!alive) return setSpecs(data || []); }) .catch((error) => { @@ -176,19 +249,29 @@ const GoodsDetail = () => { // 加载商品SKU listShopGoodsSku({goodsId: Number(goodsId)} as any) .then((data) => { + if (!alive) return setSkus(data || []); }) .catch((error) => { console.error("Failed to fetch goods skus:", error); }); } + return () => { + alive = false + } }, [goodsId]); // 分享给好友 useShareAppMessage(() => { + const inviter = Taro.getStorageSync('UserId') + const sharePath = + inviter + ? `/shop/goodsDetail/index?id=${goodsId}&inviter=${inviter}&source=goods_share&t=${Date.now()}` + : `/shop/goodsDetail/index?id=${goodsId}` + return { title: goods?.name || '精选商品', - path: `/shop/goodsDetail/index?id=${goodsId}`, + path: sharePath, imageUrl: goods?.image ? `${goods.image}?x-oss-process=image/resize,w_500,h_400,m_fill` : undefined, // 分享图片,调整为5:4比例 success: function (res: any) { console.log('分享成功', res); @@ -213,6 +296,8 @@ const GoodsDetail = () => { return 加载中...; } + const showAddToCart = ticketTemplateChecked && !ticketTemplate + return ( { - {goods.price} + {goods.buyingPrice} 会员价 - ¥{goods.salePrice} + ¥{goods.salePrice}/{goods.unitName} 已售 {goods.sales} @@ -371,10 +456,12 @@ const GoodsDetail = () => { - handleAddToCart()}>加入购物车 - - handleAddToCart()}>加入购物车 + + )} + handleBuyNow()}>立即购买 diff --git a/src/shop/orderConfirm/index.tsx b/src/shop/orderConfirm/index.tsx index b48c621..c6765b0 100644 --- a/src/shop/orderConfirm/index.tsx +++ b/src/shop/orderConfirm/index.tsx @@ -1,4 +1,4 @@ -import {useEffect, useState} from "react"; +import {useEffect, useMemo, useState} from "react"; import { Image, Button, @@ -9,6 +9,7 @@ import { ActionSheet, Popup, InputNumber, + DatePicker, ConfigProvider } from '@nutui/nutui-react-taro' import {Location, ArrowRight} from '@nutui/icons-react-taro' @@ -27,6 +28,8 @@ import OrderConfirmSkeleton from "@/components/OrderConfirmSkeleton"; import CouponList from "@/components/CouponList"; import {CouponCardProps} from "@/components/CouponCard"; import {getMyAvailableCoupons} from "@/api/shop/shopUserCoupon"; +import {getGltTicketTemplateByGoodsId} from "@/api/glt/gltTicketTemplate"; +import type {GltTicketTemplate} from "@/api/glt/gltTicketTemplate/model"; import { transformCouponData, calculateCouponDiscount, @@ -36,7 +39,11 @@ import { filterUsableCoupons, filterUnusableCoupons } from "@/utils/couponUtils"; -import navTo from "@/utils/common"; +import dayjs from 'dayjs' +import type {ShopStore} from "@/api/shop/shopStore/model"; +import {getShopStore, listShopStore} from "@/api/shop/shopStore"; +import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection"; +import { ensureLoggedIn, isLoggedIn } from '@/utils/auth' const OrderConfirm = () => { @@ -50,6 +57,21 @@ const OrderConfirm = () => { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [payLoading, setPayLoading] = useState(false) + // 配送时间(仅水票套票商品需要) + // 当日截单时间:超过该时间下单,最早配送日顺延到次日(避免 21:00 下单仍显示“当天配送”) + const DELIVERY_CUTOFF_HOUR = 21 + const getMinSendDate = () => { + const now = dayjs() + const cutoff = now.hour(DELIVERY_CUTOFF_HOUR).minute(0).second(0).millisecond(0) + const startOfToday = now.startOf('day') + // >= 截单时间则最早只能选次日 + return now.isSame(cutoff) || now.isAfter(cutoff) ? startOfToday.add(1, 'day') : startOfToday + } + const [sendTime, setSendTime] = useState(() => getMinSendDate().toDate()) + const [sendTimePickerVisible, setSendTimePickerVisible] = useState(false) + + // 水票套票活动(若存在则按规则限制最小购买量等) + const [ticketTemplate, setTicketTemplate] = useState(null) // InputNumber 主题配置 const customTheme = { @@ -67,9 +89,74 @@ const OrderConfirm = () => { const [availableCoupons, setAvailableCoupons] = useState([]) const [couponLoading, setCouponLoading] = useState(false) + // 门店选择:用于在下单页展示当前“已选门店”,并允许用户切换(写入 SelectedStore Storage) + const [storePopupVisible, setStorePopupVisible] = useState(false) + const [stores, setStores] = useState([]) + const [storeLoading, setStoreLoading] = useState(false) + const [selectedStore, setSelectedStore] = useState(getSelectedStoreFromStorage()) + const router = Taro.getCurrentInstance().router; const goodsId = router?.params?.goodsId; + // 页面级兜底:未登录直接进入下单页时,引导去注册/登录并回跳 + useEffect(() => { + if (!goodsId) { + // 也可能是 orderData 模式;这里只做最小兜底 + if (!ensureLoggedIn('/shop/orderConfirm/index')) return + return + } + if (!ensureLoggedIn(`/shop/orderConfirm/index?goodsId=${goodsId}`)) return + }, [goodsId]) + + const isTicketTemplateActive = + !!ticketTemplate && + ticketTemplate.enabled !== false && + ticketTemplate.status !== 1 && + ticketTemplate.deleted !== 1 + const hasTicketTemplate = !!ticketTemplate + + // 套票活动最低购买量:优先取模板配置 + const ticketMinBuyQty = (() => { + const n = Number(ticketTemplate?.minBuyQty) + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1 + })() + const minBuyQty = isTicketTemplateActive ? ticketMinBuyQty : 1 + + const sendTimeText = useMemo(() => { + return dayjs(sendTime).format('YYYY-MM-DD') + }, [sendTime]) + + const getGiftTicketQty = (buyQty: number) => { + if (!isTicketTemplateActive) return 0 + const multiplier = Number(ticketTemplate?.giftMultiplier || 0) + const startSend = Number(ticketTemplate?.startSendQty || 0) + if (multiplier > 0) return Math.max(0, buyQty) * multiplier + return Math.max(0, startSend) + } + + const loadStores = async () => { + if (storeLoading) return + try { + setStoreLoading(true) + const list = await listShopStore() + setStores((list || []).filter(s => s?.isDelete !== 1)) + } catch (e) { + console.error('获取门店列表失败:', e) + setStores([]) + Taro.showToast({title: '获取门店列表失败', icon: 'none'}) + } finally { + setStoreLoading(false) + } + } + + // @ts-ignore + const openStorePopup = async () => { + setStorePopupVisible(true) + if (!stores.length) { + await loadStores() + } + } + // 计算商品总价 const getGoodsTotal = () => { if (!goods) return 0 @@ -112,8 +199,9 @@ const OrderConfirm = () => { // 处理数量变化 const handleQuantityChange = (value: string | number) => { - const newQuantity = typeof value === 'string' ? parseInt(value) || 1 : value - const finalQuantity = Math.max(1, Math.min(newQuantity, goods?.stock || 999)) + const fallback = isTicketTemplateActive ? minBuyQty : 1 + const newQuantity = typeof value === 'string' ? parseInt(value, 10) || fallback : value + const finalQuantity = Math.max(fallback, Math.min(newQuantity, goods?.stock || 999)) setQuantity(finalQuantity) // 数量变化时,重新排序优惠券并检查当前选中的优惠券是否还可用 @@ -252,7 +340,7 @@ const OrderConfirm = () => { } // 加载用户优惠券 - const loadUserCoupons = async () => { + const loadUserCoupons = async (totalOverride?: number) => { try { setCouponLoading(true) @@ -264,7 +352,7 @@ const OrderConfirm = () => { const transformedCoupons = res.map(transformCouponData) // 按优惠金额排序 - const total = getGoodsTotal() + const total = totalOverride ?? getGoodsTotal() const sortedCoupons = sortCoupons(transformedCoupons, total) const usableCoupons = filterUsableCoupons(sortedCoupons, total) @@ -342,6 +430,7 @@ const OrderConfirm = () => { * 统一支付入口 */ const onPay = async (goods: ShopGoods) => { + let skipFinallyResetPayLoading = false try { setPayLoading(true) @@ -362,6 +451,32 @@ const OrderConfirm = () => { return; } + // 水票套票商品:保存配送时间到 ShopOrder.sendStartTime + if (hasTicketTemplate && !sendTime) { + Taro.showToast({ title: '请选择配送时间', icon: 'none' }) + return + } + if (hasTicketTemplate) { + const min = getMinSendDate() + if (dayjs(sendTime).isBefore(min, 'day')) { + setSendTime(min.toDate()) + Taro.showToast({ + title: `已过当日${DELIVERY_CUTOFF_HOUR}点截单,最早配送:${min.format('YYYY-MM-DD')}`, + icon: 'none' + }) + return + } + } + + // 水票套票活动:最小购买量校验 + if (isTicketTemplateActive && quantity < minBuyQty) { + Taro.showToast({ + title: `最低购买量:${minBuyQty}桶`, + icon: 'none' + }) + return + } + // 库存校验 if (goods.stock !== undefined && quantity > goods.stock) { Taro.showToast({ @@ -415,6 +530,9 @@ const OrderConfirm = () => { comments: goods.name, deliveryType: 0, buyerRemarks: orderRemark, + sendStartTime: hasTicketTemplate + ? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss') + : undefined, couponId: parseInt(String(bestCoupon.id), 10) } ); @@ -437,9 +555,12 @@ const OrderConfirm = () => { quantity, address.id, { - comments: '时里院子市集', + comments: '桂乐淘', deliveryType: 0, buyerRemarks: orderRemark, + sendStartTime: hasTicketTemplate + ? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss') + : undefined, // 🔧 确保 couponId 是正确的数字类型,且不传递 undefined couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined } @@ -482,38 +603,70 @@ const OrderConfirm = () => { // icon: 'success' // }) } catch (error: any) { - return navTo('/user/order/order?statusFilter=0', true) - // console.error('支付失败:', error) + const message = String(error?.message || '') + const isUserCancelPay = + message.includes('用户取消支付') || + message.includes('取消支付') || + message.toLowerCase().includes('requestpayment:fail cancel') || + message.toLowerCase().includes('cancel') - // 只处理PaymentHandler未处理的错误 - // if (!error.handled) { - // let errorMessage = '支付失败,请重试'; - // - // // 根据错误类型提供具体提示 - // if (error.message?.includes('余额不足')) { - // errorMessage = '账户余额不足,请充值后重试'; - // } else if (error.message?.includes('优惠券')) { - // errorMessage = '优惠券使用失败,请重新选择'; - // } else if (error.message?.includes('库存')) { - // errorMessage = '商品库存不足,请减少购买数量'; - // } else if (error.message?.includes('地址')) { - // errorMessage = '收货地址信息有误,请重新选择'; - // } else if (error.message) { - // errorMessage = error.message; - // } - // Taro.showToast({ - // title: errorMessage, - // icon: 'error' - // }) - // console.log('跳去未付款的订单列表页面') - // } + // 用户取消支付:跳转到待付款列表,方便继续支付 + if (isUserCancelPay) { + skipFinallyResetPayLoading = true + setPayLoading(false) + const url = '/user/order/order?statusFilter=0' + try { + await Taro.redirectTo({ url }) + } catch (_e) { + try { + await Taro.navigateTo({ url }) + } catch (_e2) { + // ignore + } + } + return + } + + const isOutOfDeliveryRange = + message.includes('不在配送范围') || + message.includes('配送范围') || + message.includes('电子围栏') || + message.includes('围栏') + + // “配送范围”类错误给出更友好的解释,并提供快捷入口去更换收货地址 + if (isOutOfDeliveryRange) { + try { + const res = await Taro.showModal({ + title: '暂不支持配送', + content: '当前收货地址超出配送范围。您可以更换收货地址后再下单,或联系门店确认配送范围。', + confirmText: '更换地址', + cancelText: '我知道了' + }) + if (res?.confirm) { + Taro.navigateTo({ url: '/user/address/index' }) + } + } catch (_e) { + // ignore + } + return + } + + // 兜底:仅在 PaymentHandler 未弹过提示时再提示一次 + if (!error?.handled) { + Taro.showToast({ title: message || '支付失败,请重试', icon: 'none' }) + } } finally { - setPayLoading(false) + if (!skipFinallyResetPayLoading) { + setPayLoading(false) + } } }; // 统一的数据加载函数 const loadAllData = async () => { + // 未登录时不发起接口请求;页面会被登录兜底逻辑引导走注册/登录页 + if (!isLoggedIn()) return + try { setLoading(true) setError('') @@ -530,10 +683,42 @@ const OrderConfirm = () => { ]) // 设置商品信息 - if (goodsRes) { - setGoods(goodsRes) + // 查询当前商品是否存在水票套票活动(失败/无数据时不影响正常下单) + let tpl: GltTicketTemplate | null = null + if (goodsId) { + try { + tpl = await getGltTicketTemplateByGoodsId(Number(goodsId)) + } catch (e) { + tpl = null + } } + const tplActive = + !!tpl && + tpl.enabled !== false && + tpl.status !== 1 && + tpl.deleted !== 1 + + const tplMinBuyQty = (() => { + const n = Number(tpl?.minBuyQty) + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1 + })() + + // 设置商品信息(若存在套票模板,则默认 canBuyNumber 使用模板最小购买量) + if (goodsRes) { + const patchedGoods: ShopGoods = { ...goodsRes } + if (tplActive && ((patchedGoods.canBuyNumber ?? 0) === 0)) { + patchedGoods.canBuyNumber = tplMinBuyQty + } + setGoods(patchedGoods) + + // 设置默认购买数量:优先使用 canBuyNumber,否则使用 1 + const initQty = (patchedGoods.canBuyNumber ?? 0) > 0 ? (patchedGoods.canBuyNumber as number) : 1 + setQuantity(initQty) + } + + setTicketTemplate(tpl) + // 设置默认收货地址 if (addressRes && addressRes.length > 0) { setAddress(addressRes[0]) @@ -548,9 +733,16 @@ const OrderConfirm = () => { setPayment(paymentRes[0]) } - // 加载优惠券(在商品信息加载完成后) + // 加载优惠券:使用“初始数量”对应的总价做推荐,避免默认数量变化导致推荐不准 if (goodsRes) { - await loadUserCoupons() + const initQty = (() => { + const n = Number(goodsRes?.canBuyNumber) + if (Number.isFinite(n) && n > 0) return Math.floor(n) + if (tplActive) return tplMinBuyQty + return 1 + })() + const total = parseFloat(goodsRes.price || '0') * initQty + await loadUserCoupons(total) } } catch (err) { console.error('加载数据失败:', err) @@ -561,10 +753,17 @@ const OrderConfirm = () => { } useDidShow(() => { + // 返回/切换到该页面时,刷新一下当前已选门店 + if (!isLoggedIn()) return + setSelectedStore(getSelectedStoreFromStorage()) loadAllData() }) useEffect(() => { + // 切换商品时重置配送时间,避免沿用上一次选择 + if (!isLoggedIn()) return + setSendTime(getMinSendDate().toDate()) + setSendTimePickerVisible(false) loadAllData() }, [goodsId]); @@ -623,6 +822,48 @@ const OrderConfirm = () => { )} + {hasTicketTemplate && ( + + + {sendTimeText} + + + )} + onClick={() => { + // 若页面停留跨过截单时间,打开选择器前再校正一次最早可选日期 + const min = getMinSendDate() + if (dayjs(sendTime).isBefore(min, 'day')) { + setSendTime(min.toDate()) + } + setSendTimePickerVisible(true) + }} + /> + + )} + + {/**/} + {/* */} + {/* */} + {/* 门店*/} + {/* */} + {/* )}*/} + {/* extra={(*/} + {/* */} + {/* */} + {/* {selectedStore?.name || '请选择门店'}*/} + {/* */} + {/* */} + {/* */} + {/* )}*/} + {/* onClick={openStorePopup}*/} + {/* />*/} + {/**/} + @@ -634,24 +875,32 @@ const OrderConfirm = () => { {goods.name} - 80g/袋 + {/*80g/袋*/} ¥{goods.price} - - - + + + {goods.stock !== undefined && ( 库存 {goods.stock} 件 )} + {isTicketTemplateActive && ( + + 最低购买量:{minBuyQty}桶 + 赠送水票:{getGiftTicketQty(quantity)}张 + + )} @@ -736,6 +985,20 @@ const OrderConfirm = () => { )}/> + {ticketTemplate && ( + + + 注意事项: + 最低起送量≥{ticketTemplate.startSendQty}桶; + 配送范围要在电子围栏内; + 上楼费暂不收取,收费另行通知。 +
+ )}/> + + )} + + {/* 支付方式选择 */} {
+ {/* 门店选择弹窗 */} + setStorePopupVisible(false)} + > + + + 选择门店 + setStorePopupVisible(false)} + > + 关闭 + + + + {storeLoading ? ( + + 加载中... + + ) : ( + + {stores.map((s) => { + const isActive = !!selectedStore?.id && selectedStore.id === s.id + return ( + {s.name || `门店${s.id}`}
} + description={s.address || ''} + onClick={async () => { + let storeToSave: ShopStore = s + if (s?.id) { + try { + const full = await getShopStore(s.id) + if (full) storeToSave = full + } catch (_e) { + // keep base item + } + } + setSelectedStore(storeToSave) + saveSelectedStoreToStorage(storeToSave) + setStorePopupVisible(false) + Taro.showToast({title: '门店已切换', icon: 'success'}) + }} + /> + ) + })} + {!stores.length && ( + 暂无门店数据} /> + )} + + )} + + + + setSendTimePickerVisible(false)} + onCancel={() => setSendTimePickerVisible(false)} + onConfirm={(_options, selectedValue) => { + const [y, m, d] = (selectedValue || []).map(v => Number(v)) + const next = new Date(y, (m || 1) - 1, d || 1, 0, 0, 0) + setSendTime(next) + setSendTimePickerVisible(false) + }} + /> +
@@ -836,6 +1173,7 @@ const OrderConfirm = () => { type="success" size="large" loading={payLoading} + disabled={isTicketTemplateActive && quantity < minBuyQty} onClick={() => onPay(goods)} > {payLoading ? '支付中...' : '立即付款'} diff --git a/src/shop/orderConfirmCart/index.tsx b/src/shop/orderConfirmCart/index.tsx index b9cb9dd..63acb18 100644 --- a/src/shop/orderConfirmCart/index.tsx +++ b/src/shop/orderConfirmCart/index.tsx @@ -2,8 +2,6 @@ import {useEffect, useState} from "react"; import {Image, Button, Cell, CellGroup, Input, Space} from '@nutui/nutui-react-taro' import {Location, ArrowRight} from '@nutui/icons-react-taro' import Taro from '@tarojs/taro' -import {ShopGoods} from "@/api/shop/shopGoods/model"; -import {getShopGoods} from "@/api/shop/shopGoods"; import {View} from '@tarojs/components'; import {listShopUserAddress} from "@/api/shop/shopUserAddress"; import {ShopUserAddress} from "@/api/shop/shopUserAddress/model"; @@ -12,14 +10,12 @@ import {useCart, CartItem} from "@/hooks/useCart"; import Gap from "@/components/Gap"; import {Payment} from "@/api/system/payment/model"; import {PaymentHandler, PaymentType, buildCartOrder} from "@/utils/payment"; +import { ensureLoggedIn } from '@/utils/auth' const OrderConfirm = () => { - const [goods, setGoods] = useState(null); const [address, setAddress] = useState() - const [payment, setPayment] = useState() + const [payment] = useState() const [checkoutItems, setCheckoutItems] = useState([]); - const router = Taro.getCurrentInstance().router; - const goodsId = router?.params?.goodsId; const { cartItems, @@ -27,13 +23,18 @@ const OrderConfirm = () => { } = useCart(); const reload = async () => { - const address = await listShopUserAddress({isDefault: true}); - if (address.length > 0) { - console.log(address, '111') - setAddress(address[0]) + const addressList = await listShopUserAddress({isDefault: true}); + if (addressList.length > 0) { + setAddress(addressList[0]) } } + // 页面级兜底:防止未登录时进入结算页导致接口报错/仅提示“请先登录” + useEffect(() => { + // redirect 到当前结算页,登录成功后返回继续支付 + if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return + }, []) + // 加载结算商品数据 const loadCheckoutItems = () => { try { @@ -57,6 +58,8 @@ const OrderConfirm = () => { * 统一支付入口 */ const onPay = async () => { + if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return + // 基础校验 if (!address) { Taro.showToast({ @@ -77,7 +80,7 @@ const OrderConfirm = () => { // 构建订单数据 const orderData = buildCartOrder( checkoutItems.map(item => ({ - goodsId: item.goodsId!, + goodsId: item.goodsId, quantity: item.quantity || 1 })), address.id, @@ -102,16 +105,11 @@ const OrderConfirm = () => { }; useEffect(() => { - if (goodsId) { - getShopGoods(Number(goodsId)).then(res => { - setGoods(res); - }).catch(error => { - console.error("Failed to fetch goods detail:", error); - }); - } + if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return + reload().then(); loadCheckoutItems(); - }, [goodsId, cartItems]); + }, [cartItems]); // 计算总价 const getTotalPrice = () => { @@ -157,19 +155,19 @@ const OrderConfirm = () => { - {checkoutItems.map((goods, _) => ( - + {checkoutItems.map((item) => ( + - - {goods.name} - 80g/袋 + {item.name} + {/*80g/袋*/} - ¥{goods.price} - x {goods.quantity} + ¥{item.price} + x {item.quantity} diff --git a/src/shop/orderDetail/index.tsx b/src/shop/orderDetail/index.tsx index 7bb5b00..dca8ed1 100644 --- a/src/shop/orderDetail/index.tsx +++ b/src/shop/orderDetail/index.tsx @@ -1,5 +1,5 @@ import {useEffect, useState} from "react"; -import {Cell, CellGroup, Image, Space, Button} from '@nutui/nutui-react-taro' +import {Cell, CellGroup, Image, Space, Button, Dialog} from '@nutui/nutui-react-taro' import Taro from '@tarojs/taro' import {View} from '@tarojs/components' import {ShopOrder} from "@/api/shop/shopOrder/model"; @@ -10,20 +10,34 @@ import dayjs from "dayjs"; import PaymentCountdown from "@/components/PaymentCountdown"; import './index.scss' +// 申请退款:支付成功后仅允许在指定时间窗内发起(前端展示层限制,后端仍应校验) +const isWithinRefundWindow = (payTime?: string, windowMinutes: number = 60): boolean => { + if (!payTime) return false; + const raw = String(payTime).trim(); + const t = /^\d+$/.test(raw) + ? dayjs(Number(raw) < 1e12 ? Number(raw) * 1000 : Number(raw)) // 兼容秒/毫秒时间戳 + : dayjs(raw); + if (!t.isValid()) return false; + return dayjs().diff(t, 'minute') <= windowMinutes; +}; + const OrderDetail = () => { const [order, setOrder] = useState(null); const [orderGoodsList, setOrderGoodsList] = useState([]); + const [confirmReceiveDialogVisible, setConfirmReceiveDialogVisible] = useState(false) const router = Taro.getCurrentInstance().router; const orderId = router?.params?.orderId; // 处理支付超时 const handlePaymentExpired = async () => { if (!order) return; + if (!order.orderId) return; try { // 自动取消过期订单 await updateShopOrder({ - ...order, + // 只传最小字段,避免误取消/误走售后流程 + orderId: order.orderId, orderStatus: 2 // 已取消 }); @@ -44,29 +58,62 @@ const OrderDetail = () => { const handleApplyRefund = async () => { if (order) { try { - // 更新订单状态为"退款申请中" + const confirm = await Taro.showModal({ + title: '申请退款', + content: '确认要申请退款吗?', + confirmText: '确认', + cancelText: '取消' + }) + if (!confirm?.confirm) return + + Taro.showLoading({ title: '提交中...' }) + + // 退款相关操作使用退款接口:PUT /api/shop/shop-order/refund await updateShopOrder({ orderId: order.orderId, - orderStatus: 4 // 退款申请中 - }); + refundMoney: order.payPrice || order.totalPrice, + orderStatus: 7 + }) - // 更新本地状态 - setOrder(prev => prev ? {...prev, orderStatus: 4} : null); + // 乐观更新本地状态 + setOrder(prev => prev ? { ...prev, orderStatus: 7 } : null) - // 跳转到退款申请页面 - Taro.navigateTo({ - url: `/user/order/refund/index?orderId=${order.orderId}&orderNo=${order.orderNo}` - }); + Taro.showToast({ title: '退款申请已提交', icon: 'success' }) } catch (error) { - console.error('更新订单状态失败:', error); + console.error('申请退款失败:', error); Taro.showToast({ title: '操作失败,请重试', icon: 'none' }); + } finally { + try { + Taro.hideLoading() + } catch (_e) { + // ignore + } } } }; + // 确认收货(客户) + const handleConfirmReceive = async () => { + if (!order?.orderId) return + try { + setConfirmReceiveDialogVisible(false) + await updateShopOrder({ + orderId: order.orderId, + deliveryStatus: order.deliveryStatus, // 10未发货 20已发货 30部分发货 + orderStatus: 1 // 已完成 + }) + Taro.showToast({title: '确认收货成功', icon: 'success'}) + setOrder(prev => (prev ? {...prev, orderStatus: 1} : prev)) + } catch (e) { + console.error('确认收货失败:', e) + Taro.showToast({title: '确认收货失败', icon: 'none'}) + setConfirmReceiveDialogVisible(true) + } + } + const getOrderStatusText = (order: ShopOrder) => { // 优先检查订单状态 if (order.orderStatus === 2) return '已取消'; @@ -81,8 +128,15 @@ const OrderDetail = () => { // 已付款后检查发货状态 if (order.deliveryStatus === 10) return '待发货'; - if (order.deliveryStatus === 20) return '待收货'; - if (order.deliveryStatus === 30) return '已收货'; + if (order.deliveryStatus === 20) { + // 若订单有配送员,则以配送员送达时间作为“可确认收货”的依据 + if (order.riderId) { + if (order.sendEndTime && order.orderStatus !== 1) return '待确认收货'; + return '配送中'; + } + return '待收货'; + } + if (order.deliveryStatus === 30) return '部分发货'; // 最后检查订单完成状态 if (order.orderStatus === 1) return '已完成'; @@ -133,12 +187,22 @@ const OrderDetail = () => { return
加载中...
; } + const currentUserId = Number(Taro.getStorageSync('UserId')) + const isOwner = !!currentUserId && currentUserId === order.userId + const canConfirmReceive = + isOwner && + order.payStatus && + order.orderStatus !== 1 && + order.deliveryStatus === 20 && + (!order.riderId || !!order.sendEndTime) + return (
{/* 支付倒计时显示 - 详情页实时更新 */} {!order.payStatus && order.orderStatus !== 2 && (
{ {!order.payStatus && } {!order.payStatus && } - {order.orderStatus === 1 && } - {order.deliveryStatus === 20 && - } + {order.orderStatus === 1 && order.payStatus && isWithinRefundWindow(order.payTime, 60) && ( + + )} + {canConfirmReceive && ( + + )} + + setConfirmReceiveDialogVisible(false)} + > + 确定已经收到商品了吗?确认后订单将完成。 +
); }; diff --git a/src/store/index.config.ts b/src/store/index.config.ts new file mode 100644 index 0000000..5ec6c4d --- /dev/null +++ b/src/store/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '门店中心' +}) diff --git a/src/store/index.scss b/src/store/index.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/store/index.tsx b/src/store/index.tsx new file mode 100644 index 0000000..948f192 --- /dev/null +++ b/src/store/index.tsx @@ -0,0 +1,282 @@ +import React, {useCallback, useState} from 'react' +import {View, Text} from '@tarojs/components' +import {Avatar, Button, ConfigProvider, Grid} from '@nutui/nutui-react-taro' +import {Location, Scan, Shop, Shopping, User} from '@nutui/icons-react-taro' +import Taro, {useDidShow} from '@tarojs/taro' +import {useThemeStyles} from '@/hooks/useTheme' +import {useUser} from '@/hooks/useUser' +import {getSelectedStoreFromStorage} from '@/utils/storeSelection' +import {listShopStoreUser} from '@/api/shop/shopStoreUser' +import {getShopStore} from '@/api/shop/shopStore' +import type {ShopStore as ShopStoreModel} from '@/api/shop/shopStore/model' +import { goToRegister } from '@/utils/auth' + +const StoreIndex: React.FC = () => { + const themeStyles = useThemeStyles() + const {isLoggedIn, loading: userLoading, getAvatarUrl, getDisplayName, getRoleName, hasRole} = useUser() + + const [boundStoreId, setBoundStoreId] = useState(undefined) + const [selectedStore, setSelectedStore] = useState(getSelectedStoreFromStorage()) + const [store, setStore] = useState(selectedStore) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const storeId = boundStoreId || selectedStore?.id + + const parseStoreCoords = (s: ShopStoreModel): {lng: number; lat: number} | null => { + const raw = (s.lngAndLat || s.location || '').trim() + if (!raw) return null + + const parts = raw.split(/[,\s]+/).filter(Boolean) + if (parts.length < 2) return null + + const a = parseFloat(parts[0]) + const b = parseFloat(parts[1]) + if (Number.isNaN(a) || Number.isNaN(b)) return null + + // 常见格式是 "lng,lat";这里做一个简单兜底 + const looksLikeLngLat = Math.abs(a) <= 180 && Math.abs(b) <= 90 + const looksLikeLatLng = Math.abs(a) <= 90 && Math.abs(b) <= 180 + if (looksLikeLngLat) return {lng: a, lat: b} + if (looksLikeLatLng) return {lng: b, lat: a} + return null + } + + const navigateToPage = (url: string) => { + if (!isLoggedIn) { + goToRegister({ redirect: '/store/index' }) + return + } + Taro.navigateTo({url}) + } + + const refresh = useCallback(async () => { + setError(null) + setLoading(true) + try { + const latestSelectedStore = getSelectedStoreFromStorage() + setSelectedStore(latestSelectedStore) + + const userIdRaw = Number(Taro.getStorageSync('UserId')) + const userId = Number.isFinite(userIdRaw) && userIdRaw > 0 ? userIdRaw : undefined + + let foundStoreId: number | undefined = undefined + if (userId) { + // 优先按“店员绑定关系”确定门店归属 + try { + const list = await listShopStoreUser({userId}) + const first = (list || []).find(i => i?.isDelete !== 1 && i?.storeId) + foundStoreId = first?.storeId + setBoundStoreId(foundStoreId) + } catch { + // fallback to SelectedStore + foundStoreId = undefined + setBoundStoreId(undefined) + } + } else { + foundStoreId = undefined + setBoundStoreId(undefined) + } + + const nextStoreId = (foundStoreId || latestSelectedStore?.id) + if (!nextStoreId) { + setStore(latestSelectedStore) + return + } + + // 获取门店详情(用于展示门店名称/地址/仓库等) + const full = await getShopStore(nextStoreId) + setStore(full || (latestSelectedStore?.id === nextStoreId ? latestSelectedStore : ({id: nextStoreId} as ShopStoreModel))) + } catch (e: any) { + const msg = e?.message || '获取门店信息失败' + setError(msg) + } finally { + setLoading(false) + } + }, []) + + // 返回/切换到该页面时,同步最新的已选门店与绑定门店 + useDidShow(() => { + refresh().catch(() => {}) + }) + + const openStoreLocation = () => { + if (!store?.id) { + return Taro.showToast({title: '请先选择门店', icon: 'none'}) + } + const coords = parseStoreCoords(store) + if (!coords) { + return Taro.showToast({title: '门店未配置定位', icon: 'none'}) + } + Taro.openLocation({ + latitude: coords.lat, + longitude: coords.lng, + name: store.name || '门店', + address: store.address || '' + }) + } + + if (!isLoggedIn && !userLoading) { + return ( + + + 请先登录后再进入门店中心 + + + + + + ) + } + + return ( + + {/* 头部信息 */} + + + + + + + } + className="mr-4" + style={{border: '2px solid rgba(255, 255, 255, 0.3)'}} + /> + + + {getDisplayName()} + + + {hasRole('store') ? '门店' : hasRole('rider') ? '配送员' : getRoleName()} + + + + + + + {/* 门店信息 */} + + + 当前门店 + Taro.switchTab({url: '/pages/index/index'})} + > + 切换门店 + + + + {!storeId ? ( + + + 未选择门店,请先去首页选择门店。 + + + + + + ) : ( + + + {store?.name || `门店ID: ${storeId}`} + + {!!store?.address && ( + + {store.address} + + )} + {!!store?.warehouseName && ( + + 默认仓库:{store.warehouseName} + + )} + {!!error && ( + + {error} + + )} + + )} + + + {/* 功能入口 */} + + 门店工具 + + + navigateToPage('/store/orders/index')}> + + + + + + + + navigateToPage('/user/store/verification')}> + + + + + + + + + + + + + + + + Taro.switchTab({url: '/pages/index/index'})}> + + + + + + + + + + + + + ) +} + +export default StoreIndex diff --git a/src/store/orders/index.config.ts b/src/store/orders/index.config.ts new file mode 100644 index 0000000..4de19d9 --- /dev/null +++ b/src/store/orders/index.config.ts @@ -0,0 +1,4 @@ +export default { + navigationBarTitleText: '门店订单', + navigationBarTextStyle: 'black' +} diff --git a/src/store/orders/index.tsx b/src/store/orders/index.tsx new file mode 100644 index 0000000..bcd5e8f --- /dev/null +++ b/src/store/orders/index.tsx @@ -0,0 +1,83 @@ +import {useEffect, useMemo, useState} from 'react' +import Taro from '@tarojs/taro' +import {Button} from '@nutui/nutui-react-taro' +import {View, Text} from '@tarojs/components' +import OrderList from '@/user/order/components/OrderList' +import {getSelectedStoreFromStorage} from '@/utils/storeSelection' +import {listShopStoreUser} from '@/api/shop/shopStoreUser' + +export default function StoreOrders() { + const [boundStoreId, setBoundStoreId] = useState(undefined) + + const isLoggedIn = useMemo(() => { + return !!Taro.getStorageSync('access_token') && !!Taro.getStorageSync('UserId') + }, []) + + const selectedStore = useMemo(() => getSelectedStoreFromStorage(), []) + const storeId = boundStoreId || selectedStore?.id + + useEffect(() => { + }, []) + + useEffect(() => { + // 优先按“店员绑定关系”确定门店归属:门店看到的是自己的订单 + const userId = Number(Taro.getStorageSync('UserId')) + if (!Number.isFinite(userId) || userId <= 0) return + listShopStoreUser({userId}).then(list => { + const first = (list || []).find(i => i?.isDelete !== 1 && i?.storeId) + if (first?.storeId) setBoundStoreId(first.storeId) + }).catch(() => { + // fallback to SelectedStore + }) + }, []) + + if (!isLoggedIn) { + return ( + + + 请先登录 + + + + + + ) + } + + return ( + + + + + 当前门店: + + {boundStoreId + ? (selectedStore?.id === boundStoreId ? (selectedStore?.name || `门店ID: ${boundStoreId}`) : `门店ID: ${boundStoreId}`) + : (selectedStore?.name || '未选择门店')} + + + + {!storeId ? ( + + + 请先在首页左上角选择门店,再查看门店订单。 + + + + + + ) : ( + + )} + + + ) +} diff --git a/src/types/giftCard.ts b/src/types/giftCard.ts index 9178651..3736e25 100644 --- a/src/types/giftCard.ts +++ b/src/types/giftCard.ts @@ -4,7 +4,7 @@ /** 礼品卡类型枚举 */ export enum GiftCardType { - /** 实物礼品卡 */ + /** 礼品劵 */ PHYSICAL = 10, /** 虚拟礼品卡 */ VIRTUAL = 20, diff --git a/src/user/address/add.tsx b/src/user/address/add.tsx index 9ec01f5..dba8324 100644 --- a/src/user/address/add.tsx +++ b/src/user/address/add.tsx @@ -1,6 +1,6 @@ import {useEffect, useState, useRef} from "react"; import {useRouter} from '@tarojs/taro' -import {Button, Loading, CellGroup, Input, TextArea, Form} from '@nutui/nutui-react-taro' +import {Button, Loading, CellGroup, Cell, Input, TextArea, Form} from '@nutui/nutui-react-taro' import {Scan, ArrowRight} from '@nutui/icons-react-taro' import Taro from '@tarojs/taro' import {View} from '@tarojs/components' @@ -9,6 +9,34 @@ import {ShopUserAddress} from "@/api/shop/shopUserAddress/model"; import {getShopUserAddress, listShopUserAddress, updateShopUserAddress, addShopUserAddress} from "@/api/shop/shopUserAddress"; import RegionData from '@/api/json/regions-data.json'; import FixedButton from "@/components/FixedButton"; +import { parseLngLatFromText } from "@/utils/geofence"; + +type SelectedLocation = { lng: string; lat: string; name?: string; address?: string } + +const isLocationDenied = (e: any) => { + const msg = String(e?.errMsg || e?.message || e || '') + return ( + msg.includes('auth deny') || + msg.includes('authorize') || + msg.includes('permission') || + msg.includes('denied') || + msg.includes('scope.userLocation') + ) +} + +const isUserCancel = (e: any) => { + const msg = String(e?.errMsg || e?.message || e || '') + return msg.includes('cancel') +} + +const hasValidLngLat = (addr?: Partial | null) => { + if (!addr) return false + const p = parseLngLatFromText(`${(addr as any)?.lng ?? ''},${(addr as any)?.lat ?? ''}`) + if (!p) return false + // Treat "0,0" as missing in this app (typically used as placeholder by backends). + if (p.lng === 0 && p.lat === 0) return false + return true +} const AddUserAddress = () => { const {params} = useRouter(); @@ -18,16 +46,73 @@ const AddUserAddress = () => { const [visible, setVisible] = useState(false) const [FormData, setFormData] = useState({}) const [inputText, setInputText] = useState('') + const [selectedLocation, setSelectedLocation] = useState(null) + const [regionLocked, setRegionLocked] = useState(false) const formRef = useRef(null) + const wxDraftRef = useRef | null>(null) + const wxDraftPatchedRef = useRef(false) // 判断是编辑还是新增模式 const isEditMode = !!params.id const addressId = params.id ? Number(params.id) : undefined + const fromWx = params.fromWx === '1' || params.fromWx === 'true' + const skipDefaultCheck = + fromWx || params.skipDefaultCheck === '1' || params.skipDefaultCheck === 'true' const reload = async () => { // 整理地区数据 setRegionData() + // 新增模式:若存在“默认地址”但缺少经纬度,则强制引导到编辑页完善定位 + // 目的:确保业务依赖默认地址坐标(选店/配送范围)时不会因为缺失坐标而失败 + if (!isEditMode && !skipDefaultCheck) { + try { + const defaultList = await listShopUserAddress({ isDefault: true }) + const defaultAddr = defaultList?.[0] + if (defaultAddr && !hasValidLngLat(defaultAddr)) { + await Taro.showModal({ + title: '需要完善定位', + content: '默认收货地址缺少定位信息,请先进入编辑页面选择定位并保存后再继续。', + confirmText: '去完善', + showCancel: false + }) + if (defaultAddr.id) { + Taro.navigateTo({ url: `/user/address/add?id=${defaultAddr.id}` }) + } else { + Taro.navigateTo({ url: '/user/address/index' }) + } + return + } + } catch (_e) { + // ignore: 新增页不阻塞渲染 + } + } + + // 微信地址导入:先用微信返回的字段预填表单,让用户手动选择定位后再保存 + if (!isEditMode && fromWx && !wxDraftPatchedRef.current) { + try { + const draft = Taro.getStorageSync('WxAddressDraft') + if (draft) { + wxDraftPatchedRef.current = true + wxDraftRef.current = draft as any + Taro.removeStorageSync('WxAddressDraft') + + setFormData(prev => ({ + ...prev, + ...(draft as any) + })) + + const p = String((draft as any)?.province || '').trim() + const c = String((draft as any)?.city || '').trim() + const r = String((draft as any)?.region || '').trim() + const regionText = [p, c, r].filter(Boolean).join(' ') + if (regionText) setText(regionText) + } + } catch (_e) { + // ignore + } + } + // 如果是编辑模式,加载地址数据 if (isEditMode && addressId) { try { @@ -35,6 +120,13 @@ const AddUserAddress = () => { setFormData(address) // 设置所在地区 setText(`${address.province} ${address.city} ${address.region}`) + // 回显已保存的经纬度(编辑模式) + if (hasValidLngLat(address)) { + setSelectedLocation({ lng: String(address.lng), lat: String(address.lat) }) + setRegionLocked(true) + } else { + setRegionLocked(false) + } } catch (error) { console.error('加载地址失败:', error) Taro.showToast({ @@ -86,30 +178,39 @@ const AddUserAddress = () => { const result = parseAddressText(inputText); // 更新表单数据 - const newFormData = { + const newFormData: any = { ...FormData, name: result.name || FormData.name, phone: result.phone || FormData.phone, - address: result.address || FormData.address, - province: result.province || FormData.province, - city: result.city || FormData.city, - region: result.region || FormData.region + address: result.address || FormData.address }; + if (!regionLocked) { + newFormData.province = result.province || FormData.province + newFormData.city = result.city || FormData.city + newFormData.region = result.region || FormData.region + } + setFormData(newFormData); // 更新地区显示文本 - if (result.province && result.city && result.region) { + if (!regionLocked && result.province && result.city && result.region) { setText(`${result.province} ${result.city} ${result.region}`); } // 更新表单字段值 if (formRef.current) { - formRef.current.setFieldsValue(newFormData); + const patch: any = { + name: newFormData.name, + phone: newFormData.phone, + address: newFormData.address + } + if (!regionLocked && newFormData.region) patch.region = newFormData.region + formRef.current.setFieldsValue(patch); } Taro.showToast({ - title: '识别成功', + title: regionLocked ? '识别成功(所在地区以定位为准)' : '识别成功', icon: 'success' }); @@ -210,15 +311,153 @@ const AddUserAddress = () => { return null; }; + // 选择定位:打开地图让用户选点,保存经纬度到表单数据 + const chooseGeoLocation = async () => { + const applyChosenLocation = (res: any) => { + if (!res) return + if (res.latitude === undefined || res.longitude === undefined) { + Taro.showToast({ title: '定位信息获取失败', icon: 'none' }) + return + } + + const next: SelectedLocation = { + lng: String(res.longitude), + lat: String(res.latitude), + name: res.name, + address: res.address + } + + // 尝试从地图返回的 address 文本解析省市区(best-effort) + const regionResult = res?.provinceName || res?.cityName || res?.adName + ? { + province: String(res.provinceName || ''), + city: String(res.cityName || ''), + region: String(res.adName || '') + } + : parseRegion(String(res.address || '')) + + const province = String(regionResult?.province || '').trim() + const city = String(regionResult?.city || '').trim() + const region = String(regionResult?.region || '').trim() + if (!province || !city || !region) { + Taro.showToast({ title: '定位未识别到所在地区,请重新选择定位', icon: 'none' }) + return + } + + setSelectedLocation(next) + setRegionLocked(true) + + // 将地图选点的地址同步到“收货地址”(不额外拼接省市区字段,省市区由独立字段保存) + const nextDetailAddress = (() => { + const rawAddr = String(res.address || '').trim() + const name = String(res.name || '').trim() + + // 选择定位返回的 address 往往包含省市区,这里尽量剥离掉,避免和表单的省市区字段重复 + let detail = rawAddr + for (const part of [province, city, region]) { + if (part) detail = detail.replace(part, '') + } + detail = detail.replace(/[,,]+/g, ' ').replace(/\s+/g, ' ').trim() + + const base = detail || rawAddr + if (!base && !name) return '' + if (!base) return name + if (!name) return base + return base.includes(name) ? base : `${base} ${name}` + })() + + setFormData(prev => ({ + ...prev, + lng: next.lng, + lat: next.lat, + address: nextDetailAddress || prev.address, + province, + city, + region + })) + + setText(`${province} ${city} ${region}`) + + // 更新表单展示值(Form initialValues 不会跟随 FormData 变化) + if (formRef.current) { + const patch: any = {} + if (nextDetailAddress) patch.address = nextDetailAddress + patch.region = region + formRef.current.setFieldsValue(patch) + } + } + + try { + const initLat = selectedLocation?.lat ? Number(selectedLocation.lat) : undefined + const initLng = selectedLocation?.lng ? Number(selectedLocation.lng) : undefined + const latitude = typeof initLat === 'number' && Number.isFinite(initLat) ? initLat : undefined + const longitude = typeof initLng === 'number' && Number.isFinite(initLng) ? initLng : undefined + const res = await Taro.chooseLocation({ + latitude, + longitude + }) + applyChosenLocation(res) + } catch (e: any) { + console.warn('选择定位失败:', e) + if (isUserCancel(e)) return + if (isLocationDenied(e)) { + try { + const modal = await Taro.showModal({ + title: '需要定位权限', + content: '选择定位需要开启定位权限,请在设置中开启后重试。', + confirmText: '去设置' + }) + if (modal.confirm) { + await Taro.openSetting() + // 权限可能刚被开启:重试一次 + const res = await Taro.chooseLocation({}) + applyChosenLocation(res) + } + } catch (_e) { + // ignore + } + return + } + try { + await Taro.showToast({ title: '打开地图失败,请重试', icon: 'none' }) + } catch (_e) { + // ignore + } + } + } + + const openRegionPicker = () => { + if (regionLocked) { + Taro.showToast({ title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none' }) + return + } + setVisible(true) + } + // 提交表单 const submitSucceed = async (values: any) => { + const loc = + selectedLocation || + (hasValidLngLat(FormData) ? { lng: String(FormData.lng), lat: String(FormData.lat) } : null) + if (!loc) { + Taro.showToast({ title: '请选择定位', icon: 'none' }) + return + } + if (!FormData.province || !FormData.city || !FormData.region) { + Taro.showToast({ title: '请先选择定位以自动填写所在地区', icon: 'none' }) + return + } + try { // 准备提交的数据 const submitData = { ...values, + country: FormData.country, province: FormData.province, city: FormData.city, region: FormData.region, + lng: loc.lng, + lat: loc.lat, isDefault: true // 新增或编辑的地址都设为默认地址 }; @@ -271,13 +510,40 @@ const AddUserAddress = () => { useEffect(() => { // 动态设置页面标题 Taro.setNavigationBarTitle({ - title: isEditMode ? '编辑收货地址' : '新增收货地址' + title: isEditMode ? '编辑收货地址' : (fromWx ? '完善收货地址' : '新增收货地址') }); reload().then(() => { setLoading(false) }) - }, [isEditMode]); + }, [fromWx, isEditMode]); + + useEffect(() => { + if (!regionLocked) return + if (!visible) return + setVisible(false) + }, [regionLocked, visible]) + + // NutUI Form 的 initialValues 在首次渲染后不再响应更新;微信导入时做一次 setFieldsValue 兜底回填。 + useEffect(() => { + if (loading) return + if (isEditMode) return + const draft = wxDraftRef.current + if (!draft) return + if (!formRef.current?.setFieldsValue) return + try { + formRef.current.setFieldsValue({ + name: (draft as any)?.name, + phone: (draft as any)?.phone, + address: (draft as any)?.address, + region: (draft as any)?.region + }) + } catch (_e) { + // ignore + } finally { + wxDraftRef.current = null + } + }, [fromWx, isEditMode, loading]) if (loading) { return 加载中 @@ -294,7 +560,7 @@ const AddUserAddress = () => { onFinishFailed={(errors) => submitFailed(errors)} > -
{ > 识别 -
+
- + - + { rules={[{message: '请输入您的所在地区'}]} required > -
setVisible(true)}> + -
+