Compare commits

..

No commits in common. 'v2.0' and 'master' have entirely different histories.
v2.0 ... master

  1. 221
      QR_CODE_DESIGN.md
  2. 96
      src/api/basicInfo.ts
  3. 288
      src/api/customer.ts
  4. 22
      src/api/login.ts
  5. 66
      src/api/magdev.ts
  6. 42
      src/api/order.ts
  7. 60
      src/api/payment.ts
  8. 149
      src/api/recharge.ts
  9. 262
      src/api/startDevice.ts
  10. 82
      src/app.ts
  11. 73
      src/pageScan/closeTable/components/FooterButtons.tsx
  12. 7
      src/pageScan/closeTable/index.tsx
  13. 68
      src/pageScan/couponStart/index.tsx
  14. 57
      src/pageScan/renewFee/index.tsx
  15. 76
      src/pageScan/selfStart/index.tsx
  16. 74
      src/pageScan/startTable/index.tsx
  17. 67
      src/pageScan/timedStart/index.tsx
  18. 61
      src/pageScan/turnOffRestart/index.tsx
  19. 17
      src/pages/index/index.tsx
  20. 4
      src/pages/qrHandler/index.tsx
  21. 4
      src/pages/store/index.tsx
  22. 7
      src/pages/user/index.tsx
  23. 6
      src/pagesOrder/accountBalance/components/RechargeRecordList.scss
  24. 117
      src/pagesOrder/accountBalance/components/RechargeRecordList.tsx
  25. 22
      src/pagesOrder/accountBalance/index.tsx
  26. 89
      src/pagesOrder/recharge/index.tsx
  27. 6
      src/pagesOrder/rechargeDetail/index.scss
  28. 129
      src/pagesOrder/rechargeDetail/index.tsx
  29. 29
      src/pagesUser/editProfile/index.tsx
  30. 150
      src/services/tableApi.ts
  31. 42
      src/store/table.ts
  32. 51
      src/types/login.ts
  33. 91
      src/types/magdev.ts
  34. 147
      src/types/order.ts
  35. 85
      src/types/recharge.ts
  36. 216
      src/types/startDevice.ts
  37. 24
      src/utils/apiNormalize.ts
  38. 15
      src/utils/callShopPhone.ts
  39. 58
      src/utils/customerNormalize.ts
  40. 135
      src/utils/operateNormalize.ts
  41. 70
      src/utils/orderNormalize.ts
  42. 69
      src/utils/rechargeNormalize.ts
  43. 111
      src/utils/rechargeRecords.ts
  44. 119
      src/utils/scanNormalize.ts
  45. 41
      src/utils/storeNormalize.ts

221
QR_CODE_DESIGN.md

@ -1,221 +0,0 @@ @@ -1,221 +0,0 @@
# 二维码设计方案
## 📋 **业务分析**
### **扫码场景分析**
1. **开台扫码** - 扫描球桌二维码进行开台
2. **关台扫码** - 扫描球桌二维码进行关台
3. **存杆柜扫码** - 扫描存杆柜二维码进行存取
4. **团购券扫码** - 扫描团购券二维码使用优惠
### **跳转页面映射**
```
pageScan/startTable/index - 开台选择页面
pageScan/selfStart/index - 自助开台页面
pageScan/timedStart/index - 定时开台页面
pageScan/couponStart/index - 团购券开台页面
pageScan/closeTable/index - 关台页面
pageScan/renewFee/index - 续费页面
pageScan/turnOffRestart/index - 关灯重开页面
pageScan/myCoupons/index - 我的卡券页面
pageScan/couponUse/index - 卡券使用页面
```
## 🎯 **推荐方案**
### **1. 统一处理页面** ✅ 推荐
**使用 `pages/qrHandler/index` 作为统一的二维码处理入口**
**优势:**
- ✅ 统一的跳转逻辑,便于维护
- ✅ 支持微信扫码和小程序内扫码
- ✅ 错误处理集中化
- ✅ 便于添加埋点和日志
- ✅ 支持复杂的业务逻辑判断
**使用场景:**
1. **微信扫码进入小程序**:`pages/qrHandler/index?q=xxx`
2. **小程序内扫码**:`pages/qrHandler/index?qrContent=xxx`
3. **小程序码**:`pages/qrHandler/index?scene=xxx`
### **2. 二维码内容格式设计**
#### **方案A:JSON 格式** ✅ 推荐
```json
{
"type": "table_start",
"grpSn": "TB001",
"tableId": "table_123",
"storeId": "store_456"
}
```
#### **方案B:URL 格式**
```
https://yourdomain.com/qr?type=table_start&grpSn=TB001&tableId=table_123&storeId=store_456
```
#### **方案C:简单格式**
```
table_start:TB001:table_123:store_456
```
## 🛠️ **后端实现建议**
### **1. 二维码生成接口**
```typescript
// 后端接口设计
POST /api/v2/qrcode/generate
{
"type": "table_start", // 二维码类型
"grpSn": "TB001", // 设备编码
"tableId": "table_123", // 球桌ID
"storeId": "store_456" // 门店ID
}
// 返回
{
"success": true,
"data": {
"qrContent": "{\"type\":\"table_start\",\"grpSn\":\"TB001\",\"tableId\":\"table_123\",\"storeId\":\"store_456\"}",
"qrCodeUrl": "https://api.qrserver.com/v1/create-qr-code/?data=xxx"
}
}
```
### **2. 二维码类型定义**
```typescript
enum QRCodeType {
TABLE_START = 'table_start', // 开台
TABLE_CLOSE = 'table_close', // 关台
CABINET = 'cabinet', // 存杆柜
COUPON = 'coupon' // 团购券
}
```
### **3. 不同类型二维码的数据结构**
#### **开台二维码**
```json
{
"type": "table_start",
"grpSn": "TB001", // 设备编码(必需)
"tableId": "table_123", // 球桌ID(必需)
"storeId": "store_456", // 门店ID(必需)
"tableName": "1号台", // 球桌名称(可选)
"storeInfo": { // 门店信息(可选)
"name": "黑八大师联盟球厅",
"address": "xxx路xxx号"
}
}
```
#### **关台二维码**
```json
{
"type": "table_close",
"grpSn": "TB001", // 设备编码(必需)
"tableId": "table_123", // 球桌ID(必需)
"orderId": "order_789" // 订单ID(可选)
}
```
#### **存杆柜二维码**
```json
{
"type": "cabinet",
"cabinetId": "cabinet_001", // 存杆柜ID(必需)
"storeId": "store_456", // 门店ID(必需)
"cabinetNo": "A01" // 存杆柜编号(可选)
}
```
#### **团购券二维码**
```json
{
"type": "coupon",
"couponCode": "MT123456", // 团购券码(必需)
"platform": "meituan", // 平台:meituan/douyin/dianping(可选)
"value": 50.00 // 券面值(可选)
}
```
## 🔄 **跳转逻辑流程**
```mermaid
graph TD
A[用户扫码] --> B[qrHandler页面]
B --> C[解析二维码内容]
C --> D{判断二维码类型}
D -->|table_start| E[跳转开台选择页面]
D -->|table_close| F[跳转关台页面]
D -->|cabinet| G[跳转存杆柜页面]
D -->|coupon| H[跳转团购券使用页面]
D -->|unknown| I[显示错误提示]
E --> J[startTable/index]
F --> K[closeTable/index]
G --> L[poleStorage/index]
H --> M[couponUse/index]
```
## 📱 **小程序配置**
### **1. 页面路径配置**
在 `app.config.ts` 中已添加:
```typescript
pages: [
// ...
'pages/qrHandler/index', // 统一二维码处理页面
]
```
### **2. 微信扫码跳转配置**
需要在微信公众平台配置小程序的扫码跳转规则:
- 扫码跳转页面:`pages/qrHandler/index`
- 参数传递:通过 `q` 参数传递二维码内容
## ⚠️ **注意事项**
### **1. 安全考虑**
- 二维码内容建议加密或签名
- 添加时效性验证
- 防止二维码被恶意复制使用
### **2. 用户体验**
- 添加加载状态提示
- 错误情况下提供明确的引导
- 支持重新扫码功能
### **3. 兼容性**
- 支持多种二维码格式解析
- 向后兼容旧版本二维码
- 处理异常情况的降级方案
## 🚀 **实施步骤**
1. ✅ **前端已完成**:
- 创建二维码处理工具 `qrCodeHandler.ts`
- 创建统一处理页面 `qrHandler/index`
- 更新扫码页面集成新逻辑
2. 🔄 **后端需要实现**:
- 二维码生成接口
- 不同类型二维码的数据结构
- 二维码验证和安全机制
3. 🔄 **测试验证**:
- 各种类型二维码的跳转测试
- 异常情况的处理测试
- 微信扫码进入的测试
## 💡 **总结建议**
**给后端的建议:**
1. **使用统一的 `pages/qrHandler/index` 页面**作为所有二维码的处理入口
2. **二维码内容采用 JSON 格式**,便于扩展和解析
3. **不需要为每种业务单独创建处理页面**,统一处理更好维护
4. **重点关注二维码的数据结构设计**,确保包含必要的业务参数
这样的设计既保证了功能的完整性,又具有良好的可维护性和扩展性。

96
src/api/basicInfo.ts

@ -1,10 +1,8 @@ @@ -1,10 +1,8 @@
/**
* 基础信息相关 API
* 新接口路径:/api/v2/basicinfo/
* 接口地址:/api/v2/BasicInfo/
*/
import { get } from '../services/request'
import { normalizeStoreList } from '../utils/storeNormalize'
import { pickField, toNumber } from '../utils/apiNormalize'
/**
* 门店(场所)信息
@ -30,95 +28,9 @@ export interface StoreInfoDto { @@ -30,95 +28,9 @@ export interface StoreInfoDto {
createTime: string
}
export interface InstInfoDto {
name: string
sn: string
address: string
phone: string
city: string
longitude: number
latitude: number
}
export interface BpDevInfoDto {
name: string
sn: string
price: number
type: number
class: string
status: number
wstatus: number
share: number
}
export interface BpDevQueryResultDto {
name: string
devs: BpDevInfoDto[]
}
export interface GetStoreListParams {
sn?: string
/** 0=店SN,1=机构SN */
type?: 0 | 1
}
const normalizeInstItem = (raw: Record<string, unknown>): InstInfoDto => ({
name: String(pickField(raw, 'name') ?? ''),
sn: String(pickField(raw, 'sn', 'instsn') ?? ''),
address: String(pickField(raw, 'address', 'add') ?? ''),
phone: String(pickField(raw, 'phone') ?? ''),
city: String(pickField(raw, 'city') ?? ''),
longitude: toNumber(pickField(raw, 'longitude', 'longItude')),
latitude: toNumber(pickField(raw, 'latitude', 'latItude')),
})
const normalizeDevItem = (raw: Record<string, unknown>): BpDevInfoDto => ({
name: String(pickField(raw, 'name') ?? ''),
sn: String(pickField(raw, 'sn') ?? ''),
price: toNumber(pickField(raw, 'price')),
type: toNumber(pickField(raw, 'type')),
class: String(pickField(raw, 'class') ?? ''),
status: toNumber(pickField(raw, 'status')),
wstatus: toNumber(pickField(raw, 'wstatus')),
share: toNumber(pickField(raw, 'share')),
})
/**
* 获取门店列表
*/
export const getStoreList = async (params?: GetStoreListParams) => {
const query: Record<string, string | number> = {}
if (params?.sn) query.sn = params.sn
if (params?.type !== undefined) query.type = params.type
const res = await get<Record<string, unknown>[]>('/basicinfo/getallbpinfo', query)
return { ...res, data: normalizeStoreList(res.data as Record<string, unknown>[]) }
}
/**
* 获取机构列表
*/
export const getInstList = async (instSn?: string) => {
const res = await get<Record<string, unknown>[]>(
'/basicinfo/getinst',
instSn ? { instsn: instSn } : undefined
)
const list = Array.isArray(res.data) ? res.data : []
return { ...res, data: list.map(normalizeInstItem) }
}
/**
* 查询门店下营运设备与价格
* @param sn 球台 SN 或店 SN
* @param type 0=球台SN,1=店SN
* 获取所有场所(门店)列表
*/
export const queryBpDevices = async (sn: string, type: 0 | 1) => {
const res = await get<Record<string, unknown>>('/basicinfo/query', { sn, type })
const src = res.data || {}
const devs = pickField<Record<string, unknown>[]>(src as Record<string, unknown>, 'devs') || []
const data: BpDevQueryResultDto = {
name: String(pickField(src as Record<string, unknown>, 'name') ?? ''),
devs: Array.isArray(devs) ? devs.map(normalizeDevItem) : [],
}
return { ...res, data }
export const getStoreList = () => {
return get<StoreInfoDto[]>('/BasicInfo/GetAllBpInfo')
}

288
src/api/customer.ts

@ -1,295 +1,49 @@ @@ -1,295 +1,49 @@
/**
* 客户信息相关 API
*/
import Taro from '@tarojs/taro'
import config from '../config'
import { get, post } from '../services/request'
import { get, put } from '../services/request'
import { CustomerDto, UpdateCustomerDto } from '../types/login'
import { normalizeCustomer, normalizeBalanceList } from '../utils/customerNormalize'
import { pickField, toNumber } from '../utils/apiNormalize'
import { userStore } from '../store'
export interface CustomerBalanceResult {
amount: number
}
interface ResponseResult<T = unknown> {
message: string
code: number
success: boolean
data: T
}
/**
* 根据 OpenId 获取客户信息
* 根据 OpenId 获取客户信息(含最新余额、会员类型等)
* 登录后或页面刷新时调用,比 getCustomerBySn 更通用(无需 sn)
* @param openId 微信 OpenId
*/
export const getCustomerByOpenId = async (openId: string) => {
const res = await get<Record<string, unknown>>(
`/customer/byopenid/${encodeURIComponent(openId)}`
)
return { ...res, data: normalizeCustomer(res.data as Record<string, unknown>) }
export const getCustomerByOpenId = (openId: string) => {
return get<CustomerDto>(`/Customer/${openId}`)
}
/**
* 查询客户在指定门店的余额
* 根据客户 SN 获取客户详情(含最新余额、会员类型等)
* @param sn 客户编码(CustomerDto.sn)
*/
export const getCustomerBalance = async (openId: string, bpSn: string) => {
const res = await get<Record<string, unknown>>('/customer/balance', {
openid: openId,
bpsn: bpSn,
})
const amount = toNumber(pickField(res.data as Record<string, unknown>, 'amount'))
return { ...res, data: { amount } as CustomerBalanceResult }
export const getCustomerBySn = (sn: string) => {
return get<CustomerDto>(`/Customer/sn/${sn}`)
}
/**
* 查询客户在所有已消费门店的余额列表(含积分/存杆柜数量/VIP到期时间)
* 对应接口:GET /customer/balancelist/{openId}
* 用于 pagesOrder/accountBalance 的"账户余额" Tab(BalanceList 组件)
* 更新客户信息(昵称、头像、手机号、微信号等)
* @param customerId 客户 ID(CustomerDto.id)
* @param dto 要更新的字段
*/
export const getCustomerBalanceList = async (openId: string) => {
const res = await get<Record<string, unknown>>(
`/customer/balancelist/${encodeURIComponent(openId)}`
)
return { ...res, data: normalizeBalanceList(res.data as unknown as Record<string, unknown>[]) }
export const updateCustomer = (customerId: number, dto: UpdateCustomerDto) => {
return put<void>(`/Customer/${customerId}`, dto)
}
/**
* 刷新用户资料 + 余额(有 bpSn 时)
* 读取本地图片并转为 Base64 字符串
* 用于将选择的头像图片编码后传给更新接口
* @param filePath 本地临时文件路径(如 wx.chooseImage 返回的路径)
* @returns Base64 字符串(不含 data:image/xxx;base64, 前缀)
*/
export const refreshCustomerProfile = async (openId: string, bpSn?: string) => {
const profileRes = await getCustomerByOpenId(openId)
if (profileRes.data) {
userStore.setUserInfoFromWechat(profileRes.data)
}
const storeSn = bpSn || profileRes.data?.bpSn || userStore.userInfo?.bpSn
if (storeSn) {
try {
const balanceRes = await getCustomerBalance(openId, storeSn)
userStore.setBalance(balanceRes.data.amount)
if (userStore.userInfo) {
userStore.setUserInfo({
...userStore.userInfo,
amount: balanceRes.data.amount,
})
}
} catch (e) {
console.warn('刷新余额失败:', e)
}
}
return profileRes
}
/**
* multipart 上传头像(小程序 uploadFile 仅支持 POST)
*/
const uploadCustomerWithAvatar = (
customerId: number,
dto: UpdateCustomerDto
): Promise<ResponseResult<CustomerDto>> => {
return new Promise((resolve, reject) => {
const openId = Taro.getStorageSync('openId') as string
const formData: Record<string, string> = { id: String(customerId) }
if (dto.name) formData.name = dto.name
if (dto.phone) formData.phone = dto.phone
if (dto.birthday) formData.birthday = dto.birthday
Taro.uploadFile({
url: `${config.apiPrefix}/customer/update`,
filePath: dto.avatarFilePath!,
name: 'avatarimage',
formData,
header: openId ? { 'X-OpenId': openId } : {},
success: (res) => {
try {
const body = JSON.parse(res.data) as ResponseResult<Record<string, unknown>>
if (body.success) {
resolve({ ...body, data: normalizeCustomer(body.data) })
} else {
reject(body)
}
} catch (err) {
reject(err)
}
},
fail: reject,
})
})
}
/**
* 更新客户信息(昵称、头像、手机号等)
*/
export const updateCustomer = async (customerId: number, dto: UpdateCustomerDto) => {
if (dto.avatarFilePath) {
return uploadCustomerWithAvatar(customerId, dto)
}
const body: Record<string, string> = { id: String(customerId) }
if (dto.name !== undefined) body.name = dto.name
if (dto.phone !== undefined) body.phone = dto.phone
if (dto.birthday !== undefined) body.birthday = dto.birthday
const res = await post<Record<string, unknown>>('/customer/update', body)
return { ...res, data: normalizeCustomer(res.data) }
}
/**
* @deprecated 新接口使用 multipart avatarFilePath
*/
export const readImageAsBase64 = (filePath: string): Promise<string> => {
return new Promise((resolve, reject) => {
const fs = Taro.getFileSystemManager()
const fs = wx.getFileSystemManager()
fs.readFile({
filePath,
encoding: 'base64',
success: (res) => resolve(res.data as string),
fail: (err: TaroGeneral.CallbackResult) => reject(err),
fail: (err) => reject(err),
})
})
}

22
src/api/login.ts

@ -3,27 +3,17 @@ @@ -3,27 +3,17 @@
*/
import request from '../services/request'
import { WechatLoginRequest, WechatAuthResponse } from '../types/login'
import { normalizeLoginData } from '../utils/customerNormalize'
/**
* 微信登录:code → openId + 是否新用户(资料建议再调 byopenid)
* 微信登录
* @param code 微信登录凭证
*/
export const wechatLogin = async (code: string): Promise<WechatAuthResponse> => {
export const wechatLogin = (code: string): Promise<WechatAuthResponse> => {
const data: WechatLoginRequest = { code }
const res = await request({
url: '/wechatauth/login-wechat',
return request({
url: '/WechatAuth/login-wechat',
method: 'POST',
data,
data
})
const normalized = normalizeLoginData(res.data as Record<string, any>)
return {
...res,
data: {
customer: normalized.customer,
isNewCustomer: normalized.isNewCustomer,
message: normalized.message,
},
}
}

66
src/api/magdev.ts

@ -1,66 +0,0 @@ @@ -1,66 +0,0 @@
/**
* magdev 统一操作接口
* peroperate:/api/v2/magdev/peroperate — 去开台(预检)
* operate: /api/v2/magdev/operate — 开台/关台/续费/关灯重开/分享等
* getopstatus:/api/v2/magdev/getopstatus — 查询开台结果(付款后查一次)
*/
import { get, post } from '../services/request'
import {
normalizePreOperateResponse,
normalizeOperateResponse,
normalizeGetOpStatusResponse,
} from '../utils/operateNormalize'
import { OperateRequestDto } from '../types/magdev'
import { CtrlType, PayType } from '../types/startDevice'
/** 小程序 JSAPI 支付固定交易类型 */
const MINI_PROGRAM_TRADE_TYPE = 1
/**
* 去开台(预开台)
* @param type 开台类型(1-押金 2-定时 3-信用 4-团购券)
* @param grpSn 设备编码
* @param openId 用户 openId
*/
export const preOperate = async (type: number, grpSn: string, openId: string) => {
const res = await post<Record<string, unknown>>(
'/magdev/peroperate',
{ type, grpsn: grpSn, openid: openId },
{ showLoading: true, loadingText: '处理中...' }
)
return { ...res, data: normalizePreOperateResponse(res.data as Record<string, unknown>) }
}
/**
* 统一操作接口(开台/关台/续费/关灯重开/分享等)
* @param dto 操作请求参数
*/
export const operate = async (dto: OperateRequestDto) => {
const res = await post<Record<string, unknown>>(
'/magdev/operate',
{
grpsn: dto.grpSn,
openid: dto.openId,
ctrltype: dto.ctrlType,
amount: dto.amount,
paytype: dto.payType,
tradetype: dto.tradeType ?? MINI_PROGRAM_TRADE_TYPE,
couponcode: dto.couponCode ?? '',
remark: dto.remark ?? '',
attach: dto.attach ?? '',
},
{ showLoading: true, loadingText: '处理中...' }
)
return { ...res, data: normalizeOperateResponse(res.data as Record<string, unknown>) }
}
/**
* 查询开台状态(微信支付成功后调用一次,不轮询)
* @param sn 设备编码(grpSn)
*/
export const getOpStatus = async (sn: string) => {
const res = await get<Record<string, unknown>>('/magdev/getopstatus', { sn })
return { ...res, data: normalizeGetOpStatusResponse(res.data as Record<string, unknown>) }
}
export { CtrlType, PayType }

42
src/api/order.ts

@ -2,8 +2,7 @@ @@ -2,8 +2,7 @@
* 订单/支付相关 API
*/
import { get, post } from '../services/request'
import { PaymentResult, QueryPaymentRequest, CloseOrderDto, OrderRecordQueryParams } from '../types/order'
import { normalizeOrderRecordPagedResult, normalizeOrderDetail } from '../utils/orderNormalize'
import { PaymentResult, QueryPaymentRequest, CloseOrderDto } from '../types/order'
/**
* 查询支付结果(通用,通过 outTradeNo 或 tradeNo 查询)
@ -14,7 +13,8 @@ export const queryPaymentResult = (dto: QueryPaymentRequest) => { @@ -14,7 +13,8 @@ export const queryPaymentResult = (dto: QueryPaymentRequest) => {
}
/**
* @deprecated 新接口不对接关单,取消支付由后端自行处理
* 关闭支付订单(用户取消或支付失败时调用,避免产生悬挂订单)
* @param outTradeNo 商户订单号
*/
export const closeOrder = (outTradeNo: string) => {
return post<boolean>('/Order/close', { outTradeNo } as CloseOrderDto)
@ -54,39 +54,3 @@ export const pollTablePaymentStatus = async ( @@ -54,39 +54,3 @@ export const pollTablePaymentStatus = async (
}
return false
}
/* ======================================================================
* 以下为新接口:/api/v2/order/records、/api/v2/order/detail/{id}
* 来源:后端 2026-07-29 更新的《小程序接口与功能(4).docx》「订单管理」章节
* 与上面的 /Order/query、/Order/close(旧接口,暂缓/已废弃)无关
* ====================================================================== */
/**
* 查询订单记录(消费记录列表),用于 pagesUser/orderList
* 必填 openId;支持 startDate/endDate/payType(可多选)/grpSn/categoryType/status 过滤 + 分页
*/
export const getOrderRecords = async (params: OrderRecordQueryParams) => {
const res = await get<Record<string, unknown>>('/order/records', {
openid: params.openId,
bpsn: params.bpSn,
startdate: params.startDate,
enddate: params.endDate,
paytype: params.payType,
grpsn: params.grpSn,
categorytype: params.categoryType,
status: params.status,
pageindex: params.pageIndex ?? 1,
pagesize: params.pageSize ?? 10,
})
return { ...res, data: normalizeOrderRecordPagedResult(res.data as Record<string, unknown>) }
}
/**
* 查询订单详情,用于 pagesUser/orderDetail
* @param id 订单记录 id(来自 order/records 列表项的 id 字段)
*/
export const getOrderDetail = async (id: number | string) => {
const res = await get<Record<string, unknown>>(`/order/detail/${id}`)
return { ...res, data: normalizeOrderDetail(res.data as Record<string, unknown>) }
}

60
src/api/payment.ts

@ -1,60 +0,0 @@ @@ -1,60 +0,0 @@
/**
* 支付渠道相关 API
*/
import { get } from '../services/request'
import { pickField, toNumber } from '../utils/apiNormalize'
export interface PaymentChannelItem {
/** 渠道编码:1-微信 */
code: number
name: string
enabled: boolean
}
/** 微信支付渠道码 */
export const PAY_CHANNEL_WECHAT = 1
/**
* 获取机构支持的支付方式
* 文档暂无响应体定义,按常见结构归一化;仅微信时使用 code=1
*/
export const getPaymentChannels = async (instSn: string) => {
const res = await get<unknown>('/payment/channels', { instsn: instSn }, { showError: false })
let list: PaymentChannelItem[] = []
const raw = res.data
if (Array.isArray(raw)) {
list = raw.map((item) => normalizeChannelItem(item as Record<string, unknown>))
} else if (raw && typeof raw === 'object') {
const channels = pickField<Record<string, unknown>[]>(
raw as Record<string, unknown>,
'channels',
'data'
)
if (Array.isArray(channels)) {
list = channels.map(normalizeChannelItem)
}
}
return { ...res, data: list }
}
const normalizeChannelItem = (raw: Record<string, unknown>): PaymentChannelItem => ({
code: toNumber(pickField(raw, 'code', 'paytype', 'channel')),
name: String(pickField(raw, 'name', 'channelName', 'channelname') ?? ''),
enabled: pickField(raw, 'enabled', 'available') !== false,
})
/**
* 是否支持微信支付(本期仅对接微信)
*/
export const isWechatPaySupported = async (instSn: string): Promise<boolean> => {
try {
const res = await getPaymentChannels(instSn)
if (!res.data.length) return true
return res.data.some((item) => item.code === PAY_CHANNEL_WECHAT && item.enabled)
} catch {
return true
}
}

149
src/api/recharge.ts

@ -1,155 +1,54 @@ @@ -1,155 +1,54 @@
/**
* 充值相关 API
*/
import request, { get, post } from '../services/request'
import { get, post } from '../services/request'
import {
CreateRechargePaymentDto,
RechargePaymentResponseDto,
RechargeWechatPayParameters,
RechargeStatusResult,
RechargeRecordQueryParams,
RechargeRecordPagedResult,
PaymentChannel,
} from '../types/recharge'
import {
normalizeRechargePaymentResponse,
normalizeRechargeRules,
normalizeRechargeStatus,
normalizeWechatPayParams,
} from '../utils/rechargeNormalize'
import { pickField, toNumber } from '../utils/apiNormalize'
/** 小程序 JSAPI 支付 */
const PAY_TYPE_WECHAT = 1
const TRADE_TYPE_JSAPI = 1
/** 小程序 JSAPI 支付固定交易类型 */
const MINI_PROGRAM_TRADE_TYPE = 1
/**
* 创建充值订单
* @param dto 充值请求参数
* @returns 充值支付响应(含微信支付参数)
*/
export const createRechargeOrder = async (dto: CreateRechargePaymentDto) => {
const body = {
openid: dto.openId,
bpsn: dto.bpSn,
amount: dto.amount,
paytype: PAY_TYPE_WECHAT,
tradetype: TRADE_TYPE_JSAPI,
subject: dto.subject || '账户余额充值',
body: dto.body,
}
const res = await post<Record<string, unknown>>('/recharge/create', body, {
export const createRechargeOrder = (dto: CreateRechargePaymentDto) => {
return post<RechargePaymentResponseDto>('/Recharge/create', {
tradeType: MINI_PROGRAM_TRADE_TYPE,
...dto,
}, {
showLoading: true,
loadingText: '创建订单中...',
})
return {
...res,
data: normalizeRechargePaymentResponse(res.data as Record<string, unknown>),
}
}
export interface RechargePaymentQueryResult {
/** 是否充值到账 */
paid: boolean
/** 支付成功但充值未到账,需继续轮询 */
pending: boolean
data: RechargeStatusResult | null
}
/**
* 查询充值支付结果(getrcgstatus)
* code 200=到账,204=支付成功但未到账
*/
export const queryPaymentStatus = async (outTradeNo: string): Promise<RechargePaymentQueryResult> => {
try {
const res = await request<Record<string, unknown>>({
url: '/recharge/getrcgstatus',
method: 'GET',
data: { outtradeno: outTradeNo },
showError: false,
})
const data = normalizeRechargeStatus(res.data as Record<string, unknown>)
if (res.code === 200) {
return { paid: true, pending: false, data }
}
if (res.code === 204) {
return { paid: false, pending: true, data: null }
}
return { paid: false, pending: false, data: null }
} catch (error: unknown) {
const err = error as { code?: number }
if (err.code === 204) {
return { paid: false, pending: true, data: null }
}
throw error
}
}
/**
* 获取充值规则(套餐)
* 查询支付状态
* @param outTradeNo 商户订单号(创建充值订单时返回)
* @param channel 支付渠道
* @returns 支付状态
*/
export const getRechargeRules = async (bpSn: string) => {
const res = await get<Record<string, unknown>>('/recharge/query', { bpsn: bpSn })
return {
...res,
data: normalizeRechargeRules(res.data as Record<string, unknown>),
}
}
/**
* 查询充值记录
*/
export const getRechargeRecords = async (params: RechargeRecordQueryParams) => {
const res = await get<Record<string, unknown>>('/recharge/records', {
customersn: params.customerSn,
startdate: params.startDate,
enddate: params.endDate,
paytype: params.payType,
bpsn: params.bpSn,
pageindex: params.pageIndex,
pagesize: params.pageSize,
})
const src = res.data || {}
const records = pickField<Record<string, unknown>[]>(src as Record<string, unknown>, 'data') || []
const data: RechargeRecordPagedResult = {
data: records.map((item) => ({
id: toNumber(pickField(item, 'id')),
customerSn: String(pickField(item, 'customerSn', 'customersn') ?? ''),
customerName: String(pickField(item, 'customerName', 'customername') ?? ''),
amount: toNumber(pickField(item, 'amount')),
giftAmount: toNumber(pickField(item, 'giftAmount', 'giftamount')),
payType: toNumber(pickField(item, 'payType', 'paytype')),
rcgType: toNumber(pickField(item, 'rcgType', 'rcgtype')),
rcgTypeName: String(pickField(item, 'rcgTypeName', 'rcgtypename') ?? ''),
bpSn: String(pickField(item, 'bpSn', 'bpsn') ?? ''),
bpName: String(pickField(item, 'bpName', 'bpname') ?? ''),
instSn: String(pickField(item, 'instSn', 'instsn') ?? ''),
instName: String(pickField(item, 'instName', 'instname') ?? ''),
logTime: String(pickField(item, 'logTime', 'logtime') ?? ''),
updateTime: String(pickField(item, 'updateTime', 'updatetime') ?? ''),
status: toNumber(pickField(item, 'status')),
outTradeNo: String(pickField(item, 'outTradeNo', 'outtradeno') ?? ''),
})),
totalCount: toNumber(pickField(src as Record<string, unknown>, 'totalCount', 'totalcount')),
pageIndex: toNumber(pickField(src as Record<string, unknown>, 'pageIndex', 'pageindex'), 1),
pageSize: toNumber(pickField(src as Record<string, unknown>, 'pageSize', 'pagesize'), 20),
}
return { ...res, data }
export const queryPaymentStatus = (outTradeNo: string, channel?: PaymentChannel) => {
return get<boolean>(`/Recharge/query/${outTradeNo}`, channel ? { channel } : undefined)
}
/**
* 调起微信支付(充值场景)
* @param payParams 微信支付参数
*/
export const invokeWechatPayForRecharge = (payParams: RechargeWechatPayParameters): Promise<void> => {
const params = normalizeWechatPayParams(payParams as unknown as Record<string, unknown>) || payParams
return new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: params.timeStamp,
nonceStr: params.nonceStr,
package: params.package,
signType: params.signType as 'MD5' | 'HMAC-SHA256' | 'RSA',
paySign: params.paySign,
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType as 'MD5' | 'HMAC-SHA256' | 'RSA',
paySign: payParams.paySign,
success: () => resolve(),
fail: (err) => reject(err),
})

262
src/api/startDevice.ts

@ -1,14 +1,39 @@ @@ -1,14 +1,39 @@
/**
* 台球桌设备操作相关 API
* 扫码获取台桌信息:GET /api/v2/magdev/qrcode
* 开台/关台/续费/关灯重开/分享等统一操作见 src/api/magdev.ts(peroperate / operate / getopstatus)
*
* 注:旧版 /api/v2/TablesDev/* 系列接口(start_device / stop / get-price /
* check-open-status / UpdateShareStatus)已废弃,未被任何页面使用,已随本次清理移除。
* 开台接口:/api/v2/TablesDev/start_device
* 关台/续费/关灯重开接口:/api/v2/TablesDev/stop
*/
import { get } from '../services/request'
import { normalizeTableScanResponse } from '../utils/scanNormalize'
import { WechatPayParameters, CtrlType, PayType } from '../types/startDevice'
import { get, post } from '../services/request'
import {
StartGroupDevRequestDto,
StartDeviceResponse,
WechatPayParameters,
PriceRequestDto,
TableScanResponseDto,
CtrlType,
PayType,
StopGroupDevRequestDto,
StopGroupDevResponseDto,
CheckOpenStatusResponse,
} from '../types/startDevice'
/** 小程序 JSAPI 支付固定交易类型 */
const MINI_PROGRAM_TRADE_TYPE = 1
/**
* 开台操作接口
* @param data 开台请求参数
* @returns 开台响应
*/
export const startDevice = (data: StartGroupDevRequestDto) => {
return post<StartDeviceResponse>('/TablesDev/start_device', {
tradeType: MINI_PROGRAM_TRADE_TYPE,
...data,
}, {
showLoading: true,
loadingText: '处理中...',
})
}
/**
* 调起微信支付
@ -33,15 +58,217 @@ export const invokeWechatPay = (payParams: WechatPayParameters): Promise<void> = @@ -33,15 +58,217 @@ export const invokeWechatPay = (payParams: WechatPayParameters): Promise<void> =
})
}
/**
* 押金开台
* @param grpSn 设备编码
* @param openId 用户OpenId
* @param amount 押金金额
* @param payType 支付方式
*/
export const depositStart = (
grpSn: string,
openId: string,
amount: number,
payType: PayType = PayType.WechatPay
) => {
return startDevice({
grpSn,
openId,
ctrlType: CtrlType.DepositStart,
amount,
payType,
})
}
/**
* 余额开台(押金开台 + 余额支付方式组合)
* @param grpSn 设备编码
* @param openId 用户OpenId
* @param amount 金额
*/
export const balanceStart = (grpSn: string, openId: string, amount: number) => {
return startDevice({
grpSn,
openId,
ctrlType: CtrlType.DepositStart,
amount,
payType: PayType.Balance,
})
}
/**
* 定时开台
* @param grpSn 设备编码
* @param openId 用户OpenId
* @param durationHours 开台时长(小时)
* @param amount 金额
* @param payType 支付方式
*/
export const timedStart = (
grpSn: string,
openId: string,
durationHours: number,
amount: number,
payType: PayType = PayType.WechatPay
) => {
return startDevice({
grpSn,
openId,
ctrlType: CtrlType.TimedStart,
durationHours,
amount,
payType,
})
}
/**
* 团购券开台
* @param grpSn 设备编码
* @param openId 用户OpenId
* @param couponCode 团购券码
*/
export const couponStart = (grpSn: string, openId: string, couponCode: string) => {
return startDevice({
grpSn,
openId,
ctrlType: CtrlType.CouponStart,
couponCode,
})
}
/**
* 关台操作基础接口(对应 /api/v2/TablesDev/stop)
* 统一处理立即关台、续费、关灯重开三种操作
*/
export const stopDevice = (data: StopGroupDevRequestDto) => {
return post<StopGroupDevResponseDto>('/TablesDev/stop', {
tradeType: MINI_PROGRAM_TRADE_TYPE,
...data,
}, {
showLoading: true,
loadingText: '处理中...',
})
}
/**
* 立即关台
* @param grpSn 设备编码
* @param openId 用户OpenId
*/
export const immediateClose = (grpSn: string, openId: string) => {
return stopDevice({
grpSn,
openId,
ctrlType: CtrlType.ImmediateClose,
})
}
/**
* 续费
* @param grpSn 设备编码
* @param openId 用户OpenId
* @param renewalAmount 续费金额(元)
* @param payType 支付方式
*/
export const renewFee = (
grpSn: string,
openId: string,
renewalAmount: number,
payType: PayType = PayType.WechatPay
) => {
return stopDevice({
grpSn,
openId,
ctrlType: CtrlType.Renew,
payType,
renewalAmount,
})
}
/**
* 关灯重开
* @param grpSn 设备编码
* @param openId 用户OpenId
*/
export const turnOffRestart = (grpSn: string, openId: string) => {
return stopDevice({
grpSn,
openId,
ctrlType: CtrlType.TurnOffRestart,
})
}
/**
* 扫码后获取开台信息(球台状态、价格、可用开台方式、当前订单等)
* @param sn 设备 SN(即 grpSn)
* @param openId 用户 OpenId
* @param source 来源(可选)
*/
export const getTableInfo = async (sn: string, openId?: string, source?: string) => {
const res = await get<Record<string, unknown>>('/magdev/qrcode', { sn, openId, source })
return { ...res, data: normalizeTableScanResponse(res.data as Record<string, unknown>) }
export const getTableInfo = (sn: string, openId?: string, source?: string) => {
return get<TableScanResponseDto>('/TablesDev/qrcode', { sn, openId, source })
}
/**
* 根据用户获取台球桌实际价格(VIP/普通用户价格不同)
* @param openId 用户 OpenId
* @param groupDevSn 设备序列号(即 grpSn)
* @returns 该用户对应的价格(元/小时)
*/
export const getTablePrice = (openId: string, groupDevSn: string) => {
const data: PriceRequestDto = { openId, groupDevSn }
return post<number>('/TablesDev/get-price', data)
}
/**
* 查询开台状态(支付成功后轮询,确认台子是否真正开启)
* 解决"付款成功但 scanType 仍为 open"的问题
* @param outTradeNo 商户订单号(开台接口返回的 outTradeNo)
*/
export const checkOpenStatus = (outTradeNo: string) => {
return get<CheckOpenStatusResponse>('/TablesDev/check-open-status', { outTradeNo })
}
/**
* 轮询开台状态,直到台子真正开启或超时/失败
* @param outTradeNo 商户订单号
* @param maxRetries 最大轮询次数(默认 8 次,每次 2s,共约 16s)
* @param intervalMs 轮询间隔(毫秒)
* @returns 开台成功时返回完整状态数据(含 operationId),失败/超时返回 null
*/
export const pollCheckOpenStatus = async (
outTradeNo: string,
maxRetries = 8,
intervalMs = 2000
): Promise<CheckOpenStatusResponse | null> => {
for (let i = 0; i < maxRetries; i++) {
await new Promise(resolve => setTimeout(resolve, intervalMs))
try {
const res = await checkOpenStatus(outTradeNo)
const { isOpened, openStatus } = res.data
if (isOpened && openStatus === 'Opened') {
return res.data
}
if (openStatus === 'Failed') {
console.warn('开台状态查询:台子开启失败')
return null
}
} catch (e) {
console.warn(`查询开台状态第 ${i + 1} 次异常:`, e)
}
}
console.warn('查询开台状态超时')
return null
}
/**
* 更新球台分享状态(分享球台功能)
* @param orderSn 订单编号(opInfo.orderNo)
*/
export const updateShareStatus = (orderSn: string) => {
return post<boolean>('/TablesDev/UpdateShareStatus', { orderSn }, {
showLoading: true,
loadingText: '处理中...',
})
}
// 导出枚举类型
@ -49,7 +276,20 @@ export { CtrlType, PayType } @@ -49,7 +276,20 @@ export { CtrlType, PayType }
// 导出所有方法和类型
export default {
startDevice,
stopDevice,
invokeWechatPay,
depositStart,
balanceStart,
timedStart,
couponStart,
immediateClose,
renewFee,
turnOffRestart,
checkOpenStatus,
pollCheckOpenStatus,
updateShareStatus,
getTablePrice,
getTableInfo,
CtrlType,
PayType,

82
src/app.ts

@ -1,126 +1,66 @@ @@ -1,126 +1,66 @@
import { PropsWithChildren } from 'react'
import { useLaunch } from '@tarojs/taro'
import Taro from '@tarojs/taro'
import { userStore } from './store'
import { refreshCustomerProfile } from './api/customer'
import { getCustomerByOpenId } from './api/customer'
import { wechatLogin } from './api/login'
import './app.scss'
function App({ children }: PropsWithChildren<any>) {
useLaunch(() => {
console.log('台球馆小程序启动')
// 先从本地缓存恢复用户信息(快速恢复登录态)
try {
const savedUserInfo = Taro.getStorageSync('userInfo')
if (savedUserInfo) {
userStore.setUserInfo(savedUserInfo)
}
} catch (error) {
console.warn('初始化用户信息失败:', error)
}
const openId = userStore.userInfo?.wxOpenId || Taro.getStorageSync('openId')
if (openId) {
// 已有 openId:后台静默刷新最新用户信息(余额、会员等),不阻塞启动
refreshCustomerProfile(openId)
getCustomerByOpenId(openId)
.then(res => {
if (res.data) {
userStore.setUserInfoFromWechat(res.data)
}
})
.catch(e => console.warn('启动刷新用户信息失败:', e))
.finally(() => userStore.setLoginReady())
} else {
// 无本地 openId:执行无感登录,wx.login() 不需要用户任何操作
Taro.login({
success: async ({ code }) => {
try {
const res = await wechatLogin(code)
if (res.success && res.data?.customer) {
if (res.success && res.data) {
userStore.setUserInfoFromWechat(res.data.customer)
// 新用户:触发头像/昵称补全弹窗
if (res.data.isNewCustomer) {
userStore.setIsNewUser(true)
}
const wxOpenId = res.data.customer.wxOpenId
if (wxOpenId) {
await refreshCustomerProfile(wxOpenId)
}
}
} catch (e) {
console.warn('无感登录失败:', e)
} finally {
// 无论成功失败,都标记登录流程结束,让 qrHandler 继续处理
userStore.setLoginReady()
}
},
fail: (err) => {
console.warn('wx.login 调用失败:', err)
// 登录失败也要标记结束,避免 qrHandler 永久等待
userStore.setLoginReady()
}
})
}
})
return children
}
export default App

73
src/pageScan/closeTable/components/FooterButtons.tsx

@ -4,15 +4,15 @@ @@ -4,15 +4,15 @@
import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState } from 'react'
import { invokeWechatPay, PayType } from '../../../api/startDevice'
import { operate, CtrlType } from '../../../api/magdev'
import { immediateClose, invokeWechatPay, updateShareStatus } from '../../../api/startDevice'
import { closeOrder, pollTablePaymentStatus } from '../../../api/order'
import { userStore, tableStore } from '../../../store'
import './FooterButtons.scss'
interface FooterButtonsProps {
type: 'owner' | 'guest'
grpSn: string
operationId?: number
operationId: number
/** 是否从支付完成后跳转而来,true 时只显示「立即关台」 */
fromPayment?: boolean
}
@ -59,29 +59,40 @@ const FooterButtons = ({ type, grpSn, operationId, fromPayment = false }: Footer @@ -59,29 +59,40 @@ const FooterButtons = ({ type, grpSn, operationId, fromPayment = false }: Footer
setLoading(true)
try {
console.log('立即关台,设备编码:', grpSn)
const result = await operate({
grpSn,
openId,
ctrlType: CtrlType.ImmediateClose,
amount: 0,
payType: PayType.WechatPay,
})
console.log('立即关台,设备编码:', grpSn, '操作记录ID:', operationId)
// 调用立即关台接口
const result = await immediateClose(grpSn, openId)
console.log('关台接口返回:', result)
const payParams = result.data.paymentResponseDto?.payParameters
const { operationId: responseOperationId, outTradeNo, needPay, payParameters } = result.data
if (payParams) {
// 需要微信支付(补差价场景)
// 判断是否需要支付(可能需要补差价)
if (needPay && payParameters) {
// 调起微信支付
try {
await invokeWechatPay(payParams)
// 关台支付成功后无需额外查询,直接清场
tableStore.clearAll()
await invokeWechatPay(payParameters)
// 轮询服务端确认支付结果(responseOperationId 对应后端 operationId,需确认与 consumptionId 语义一致)
Taro.showLoading({ title: '确认支付中...', mask: true })
const paid = await pollTablePaymentStatus(responseOperationId)
Taro.hideLoading()
if (paid) {
Taro.showToast({ title: '关台成功', icon: 'success', duration: 2000 })
setTimeout(() => Taro.switchTab({ url: '/pages/index/index' }), 2000)
} else {
Taro.showModal({
title: '支付确认中',
content: '支付结果尚未到账,请稍后查看订单,如有疑问请联系店长。',
showCancel: false,
confirmText: '知道了',
})
}
} catch (payError: any) {
console.log('支付错误:', payError)
if (outTradeNo) closeOrder(outTradeNo).catch(() => {})
if (payError.errMsg?.includes('cancel')) {
Taro.showToast({ title: '已取消支付', icon: 'none', duration: 2000 })
} else {
@ -89,13 +100,13 @@ const FooterButtons = ({ type, grpSn, operationId, fromPayment = false }: Footer @@ -89,13 +100,13 @@ const FooterButtons = ({ type, grpSn, operationId, fromPayment = false }: Footer
}
}
} else {
// 无需支付(余额关台 / 押金退还场景)
tableStore.clearAll()
// 无需支付(押金关台场景)
Taro.showToast({ title: '关台成功', icon: 'success', duration: 2000 })
setTimeout(() => Taro.switchTab({ url: '/pages/index/index' }), 2000)
}
} catch (error: any) {
console.error('关台失败:', error)
// 错误提示由 request 拦截器处理
} finally {
setLoading(false)
}
@ -129,23 +140,19 @@ const FooterButtons = ({ type, grpSn, operationId, fromPayment = false }: Footer @@ -129,23 +140,19 @@ const FooterButtons = ({ type, grpSn, operationId, fromPayment = false }: Footer
})
}
// 分享球台(toggle:开启/取消分享)
// 分享球台
const handleShareTable = async () => {
const openId = userStore.userInfo?.wxOpenId || Taro.getStorageSync('openId')
if (!openId || !grpSn) {
Taro.showToast({ title: '信息异常,请重新扫码', icon: 'none', duration: 2000 })
const orderNo = tableStore.scanInfo?.opInfo?.orderNo
if (!orderNo) {
Taro.showToast({ title: '订单编号暂不可用,请稍后再试', icon: 'none', duration: 2000 })
return
}
try {
await operate({
grpSn,
openId,
ctrlType: CtrlType.Share,
amount: 0,
payType: PayType.WechatPay,
})
Taro.showToast({ title: '操作成功', icon: 'success', duration: 2000 })
} catch (_) {}
await updateShareStatus(orderNo)
Taro.showToast({ title: '已开启分享', icon: 'success', duration: 2000 })
} catch (_) {
// 错误由 request 拦截器处理
}
}
// 关灯重开

7
src/pageScan/closeTable/index.tsx

@ -8,7 +8,6 @@ import { observer } from 'mobx-react' @@ -8,7 +8,6 @@ import { observer } from 'mobx-react'
import { ArrowDown } from '@taroify/icons'
import FooterButtons from './components/FooterButtons'
import { tableStore } from '../../store'
import { callShopPhone } from '../../utils/callShopPhone'
import './index.scss'
// 使用本地图片资源
@ -40,7 +39,11 @@ const CloseTable = observer(() => { @@ -40,7 +39,11 @@ const CloseTable = observer(() => {
// 拨打电话
const handlePhoneCall = () => {
callShopPhone(scanInfo?.phone)
Taro.makePhoneCall({
phoneNumber: '100-666-XXXX',
}).catch((err) => {
console.error('拨打电话失败:', err)
})
}
// 切换展开/收起

68
src/pageScan/couponStart/index.tsx

@ -5,10 +5,9 @@ import { View, Text, Image, Input } from '@tarojs/components' @@ -5,10 +5,9 @@ import { View, Text, Image, Input } from '@tarojs/components'
import Taro, { useLoad, useRouter } from '@tarojs/taro'
import { useState } from 'react'
import { observer } from 'mobx-react'
import { invokeWechatPay, getTableInfo, PayType } from '../../api/startDevice'
import { operate, getOpStatus, CtrlType } from '../../api/magdev'
import { couponStart, invokeWechatPay } from '../../api/startDevice'
import { closeOrder, pollTablePaymentStatus } from '../../api/order'
import { userStore, tableStore } from '../../store'
import { callShopPhone } from '../../utils/callShopPhone'
import './index.scss'
// 使用本地图片资源
@ -82,7 +81,11 @@ const CouponStart = observer(() => { @@ -82,7 +81,11 @@ const CouponStart = observer(() => {
// 拨打电话
const handlePhoneCall = () => {
callShopPhone(scanInfo?.phone)
Taro.makePhoneCall({
phoneNumber: '100-666-XXXX',
}).catch((err) => {
console.error('拨打电话失败:', err)
})
}
// 扫描券码
@ -157,56 +160,52 @@ const CouponStart = observer(() => { @@ -157,56 +160,52 @@ const CouponStart = observer(() => {
setLoading(true)
try {
// 使用输入的券码或选中的券
const finalCouponCode = couponCode || `COUPON_${selectedCouponId}`
console.log('团购券开台,券码:', finalCouponCode, '设备编码:', grpSn)
const result = await operate({
grpSn,
openId,
ctrlType: CtrlType.CouponStart,
amount: 0,
payType: PayType.WechatPay,
couponCode: finalCouponCode,
})
// 调用团购券开台接口
const result = await couponStart(grpSn, openId, finalCouponCode)
console.log('开台接口返回:', result)
// 保存操作记录ID到 store
if (result.data.consumptionId) {
tableStore.setCurrentOperationId(result.data.consumptionId)
}
const payParams = result.data.paymentResponseDto?.payParameters
const consumptionId = result.data.consumptionId
const outTradeNo = result.data.paymentResponseDto?.outTradeNo
if (payParams) {
// 判断是否需要支付(团购券一般无需支付,但可能需要补差价)
if (result.data.paymentResponseDto?.payParameters) {
// 调起微信支付
try {
await invokeWechatPay(payParams)
await invokeWechatPay(result.data.paymentResponseDto.payParameters)
Taro.showLoading({ title: '开台确认中...', mask: true })
let isOpened = false
try {
const statusRes = await getOpStatus(grpSn)
isOpened = statusRes.data.isOpened
} catch (_) {}
// 轮询服务端确认支付结果
Taro.showLoading({ title: '确认支付中...', mask: true })
const paid = await pollTablePaymentStatus(consumptionId)
Taro.hideLoading()
if (isOpened) {
try {
const freshInfo = await getTableInfo(grpSn, openId)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
if (paid) {
Taro.showToast({ title: '开台成功', icon: 'success', duration: 2000 })
setTimeout(() => {
Taro.redirectTo({ url: `/pageScan/closeTable/index?grpSn=${grpSn}&fromPayment=1` })
Taro.redirectTo({
url: `/pageScan/closeTable/index?grpSn=${grpSn}&operationId=${consumptionId}&fromPayment=1`
})
}, 2000)
} else {
Taro.showModal({
title: '开台确认中',
content: '台桌正在启动,请稍后重新扫码进入关台页,如有疑问请联系店长。',
title: '支付确认中',
content: '支付结果尚未到账,请稍后至"关台"页查看状态,如有疑问请联系店长。',
showCancel: false,
confirmText: '知道了',
})
}
} catch (payError: any) {
console.log('支付错误:', payError)
if (outTradeNo) closeOrder(outTradeNo).catch(() => {})
if (payError.errMsg?.includes('cancel')) {
Taro.showToast({ title: '已取消支付', icon: 'none', duration: 2000 })
} else {
@ -214,18 +213,17 @@ const CouponStart = observer(() => { @@ -214,18 +213,17 @@ const CouponStart = observer(() => {
}
}
} else {
// 无需支付(团购券直接开台)
try {
const freshInfo = await getTableInfo(grpSn, openId)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
// 无需支付
Taro.showToast({ title: '兑换成功,开台成功', icon: 'success', duration: 2000 })
setTimeout(() => {
Taro.redirectTo({ url: `/pageScan/closeTable/index?grpSn=${grpSn}&fromPayment=1` })
Taro.redirectTo({
url: `/pageScan/closeTable/index?grpSn=${grpSn}&operationId=${consumptionId}&fromPayment=1`
})
}, 2000)
}
} catch (error: any) {
console.error('兑换开台失败:', error)
// 错误提示由 request 拦截器处理
} finally {
setLoading(false)
}

57
src/pageScan/renewFee/index.tsx

@ -6,10 +6,9 @@ import Taro, { useLoad, useRouter } from '@tarojs/taro' @@ -6,10 +6,9 @@ import Taro, { useLoad, useRouter } from '@tarojs/taro'
import { useState } from 'react'
import { observer } from 'mobx-react'
import { Stepper } from '@taroify/core'
import { invokeWechatPay, getTableInfo, PayType } from '../../api/startDevice'
import { operate, CtrlType } from '../../api/magdev'
import { renewFee, invokeWechatPay, PayType } from '../../api/startDevice'
import { closeOrder, pollTablePaymentStatus } from '../../api/order'
import { userStore, tableStore } from '../../store'
import { callShopPhone } from '../../utils/callShopPhone'
import './index.scss'
// 使用本地图片资源
@ -41,7 +40,11 @@ const RenewFee = observer(() => { @@ -41,7 +40,11 @@ const RenewFee = observer(() => {
// 拨打电话
const handlePhoneCall = () => {
callShopPhone(scanInfo?.phone)
Taro.makePhoneCall({
phoneNumber: '100-666-XXXX',
}).catch((err) => {
console.error('拨打电话失败:', err)
})
}
// 续费金额变化
@ -95,29 +98,38 @@ const RenewFee = observer(() => { @@ -95,29 +98,38 @@ const RenewFee = observer(() => {
try {
console.log('立即续费,金额:', renewAmount, '设备编码:', grpSn)
const result = await operate({
grpSn,
openId,
ctrlType: CtrlType.Renew,
amount: renewAmount,
payType: PayType.WechatPay,
})
// 调用续费接口
const result = await renewFee(grpSn, openId, renewAmount, PayType.WechatPay)
console.log('续费接口返回:', result)
const payParams = result.data.paymentResponseDto?.payParameters
const { operationId: responseOperationId, outTradeNo, needPay, payParameters } = result.data
if (payParams) {
try {
await invokeWechatPay(payParams)
// 续费支付成功,无需额外查询,可选刷新关台页数据
// 判断是否需要支付
if (needPay && payParameters) {
// 调起微信支付
try {
const freshInfo = await getTableInfo(grpSn, openId)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
await invokeWechatPay(payParameters)
// 轮询服务端确认支付结果(responseOperationId 对应后端 operationId,需确认与 consumptionId 语义一致)
Taro.showLoading({ title: '确认支付中...', mask: true })
const paid = await pollTablePaymentStatus(responseOperationId)
Taro.hideLoading()
if (paid) {
Taro.showToast({ title: '续费成功', icon: 'success', duration: 2000 })
setTimeout(() => Taro.navigateBack(), 2000)
} else {
Taro.showModal({
title: '支付确认中',
content: '支付结果尚未到账,请稍后刷新页面查看,如有疑问请联系店长。',
showCancel: false,
confirmText: '知道了',
})
}
} catch (payError: any) {
console.log('支付错误:', payError)
if (outTradeNo) closeOrder(outTradeNo).catch(() => {})
if (payError.errMsg?.includes('cancel')) {
Taro.showToast({ title: '已取消支付', icon: 'none', duration: 2000 })
} else {
@ -125,16 +137,13 @@ const RenewFee = observer(() => { @@ -125,16 +137,13 @@ const RenewFee = observer(() => {
}
}
} else {
// 余额续费,无需支付
try {
const freshInfo = await getTableInfo(grpSn, openId)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
// 无需支付
Taro.showToast({ title: '续费成功', icon: 'success', duration: 2000 })
setTimeout(() => Taro.navigateBack(), 2000)
}
} catch (error: any) {
console.error('续费失败:', error)
// 错误提示由 request 拦截器处理
} finally {
setLoading(false)
}

76
src/pageScan/selfStart/index.tsx

@ -6,10 +6,9 @@ import Taro, { useLoad, useRouter } from '@tarojs/taro' @@ -6,10 +6,9 @@ import Taro, { useLoad, useRouter } from '@tarojs/taro'
import { useState } from 'react'
import { observer } from 'mobx-react'
import { Stepper } from '@taroify/core'
import { invokeWechatPay, getTableInfo, PayType } from '../../api/startDevice'
import { operate, getOpStatus, CtrlType } from '../../api/magdev'
import { depositStart, balanceStart, invokeWechatPay, PayType, getTableInfo, pollCheckOpenStatus } from '../../api/startDevice'
import { closeOrder } from '../../api/order'
import { userStore, tableStore } from '../../store'
import { callShopPhone } from '../../utils/callShopPhone'
import './index.scss'
// 使用本地图片资源
@ -43,7 +42,11 @@ const SelfStart = observer(() => { @@ -43,7 +42,11 @@ const SelfStart = observer(() => {
// 拨打电话
const handlePhoneCall = () => {
callShopPhone(scanInfo?.phone)
Taro.makePhoneCall({
phoneNumber: '100-666-XXXX',
}).catch((err) => {
console.error('拨打电话失败:', err)
})
}
// 押金变化
@ -123,47 +126,51 @@ const SelfStart = observer(() => { @@ -123,47 +126,51 @@ const SelfStart = observer(() => {
try {
console.log('立即开台,押金:', deposit, '设备编码:', grpSn, '支付方式:', paymentMethod)
const payType = paymentMethod === '余额支付' ? PayType.Balance : PayType.WechatPay
const result = await operate({
grpSn,
openId,
ctrlType: CtrlType.DepositStart,
amount: deposit,
payType,
})
// 根据支付方式选择接口
let result
if (paymentMethod === '余额支付') {
// 余额开台
result = await balanceStart(grpSn, openId, deposit)
} else {
// 押金开台(微信支付)
result = await depositStart(grpSn, openId, deposit, PayType.WechatPay)
}
console.log('开台接口返回:', result)
// 保存 consumptionId(兼容旧逻辑,新流程不依赖它查接口)
// 保存操作记录ID到 store
if (result.data.consumptionId) {
tableStore.setCurrentOperationId(result.data.consumptionId)
}
const payParams = result.data.paymentResponseDto?.payParameters
const consumptionId = result.data.consumptionId
const outTradeNo = result.data.paymentResponseDto?.outTradeNo
if (payParams) {
// 判断是否需要支付
if (result.data.paymentResponseDto?.payParameters) {
// 调起微信支付
try {
await invokeWechatPay(payParams)
await invokeWechatPay(result.data.paymentResponseDto.payParameters)
// 付款后查一次开台状态(getopstatus,不轮询)
// 轮询台子是否真正开启(check-open-status),解决付款后仍显示 open 的问题
Taro.showLoading({ title: '开台确认中...', mask: true })
let isOpened = false
try {
const statusRes = await getOpStatus(grpSn)
isOpened = statusRes.data.isOpened
} catch (_) {}
const openResult = await pollCheckOpenStatus(outTradeNo!)
Taro.hideLoading()
if (isOpened) {
// 刷新 scanInfo,确保关台页有真实的 opInfo
if (openResult?.isOpened) {
// 用接口返回的 operationId(比 consumptionId 更准确)
const confirmedOperationId = openResult.operationId || consumptionId
// 刷新 scanInfo,关台页 opInfo 有真实数据
try {
const freshInfo = await getTableInfo(grpSn, openId)
const openId2 = userStore.userInfo?.wxOpenId || Taro.getStorageSync('openId')
const freshInfo = await getTableInfo(grpSn, openId2)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
Taro.showToast({ title: '开台成功', icon: 'success', duration: 2000 })
setTimeout(() => {
Taro.redirectTo({ url: `/pageScan/closeTable/index?grpSn=${grpSn}&fromPayment=1` })
Taro.redirectTo({
url: `/pageScan/closeTable/index?grpSn=${grpSn}&operationId=${confirmedOperationId}&fromPayment=1`
})
}, 2000)
} else {
Taro.showModal({
@ -174,6 +181,9 @@ const SelfStart = observer(() => { @@ -174,6 +181,9 @@ const SelfStart = observer(() => {
})
}
} catch (payError: any) {
console.log('支付错误:', payError)
// 关闭服务端未支付订单,避免产生悬挂订单
if (outTradeNo) closeOrder(outTradeNo).catch(() => {})
if (payError.errMsg?.includes('cancel')) {
Taro.showToast({ title: '已取消支付', icon: 'none', duration: 2000 })
} else {
@ -181,18 +191,24 @@ const SelfStart = observer(() => { @@ -181,18 +191,24 @@ const SelfStart = observer(() => {
}
}
} else {
// 无需支付(余额支付场景)
// 无需支付(余额支付等场景)
try {
const freshInfo = await getTableInfo(grpSn, openId)
const openId2 = userStore.userInfo?.wxOpenId || Taro.getStorageSync('openId')
const freshInfo = await getTableInfo(grpSn, openId2)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
} catch (_) {
// 刷新失败不阻断流程
}
Taro.showToast({ title: '开台成功', icon: 'success', duration: 2000 })
setTimeout(() => {
Taro.redirectTo({ url: `/pageScan/closeTable/index?grpSn=${grpSn}&fromPayment=1` })
Taro.redirectTo({
url: `/pageScan/closeTable/index?grpSn=${grpSn}&operationId=${consumptionId}&fromPayment=1`
})
}, 2000)
}
} catch (error: any) {
console.error('开台失败:', error)
// 错误提示由 request 拦截器处理
} finally {
setLoading(false)
}

74
src/pageScan/startTable/index.tsx

@ -3,12 +3,9 @@ @@ -3,12 +3,9 @@
*/
import { View, Text, Image } from '@tarojs/components'
import Taro, { useLoad, useRouter } from '@tarojs/taro'
import { useState } from 'react'
import { observer } from 'mobx-react'
import { CtrlType, getTableInfo } from '../../api/startDevice'
import { preOperate } from '../../api/magdev'
import { userStore, tableStore } from '../../store'
import { callShopPhone } from '../../utils/callShopPhone'
import { CtrlType } from '../../api/startDevice'
import { tableStore } from '../../store'
import './index.scss'
const phoneIcon = require('./images/phone.png')
@ -25,7 +22,6 @@ const StartTable = observer(() => { @@ -25,7 +22,6 @@ const StartTable = observer(() => {
const router = useRouter()
const grpSn = router.params.grpSn || tableStore.currentGrpSn
const scanInfo = tableStore.scanInfo
const [loading, setLoading] = useState(false)
useLoad(() => {
console.log('开台页面加载, grpSn:', grpSn)
@ -33,63 +29,17 @@ const StartTable = observer(() => { @@ -33,63 +29,17 @@ const StartTable = observer(() => {
})
const handlePhoneCall = () => {
callShopPhone(scanInfo?.phone)
Taro.makePhoneCall({ phoneNumber: '100-666-XXXX' }).catch(() => {})
}
const handleStartTable = async (
option: { optionCode: number; type: number; title: string },
available: boolean
) => {
const handleStartTable = (option: { optionCode: number; type: number; title: string }, available: boolean) => {
if (!available) {
Taro.showToast({ title: '当前条件不满足,无法使用该开台方式', icon: 'none', duration: 2000 })
return
}
if (loading) return
const openId = userStore.userInfo?.wxOpenId || Taro.getStorageSync('openId')
if (!openId) {
Taro.showToast({ title: '请先登录', icon: 'none', duration: 2000 })
return
}
if (!grpSn) {
Taro.showToast({ title: '设备信息异常,请重新扫码', icon: 'none', duration: 2000 })
return
}
setLoading(true)
try {
const res = await preOperate(option.type, grpSn, openId)
const data = res.data
// 写入 bpsn / balance / 会员状态 到 userStore
if (data.bpSn && userStore.userInfo) {
userStore.setUserInfo({
...userStore.userInfo,
bpSn: data.bpSn,
bpInfoName: data.bpName,
instSn: data.instSn,
})
}
if (data.balance >= 0) {
userStore.setBalance(data.balance)
}
if (data.outTradeNo) {
// 余额充足,已直接开台 → 刷新 scanInfo 后跳转关台页
try {
const freshInfo = await getTableInfo(grpSn, openId)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
Taro.showToast({ title: '开台成功', icon: 'success', duration: 1500 })
setTimeout(() => {
Taro.redirectTo({ url: `/pageScan/closeTable/index?grpSn=${grpSn}` })
}, 1500)
return
}
// 余额不足 → 进对应子页
// 先用 optionCode 匹配,匹配不到再用 type 匹配
const url = OPTION_URL_MAP[option.optionCode] ?? OPTION_URL_MAP[option.type]
console.log(`[DEBUG] 点击开台方式 title=${option.title} type=${option.type} → url=${url}`)
console.log(`[DEBUG] 点击开台方式 title=${option.title} optionCode=${option.optionCode} type=${option.type} → url=${url}`)
if (url) {
Taro.navigateTo({ url: `${url}?grpSn=${grpSn}` })
} else if (option.type === CtrlType.CreditStart) {
@ -97,11 +47,6 @@ const StartTable = observer(() => { @@ -97,11 +47,6 @@ const StartTable = observer(() => {
} else {
Taro.showToast({ title: '功能开发中', icon: 'none', duration: 2000 })
}
} catch (error: any) {
console.error('preOperate 失败:', error)
} finally {
setLoading(false)
}
}
const price = scanInfo?.price
@ -160,17 +105,16 @@ const StartTable = observer(() => { @@ -160,17 +105,16 @@ const StartTable = observer(() => {
</View>
<View className="method-right">
<View
className={`start-btn ${(!option.available || loading) ? 'disabled' : ''}`}
className={`start-btn ${!option.available ? 'disabled' : ''}`}
onClick={() => handleStartTable(option, option.available)}
>
<Text className="btn-text">
{loading ? '处理中...' : (option.available ? option.actionText || '去开台' : '不可用')}
</Text>
<Text className="btn-text">{option.available ? option.actionText || '去开台' : '不可用'}</Text>
</View>
</View>
</View>
))
) : (
// 接口数据未加载时显示占位
<View className="method-item">
<Text className="method-desc">加载中...</Text>
</View>

67
src/pageScan/timedStart/index.tsx

@ -5,10 +5,9 @@ import { View, Text, Image } from '@tarojs/components' @@ -5,10 +5,9 @@ import { View, Text, Image } from '@tarojs/components'
import Taro, { useLoad, useRouter } from '@tarojs/taro'
import { useState } from 'react'
import { observer } from 'mobx-react'
import { invokeWechatPay, getTableInfo, PayType } from '../../api/startDevice'
import { operate, getOpStatus, CtrlType } from '../../api/magdev'
import { timedStart, invokeWechatPay, PayType } from '../../api/startDevice'
import { closeOrder, pollTablePaymentStatus } from '../../api/order'
import { userStore, tableStore } from '../../store'
import { callShopPhone } from '../../utils/callShopPhone'
import './index.scss'
// 使用本地图片资源
@ -48,7 +47,11 @@ const TimedStart = observer(() => { @@ -48,7 +47,11 @@ const TimedStart = observer(() => {
// 拨打电话
const handlePhoneCall = () => {
callShopPhone(scanInfo?.phone)
Taro.makePhoneCall({
phoneNumber: '100-666-XXXX',
}).catch((err) => {
console.error('拨打电话失败:', err)
})
}
// 选择时长
@ -106,53 +109,48 @@ const TimedStart = observer(() => { @@ -106,53 +109,48 @@ const TimedStart = observer(() => {
const amount = parseFloat(calculateAmount())
console.log('定时开台,时长:', selectedDuration, '小时,金额:', amount, '设备编码:', grpSn)
const result = await operate({
grpSn,
openId,
ctrlType: CtrlType.TimedStart,
amount,
payType: PayType.WechatPay,
})
// 调用定时开台接口
const result = await timedStart(grpSn, openId, selectedDuration, amount, PayType.WechatPay)
console.log('开台接口返回:', result)
// 保存操作记录ID到 store
if (result.data.consumptionId) {
tableStore.setCurrentOperationId(result.data.consumptionId)
}
const payParams = result.data.paymentResponseDto?.payParameters
const consumptionId = result.data.consumptionId
const outTradeNo = result.data.paymentResponseDto?.outTradeNo
if (payParams) {
// 判断是否需要支付
if (result.data.paymentResponseDto?.payParameters) {
// 调起微信支付
try {
await invokeWechatPay(payParams)
await invokeWechatPay(result.data.paymentResponseDto.payParameters)
// 付款后查一次开台状态(不轮询)
Taro.showLoading({ title: '开台确认中...', mask: true })
let isOpened = false
try {
const statusRes = await getOpStatus(grpSn)
isOpened = statusRes.data.isOpened
} catch (_) {}
// 轮询服务端确认支付结果
Taro.showLoading({ title: '确认支付中...', mask: true })
const paid = await pollTablePaymentStatus(consumptionId)
Taro.hideLoading()
if (isOpened) {
try {
const freshInfo = await getTableInfo(grpSn, openId)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
if (paid) {
Taro.showToast({ title: '开台成功', icon: 'success', duration: 2000 })
setTimeout(() => {
Taro.redirectTo({ url: `/pageScan/closeTable/index?grpSn=${grpSn}&fromPayment=1` })
Taro.redirectTo({
url: `/pageScan/closeTable/index?grpSn=${grpSn}&operationId=${consumptionId}&fromPayment=1`
})
}, 2000)
} else {
Taro.showModal({
title: '开台确认中',
content: '台桌正在启动,请稍后重新扫码进入关台页,如有疑问请联系店长。',
title: '支付确认中',
content: '支付结果尚未到账,请稍后至"关台"页查看状态,如有疑问请联系店长。',
showCancel: false,
confirmText: '知道了',
})
}
} catch (payError: any) {
console.log('支付错误:', payError)
if (outTradeNo) closeOrder(outTradeNo).catch(() => {})
if (payError.errMsg?.includes('cancel')) {
Taro.showToast({ title: '已取消支付', icon: 'none', duration: 2000 })
} else {
@ -160,18 +158,17 @@ const TimedStart = observer(() => { @@ -160,18 +158,17 @@ const TimedStart = observer(() => {
}
}
} else {
// 无需支付(余额开台等)
try {
const freshInfo = await getTableInfo(grpSn, openId)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
// 无需支付
Taro.showToast({ title: '开台成功', icon: 'success', duration: 2000 })
setTimeout(() => {
Taro.redirectTo({ url: `/pageScan/closeTable/index?grpSn=${grpSn}&fromPayment=1` })
Taro.redirectTo({
url: `/pageScan/closeTable/index?grpSn=${grpSn}&operationId=${consumptionId}&fromPayment=1`
})
}, 2000)
}
} catch (error: any) {
console.error('开台失败:', error)
// 错误提示由 request 拦截器处理
} finally {
setLoading(false)
}

61
src/pageScan/turnOffRestart/index.tsx

@ -5,10 +5,9 @@ import { View, Text, Image } from '@tarojs/components' @@ -5,10 +5,9 @@ import { View, Text, Image } from '@tarojs/components'
import Taro, { useLoad, useRouter } from '@tarojs/taro'
import { useState } from 'react'
import { observer } from 'mobx-react'
import { invokeWechatPay, getTableInfo, PayType } from '../../api/startDevice'
import { operate, CtrlType } from '../../api/magdev'
import { turnOffRestart, invokeWechatPay } from '../../api/startDevice'
import { closeOrder, pollTablePaymentStatus } from '../../api/order'
import { userStore, tableStore } from '../../store'
import { callShopPhone } from '../../utils/callShopPhone'
import './index.scss'
// 使用本地图片资源
@ -35,7 +34,11 @@ const TurnOffRestart = observer(() => { @@ -35,7 +34,11 @@ const TurnOffRestart = observer(() => {
// 拨打电话
const handlePhoneCall = () => {
callShopPhone(scanInfo?.phone)
Taro.makePhoneCall({
phoneNumber: '100-666-XXXX',
}).catch((err) => {
console.error('拨打电话失败:', err)
})
}
// 确认结算
@ -77,31 +80,40 @@ const TurnOffRestart = observer(() => { @@ -77,31 +80,40 @@ const TurnOffRestart = observer(() => {
setLoading(true)
try {
console.log('关灯重开,设备编码:', grpSn)
const result = await operate({
grpSn,
openId,
ctrlType: CtrlType.TurnOffRestart,
amount: 0,
payType: PayType.WechatPay,
})
console.log('关灯重开,设备编码:', grpSn, '操作记录ID:', operationId)
// 调用关灯重开接口
const result = await turnOffRestart(grpSn, openId)
console.log('关灯重开接口返回:', result)
const payParams = result.data.paymentResponseDto?.payParameters
const { operationId: responseOperationId, outTradeNo, needPay, payParameters } = result.data
if (payParams) {
// 判断是否需要支付
if (needPay && payParameters) {
// 调起微信支付
try {
await invokeWechatPay(payParams)
// 支付成功后刷新 scanInfo,关台页返回时显示新会话数据
try {
const freshInfo = await getTableInfo(grpSn, openId)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
await invokeWechatPay(payParameters)
// 轮询服务端确认支付结果(responseOperationId 对应后端 operationId,需确认与 consumptionId 语义一致)
Taro.showLoading({ title: '确认支付中...', mask: true })
const paid = await pollTablePaymentStatus(responseOperationId)
Taro.hideLoading()
if (paid) {
Taro.showToast({ title: '结算成功', icon: 'success', duration: 2000 })
setTimeout(() => Taro.navigateBack(), 2000)
} else {
Taro.showModal({
title: '支付确认中',
content: '支付结果尚未到账,请稍后刷新页面查看,如有疑问请联系店长。',
showCancel: false,
confirmText: '知道了',
})
}
} catch (payError: any) {
console.log('支付错误:', payError)
if (outTradeNo) closeOrder(outTradeNo).catch(() => {})
if (payError.errMsg?.includes('cancel')) {
Taro.showToast({ title: '已取消支付', icon: 'none', duration: 2000 })
} else {
@ -109,16 +121,13 @@ const TurnOffRestart = observer(() => { @@ -109,16 +121,13 @@ const TurnOffRestart = observer(() => {
}
}
} else {
// 余额结算,无需支付,刷新新会话数据
try {
const freshInfo = await getTableInfo(grpSn, openId)
tableStore.setScanInfo(freshInfo.data)
} catch (_) {}
// 无需支付
Taro.showToast({ title: '结算成功', icon: 'success', duration: 2000 })
setTimeout(() => Taro.navigateBack(), 2000)
}
} catch (error: any) {
console.error('关灯重开失败:', error)
// 错误提示由 request 拦截器处理
} finally {
setLoading(false)
}

17
src/pages/index/index.tsx

@ -9,7 +9,7 @@ import { Swiper, Image as TaroifyImage } from '@taroify/core' @@ -9,7 +9,7 @@ import { Swiper, Image as TaroifyImage } from '@taroify/core'
import { LocationOutlined } from '@taroify/icons'
import { useEffect, useState } from 'react'
import { userStore } from '../../store'
import { updateCustomer } from '@/api/customer'
import { updateCustomer, readImageAsBase64 } from '@/api/customer'
import { getBigImage } from '@/utils/imageHelper'
import myAvatar from '@/assets/icon/my_avatar.png'
import './index.scss'
@ -50,17 +50,26 @@ const Index = observer(() => { @@ -50,17 +50,26 @@ const Index = observer(() => {
setSaving(true)
try {
let avatarImage: string | undefined
if (newAvatarPath) {
try {
avatarImage = await readImageAsBase64(newAvatarPath)
} catch (e) {
console.warn('头像转 Base64 失败,跳过头像更新:', e)
}
}
await updateCustomer(userId, {
id: userId,
name: newNickname.trim() || undefined,
avatarFilePath: newAvatarPath || undefined,
avatarImage,
})
// 同步更新本地 store
userStore.setUserInfo({
...userStore.userInfo!,
name: newNickname.trim() || userStore.userInfo?.name || '',
nickname: newNickname.trim() || userStore.userInfo?.nickname || '微信用户',
name: newNickname.trim() || userStore.userInfo?.name,
nickname: newNickname.trim() || userStore.userInfo?.nickname,
avatar: newAvatarPath || userStore.userInfo?.avatar,
})

4
src/pages/qrHandler/index.tsx

@ -100,9 +100,7 @@ const QRHandlerPage = () => { @@ -100,9 +100,7 @@ const QRHandlerPage = () => {
if (scanType === 'open') {
await Taro.navigateTo({ url: `/pageScan/startTable/index?grpSn=${sn}` })
} else {
// isOwner=false 表示扫的是别人正在使用的台,关台页需展示"帮他付款/AA制付款"按钮组
const type = res.data.isOwner === false ? 'guest' : 'owner'
await Taro.navigateTo({ url: `/pageScan/closeTable/index?grpSn=${sn}&type=${type}` })
await Taro.navigateTo({ url: `/pageScan/closeTable/index?grpSn=${sn}` })
}
return
}

4
src/pages/store/index.tsx

@ -210,7 +210,7 @@ const StoreIndex = observer(() => { @@ -210,7 +210,7 @@ const StoreIndex = observer(() => {
// 跳转到店铺详情
const clickStoreItem = (store: StoreInfoDto) => {
Taro.navigateTo({
url: '/pageStore/storeDetail/index?sn=' + store.sn
url: '/pageStore/storeDetail/index?id=' + store.id
})
}
@ -269,7 +269,7 @@ const StoreIndex = observer(() => { @@ -269,7 +269,7 @@ const StoreIndex = observer(() => {
<View className="list-footer"><Text className="footer-text">暂无门店数据</Text></View>
) : (
storeList.map((store) => (
<View className="store-item" key={store.sn || store.id} onClick={() => clickStoreItem(store)}>
<View className="store-item" key={store.id} onClick={() => clickStoreItem(store)}>
{/* 标题和类型 */}
<View className="store-header">
<Text className="store-name">{store.name}</Text>

7
src/pages/user/index.tsx

@ -7,7 +7,7 @@ import Taro from '@tarojs/taro' @@ -7,7 +7,7 @@ import Taro from '@tarojs/taro'
import { observer } from 'mobx-react'
import { Image as TaroifyImage } from '@taroify/core'
import { userStore } from '../../store'
import { refreshCustomerProfile } from '../../api/customer'
import { getCustomerByOpenId } from '../../api/customer'
import { getBigImage } from '@/utils/imageHelper'
import './index.scss'
@ -32,7 +32,10 @@ const UserIndex = observer(() => { @@ -32,7 +32,10 @@ const UserIndex = observer(() => {
const openId = userStore.userInfo?.wxOpenId || Taro.getStorageSync('openId')
if (userStore.isLoggedIn && openId) {
try {
await refreshCustomerProfile(openId)
const res = await getCustomerByOpenId(openId)
if (res.data) {
userStore.setUserInfoFromWechat(res.data)
}
} catch (e) {
console.warn('刷新用户信息失败:', e)
}

6
src/pagesOrder/accountBalance/components/RechargeRecordList.scss

@ -2,12 +2,6 @@ @@ -2,12 +2,6 @@
padding: 30px;
// 空数据展示
.empty-wrap {
.empty-month-card {
margin-bottom: 0;
}
}
.empty-data {
display: flex;
justify-content: center;

117
src/pagesOrder/accountBalance/components/RechargeRecordList.tsx

@ -1,57 +1,76 @@ @@ -1,57 +1,76 @@
/**
* 充值记录列表组件(账户余额页 Tab)
* 充值记录列表组件
*/
import { View, Text, Image } from '@tarojs/components'
import { FC, useEffect, useState } from 'react'
import { FC, useState } from 'react'
import { ArrowDown } from '@taroify/icons'
import { Popup, DatetimePicker } from '@taroify/core'
import Taro from '@tarojs/taro'
import {
fetchRechargeRecordsByMonth,
getCurrentYearMonth,
RechargeRecordViewItem,
} from '../../../utils/rechargeRecords'
import './RechargeRecordList.scss'
import { getBigImage } from '@/utils/imageHelper'
interface RechargeRecordItem {
id: string
storeName: string
rechargeTime: string
rechargeType: string
amount: number
status: string
}
interface RechargeRecordListProps {
/** 兼容旧 props,实际数据改由接口拉取 */
recordList?: RechargeRecordViewItem[]
recordList: RechargeRecordItem[]
}
const RechargeRecordList: FC<RechargeRecordListProps> = () => {
const [selectedMonth, setSelectedMonth] = useState(getCurrentYearMonth())
const RechargeRecordList: FC<RechargeRecordListProps> = ({ recordList }) => {
const [selectedMonth, setSelectedMonth] = useState('2025-03')
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [pickerValue, setPickerValue] = useState(new Date())
const [dataList, setDataList] = useState<RechargeRecordViewItem[]>([])
const loadRecords = async (yearMonth: string) => {
Taro.showLoading({ title: '加载中...', mask: true })
try {
const list = await fetchRechargeRecordsByMonth(yearMonth)
setDataList(list)
} catch (error: any) {
setDataList([])
if (error?.message === 'NO_CUSTOMER_SN') {
Taro.showToast({ title: '请先登录', icon: 'none', duration: 2000 })
const [dataList, setDataList] = useState<RechargeRecordItem[]>(recordList)
// 模拟接口请求充值记录数据
const fetchRechargeRecords = async (yearMonth: string) => {
Taro.showLoading({
title: '加载中...',
mask: true
})
// 模拟接口请求
return new Promise<RechargeRecordItem[]>((resolve) => {
setTimeout(() => {
// 模拟返回数据
const mockData: RechargeRecordItem[] = [
{
id: `${yearMonth}-1`,
storeName: '来力.山岩',
rechargeTime: `${yearMonth}-15 14:30:00`,
rechargeType: '账户充值',
amount: 100,
status: '充值完成'
},
{
id: `${yearMonth}-2`,
storeName: '来力.山岩',
rechargeTime: `${yearMonth}-10 10:20:00`,
rechargeType: '账户充值',
amount: 200,
status: '充值完成'
}
} finally {
]
setDataList(mockData)
Taro.hideLoading()
}
resolve(mockData)
}, 1000) // 模拟 1 秒的网络延迟
})
}
useEffect(() => {
loadRecords(selectedMonth)
// 仅挂载时拉当前月
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const groupByMonth = (records: RechargeRecordViewItem[]) => {
const groups: Record<string, RechargeRecordViewItem[]> = {}
// 按月份分组记录
const groupByMonth = (records: RechargeRecordItem[]) => {
const groups: Record<string, RechargeRecordItem[]> = {}
records.forEach(record => {
const month = (record.rechargeTime || '').substring(0, 7)
if (!month || month.length < 7) return
const month = record.rechargeTime.substring(0, 7) // 提取 YYYY-MM
if (!groups[month]) {
groups[month] = []
}
@ -62,14 +81,17 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => { @@ -62,14 +81,17 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => {
const groupedRecords = groupByMonth(dataList)
const calculateMonthTotal = (records: RechargeRecordViewItem[]) => {
return records.reduce((sum, record) => sum + (record.amount || 0), 0)
// 计算当月总充值金额
const calculateMonthTotal = (records: RechargeRecordItem[]) => {
return records.reduce((sum, record) => sum + record.amount, 0)
}
// 打开月份选择器
const handleSelectMonth = () => {
setDatePickerOpen(true)
}
// 确认选择月份
const handleDateConfirm = async (value: Date) => {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
@ -79,9 +101,11 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => { @@ -79,9 +101,11 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => {
setPickerValue(value)
setDatePickerOpen(false)
await loadRecords(formattedMonth)
// 调用接口请求该月份的数据
await fetchRechargeRecords(formattedMonth)
}
// 取消选择
const handleDateCancel = () => {
setDatePickerOpen(false)
}
@ -89,19 +113,9 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => { @@ -89,19 +113,9 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => {
return (
<View className="recharge-record-list">
{dataList.length === 0 ? (
<View className="empty-wrap">
<View className="month-card empty-month-card">
<View className="month-header">
<View className="month-left" onClick={handleSelectMonth}>
<Text className="month-text">{selectedMonth}</Text>
<ArrowDown className="month-arrow" size="20px" color="#FFFEFE" />
</View>
</View>
</View>
<View className="empty-data">
<Image className="empty-image" src={getBigImage('NO_DATA')} mode="aspectFit" />
</View>
</View>
) : (
<>
{Object.keys(groupedRecords).map(month => {
@ -110,6 +124,7 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => { @@ -110,6 +124,7 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => {
return (
<View key={month} className="month-card">
{/* 月份标题和充值统计 */}
<View className="month-header">
<View className="month-left" onClick={handleSelectMonth}>
<Text className="month-text">{month}</Text>
@ -122,21 +137,26 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => { @@ -122,21 +137,26 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => {
</View>
</View>
{/* 分割线 */}
<View className="divider" />
{/* 记录列表 */}
<View className="record-list">
{monthRecords.map((item, index) => (
<View key={item.id}>
<View className="record-item">
{/* 第一行:店铺名称 + 状态 */}
<View className="item-row">
<Text className="store-name">{item.storeName}</Text>
<Text className="status">{item.status}</Text>
</View>
{/* 第二行:充值类型 */}
<View className="item-row">
<Text className="recharge-type">{item.rechargeType}</Text>
</View>
{/* 第三行:充值时间 + 实付金额 */}
<View className="item-row">
<Text className="recharge-time">充值时间:{item.rechargeTime}</Text>
<View className="actual-pay">
@ -146,6 +166,7 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => { @@ -146,6 +166,7 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => {
</View>
</View>
{/* item 之间的分割线 */}
{index < monthRecords.length - 1 && (
<View className="item-divider" />
)}
@ -156,12 +177,14 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => { @@ -156,12 +177,14 @@ const RechargeRecordList: FC<RechargeRecordListProps> = () => {
)
})}
{/* 到底了提示 */}
<View className="list-end">
<Text className="end-text">到底了~</Text>
</View>
</>
)}
{/* 年月选择器 */}
<Popup
open={datePickerOpen}
placement="bottom"

22
src/pagesOrder/accountBalance/index.tsx

@ -73,6 +73,26 @@ const AccountBalance = () => { @@ -73,6 +73,26 @@ const AccountBalance = () => {
}
])
// 充值记录模拟数据
const [rechargeRecordList] = useState([
{
id: '1',
storeName: '北京XXXXXXXXXXXXX店',
rechargeTime: '2025-03-09 00:00:00',
rechargeType: '账户充值',
amount: 100.00,
status: '充值完成'
},
{
id: '2',
storeName: '北京XXX店',
rechargeTime: '2025-03-09 00:00:00',
rechargeType: '账户充值',
amount: 100.00,
status: '充值完成'
}
])
useLoad(() => {
console.log('账户余额页面加载')
})
@ -119,7 +139,7 @@ const AccountBalance = () => { @@ -119,7 +139,7 @@ const AccountBalance = () => {
)}
{activeTab === 2 && (
<RechargeRecordList />
<RechargeRecordList recordList={rechargeRecordList} />
)}
</ScrollView>
</View>

89
src/pagesOrder/recharge/index.tsx

@ -6,25 +6,18 @@ import Taro, { useLoad } from '@tarojs/taro' @@ -6,25 +6,18 @@ import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { observer } from 'mobx-react'
import { Arrow } from '@taroify/icons'
import {
createRechargeOrder,
invokeWechatPayForRecharge,
queryPaymentStatus,
getRechargeRules,
} from '@/api/recharge'
import { RechargeRuleItem } from '@/types/recharge'
import { createRechargeOrder, invokeWechatPayForRecharge, queryPaymentStatus } from '@/api/recharge'
import { userStore } from '@/store'
import './index.scss'
// 充值套餐数据(套餐内容需与后端确认是否动态配置)
interface RechargePackage {
id: number
amount: number
bonus: number
desc?: string
}
/** 接口不可用时的兜底套餐 */
const FALLBACK_PACKAGES: RechargePackage[] = [
const RechargePackages: RechargePackage[] = [
{ id: 1, amount: 100, bonus: 50 },
{ id: 2, amount: 200, bonus: 100 },
{ id: 3, amount: 300, bonus: 200 },
@ -36,63 +29,36 @@ const FALLBACK_PACKAGES: RechargePackage[] = [ @@ -36,63 +29,36 @@ const FALLBACK_PACKAGES: RechargePackage[] = [
const RechargePage = observer(() => {
const [selectedPackageId, setSelectedPackageId] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
const [packages, setPackages] = useState<RechargePackage[]>(FALLBACK_PACKAGES)
const [storeName, setStoreName] = useState(userStore.userInfo?.bpInfoName || '--')
useLoad(async () => {
const bpSn = userStore.userInfo?.bpSn
if (!bpSn) return
try {
const res = await getRechargeRules(bpSn)
if (res.data?.rules?.length) {
setPackages(
res.data.rules.map((rule: RechargeRuleItem, index: number) => ({
id: index + 1,
amount: rule.recharge,
bonus: rule.gift,
desc: rule.desc,
}))
)
if (res.data.bpName) {
setStoreName(res.data.bpName)
}
}
} catch (e) {
console.warn('获取充值规则失败,使用默认套餐:', e)
}
useLoad(() => {
console.log('余额充值页面加载')
})
// 当前余额和门店名称从 userStore 读取
const currentBalance = userStore.balance
const storeName = userStore.userInfo?.bpInfoName || '--'
// 选择套餐
const handleSelectPackage = (id: number) => {
setSelectedPackageId(id)
}
// 查看明细
const handleViewDetail = () => {
Taro.navigateTo({
url: '/pagesOrder/rechargeDetail/index'
})
}
// 轮询查询支付状态,最多查 5 次,每次间隔 2 秒
const pollPaymentStatus = async (outTradeNo: string): Promise<boolean> => {
for (let i = 0; i < 5; i++) {
await new Promise(resolve => setTimeout(resolve, 2000))
try {
const res = await queryPaymentStatus(outTradeNo)
if (res.paid && res.data) {
userStore.setBalance(res.data.balance)
if (userStore.userInfo) {
userStore.setUserInfo({
...userStore.userInfo,
amount: res.data.balance,
})
}
const res = await queryPaymentStatus(outTradeNo, 'WechatPay')
if (res.data === true) {
return true
}
if (!res.pending) {
return false
}
} catch (e) {
console.warn('查询支付状态异常:', e)
}
@ -100,6 +66,7 @@ const RechargePage = observer(() => { @@ -100,6 +66,7 @@ const RechargePage = observer(() => {
return false
}
// 立即充值
const handleRecharge = async () => {
if (loading) return
@ -108,29 +75,29 @@ const RechargePage = observer(() => { @@ -108,29 +75,29 @@ const RechargePage = observer(() => {
return
}
const selectedPackage = packages.find(pkg => pkg.id === selectedPackageId)
const selectedPackage = RechargePackages.find(pkg => pkg.id === selectedPackageId)
if (!selectedPackage) return
const openId = userStore.userInfo?.wxOpenId
const bpSn = userStore.userInfo?.bpSn
if (!openId) {
Taro.showToast({ title: '请先登录', icon: 'none', duration: 2000 })
return
}
if (!bpSn) {
Taro.showToast({ title: '未绑定门店,暂无法充值', icon: 'none', duration: 2000 })
return
}
setLoading(true)
try {
// 创建充值订单
const result = await createRechargeOrder({
userId: String(userStore.userInfo?.id || ''),
customerSn: userStore.userInfo?.bpSn,
amount: selectedPackage.amount,
bpSn,
giftAmount: selectedPackage.bonus,
bpSn: userStore.userInfo?.bpSn,
instSn: userStore.userInfo?.instSn,
openId,
subject: '账户余额充值',
body: selectedPackage.desc || `充值 ¥${selectedPackage.amount},赠送 ¥${selectedPackage.bonus}`,
body: `充值 ¥${selectedPackage.amount},赠送 ¥${selectedPackage.bonus}`,
})
const payData = result.data
@ -140,14 +107,20 @@ const RechargePage = observer(() => { @@ -140,14 +107,20 @@ const RechargePage = observer(() => {
return
}
// 调起微信支付
try {
await invokeWechatPayForRecharge(payData.payParameters)
// 微信支付成功回调后,查询服务端支付状态确认
Taro.showLoading({ title: '确认支付中...', mask: true })
const paid = await pollPaymentStatus(payData.outTradeNo)
Taro.hideLoading()
if (paid) {
// 更新本地余额(充值金额 + 赠送金额)
const newBalance = currentBalance + selectedPackage.amount + selectedPackage.bonus
userStore.setBalance(newBalance)
Taro.showToast({ title: '充值成功', icon: 'success', duration: 2000 })
setSelectedPackageId(null)
} else {
@ -168,6 +141,7 @@ const RechargePage = observer(() => { @@ -168,6 +141,7 @@ const RechargePage = observer(() => {
}
} catch (error: any) {
console.error('创建充值订单失败:', error)
// 错误提示由 request 拦截器统一处理
} finally {
setLoading(false)
}
@ -175,6 +149,7 @@ const RechargePage = observer(() => { @@ -175,6 +149,7 @@ const RechargePage = observer(() => {
return (
<View className="recharge-page">
{/* 上部分:余额卡片区域 */}
<View className="balance-card-area">
<Text className="store-name">{storeName}</Text>
@ -205,11 +180,13 @@ const RechargePage = observer(() => { @@ -205,11 +180,13 @@ const RechargePage = observer(() => {
</View>
</View>
{/* 下部分:套餐和提示区域 */}
<View className="packages-area">
<Text className="section-title">余额充值</Text>
{/* 充值套餐列表 */}
<View className="packages-grid">
{packages.map(pkg => (
{RechargePackages.map(pkg => (
<View
key={pkg.id}
className={`package-item ${selectedPackageId === pkg.id ? 'selected' : ''}`}
@ -221,6 +198,7 @@ const RechargePage = observer(() => { @@ -221,6 +198,7 @@ const RechargePage = observer(() => {
))}
</View>
{/* 温馨提示 */}
<View className="tips-section">
<Text className="tips-title">温馨提示:{'\n'}</Text>
<Text className="tips-content">
@ -233,6 +211,7 @@ const RechargePage = observer(() => { @@ -233,6 +211,7 @@ const RechargePage = observer(() => {
</View>
</View>
{/* 底部固定按钮 */}
<View className="footer-bar">
<View className={`recharge-btn ${loading ? 'disabled' : ''}`} onClick={handleRecharge}>
<Text className="recharge-btn-text">{loading ? '处理中...' : '立即充值'}</Text>

6
src/pagesOrder/rechargeDetail/index.scss

@ -4,12 +4,6 @@ @@ -4,12 +4,6 @@
min-height: 100vh;
// 空数据展示
.empty-wrap {
.empty-month-card {
margin-bottom: 0;
}
}
.empty-data {
display: flex;
justify-content: center;

129
src/pagesOrder/rechargeDetail/index.tsx

@ -6,46 +6,90 @@ import { useState } from 'react' @@ -6,46 +6,90 @@ import { useState } from 'react'
import { ArrowDown } from '@taroify/icons'
import { Popup, DatetimePicker } from '@taroify/core'
import Taro, { useLoad } from '@tarojs/taro'
import {
fetchRechargeRecordsByMonth,
getCurrentYearMonth,
RechargeRecordViewItem,
} from '../../utils/rechargeRecords'
import './index.scss'
import { getBigImage } from '@/utils/imageHelper'
interface RechargeRecordItem {
id: string
storeName: string
rechargeTime: string
rechargeType: string
amount: number
status: string
}
const RechargeDetail = () => {
const [selectedMonth, setSelectedMonth] = useState(getCurrentYearMonth())
const [selectedMonth, setSelectedMonth] = useState('2025-03')
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [pickerValue, setPickerValue] = useState(new Date())
const [dataList, setDataList] = useState<RechargeRecordViewItem[]>([])
const loadRecords = async (yearMonth: string) => {
Taro.showLoading({ title: '加载中...', mask: true })
try {
const list = await fetchRechargeRecordsByMonth(yearMonth)
setDataList(list)
} catch (error: any) {
setDataList([])
if (error?.message === 'NO_CUSTOMER_SN') {
Taro.showToast({ title: '请先登录', icon: 'none', duration: 2000 })
}
// 其它错误由 request 拦截器提示
} finally {
Taro.hideLoading()
}
// 模拟充值记录数据
const [dataList, setDataList] = useState<RechargeRecordItem[]>([
{
id: '2025-03-1',
storeName: '北京球场风云店',
rechargeTime: '2025-03-09 00:00:00',
rechargeType: '账户充值',
amount: 100.00,
status: '充值完成'
},
{
id: '2025-03-2',
storeName: '北京球场风云店',
rechargeTime: '2025-03-06 00:00:00',
rechargeType: '账户充值',
amount: 100.00,
status: '充值完成'
}
])
useLoad(() => {
loadRecords(selectedMonth)
console.log('充值明细页面加载')
})
const groupByMonth = (records: RechargeRecordViewItem[]) => {
const groups: Record<string, RechargeRecordViewItem[]> = {}
// 模拟接口请求充值记录数据
const fetchRechargeRecords = async (yearMonth: string) => {
Taro.showLoading({
title: '加载中...',
mask: true
})
// 模拟接口请求
return new Promise<RechargeRecordItem[]>((resolve) => {
setTimeout(() => {
// 模拟返回数据
const mockData: RechargeRecordItem[] = [
{
id: `${yearMonth}-1`,
storeName: '北京球场风云店',
rechargeTime: `${yearMonth}-15 14:30:00`,
rechargeType: '账户充值',
amount: 100,
status: '充值完成'
},
{
id: `${yearMonth}-2`,
storeName: '北京球场风云店',
rechargeTime: `${yearMonth}-10 10:20:00`,
rechargeType: '账户充值',
amount: 200,
status: '充值完成'
}
]
setDataList(mockData)
Taro.hideLoading()
resolve(mockData)
}, 1000) // 模拟 1 秒的网络延迟
})
}
// 按月份分组记录
const groupByMonth = (records: RechargeRecordItem[]) => {
const groups: Record<string, RechargeRecordItem[]> = {}
records.forEach(record => {
const month = (record.rechargeTime || '').substring(0, 7)
if (!month || month.length < 7) return
const month = record.rechargeTime.substring(0, 7) // 提取 YYYY-MM
if (!groups[month]) {
groups[month] = []
}
@ -56,14 +100,17 @@ const RechargeDetail = () => { @@ -56,14 +100,17 @@ const RechargeDetail = () => {
const groupedRecords = groupByMonth(dataList)
const calculateMonthTotal = (records: RechargeRecordViewItem[]) => {
return records.reduce((sum, record) => sum + (record.amount || 0), 0)
// 计算当月总充值金额
const calculateMonthTotal = (records: RechargeRecordItem[]) => {
return records.reduce((sum, record) => sum + record.amount, 0)
}
// 打开月份选择器
const handleSelectMonth = () => {
setDatePickerOpen(true)
}
// 确认选择月份
const handleDateConfirm = async (value: Date) => {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
@ -73,30 +120,21 @@ const RechargeDetail = () => { @@ -73,30 +120,21 @@ const RechargeDetail = () => {
setPickerValue(value)
setDatePickerOpen(false)
await loadRecords(formattedMonth)
// 调用接口请求该月份的数据
await fetchRechargeRecords(formattedMonth)
}
// 取消选择
const handleDateCancel = () => {
setDatePickerOpen(false)
}
return (
<View className="recharge-detail-page">
{/* 无数据时仍可切换月份 */}
{dataList.length === 0 ? (
<View className="empty-wrap">
<View className="month-card empty-month-card">
<View className="month-header">
<View className="month-left" onClick={handleSelectMonth}>
<Text className="month-text">{selectedMonth}</Text>
<ArrowDown className="month-arrow" size="20px" color="#000000" />
</View>
</View>
</View>
<View className="empty-data">
<Image className="empty-image" src={getBigImage('NO_DATA')} mode="aspectFit" />
</View>
</View>
) : (
<>
{Object.keys(groupedRecords).map(month => {
@ -105,6 +143,7 @@ const RechargeDetail = () => { @@ -105,6 +143,7 @@ const RechargeDetail = () => {
return (
<View key={month} className="month-card">
{/* 月份标题和充值统计 */}
<View className="month-header">
<View className="month-left" onClick={handleSelectMonth}>
<Text className="month-text">{month}</Text>
@ -117,21 +156,26 @@ const RechargeDetail = () => { @@ -117,21 +156,26 @@ const RechargeDetail = () => {
</View>
</View>
{/* 分割线 */}
<View className="divider" />
{/* 记录列表 */}
<View className="record-list">
{monthRecords.map((item, index) => (
<View key={item.id}>
<View className="record-item">
{/* 第一行:店铺名称 + 状态 */}
<View className="item-row">
<Text className="store-name">{item.storeName}</Text>
<Text className="status">{item.status}</Text>
</View>
{/* 第二行:充值类型 */}
<View className="item-row">
<Text className="recharge-type">{item.rechargeType}</Text>
</View>
{/* 第三行:充值时间 + 实付金额 */}
<View className="item-row">
<Text className="recharge-time">充值时间:{item.rechargeTime}</Text>
<View className="actual-pay">
@ -141,6 +185,7 @@ const RechargeDetail = () => { @@ -141,6 +185,7 @@ const RechargeDetail = () => {
</View>
</View>
{/* item 之间的分割线 */}
{index < monthRecords.length - 1 && (
<View className="item-divider" />
)}
@ -151,12 +196,14 @@ const RechargeDetail = () => { @@ -151,12 +196,14 @@ const RechargeDetail = () => {
)
})}
{/* 到底了提示 */}
<View className="list-end">
<Text className="end-text">到底了~</Text>
</View>
</>
)}
{/* 年月选择器 */}
<Popup
open={datePickerOpen}
placement="bottom"

29
src/pagesUser/editProfile/index.tsx

@ -6,7 +6,7 @@ import Taro, { useLoad } from '@tarojs/taro' @@ -6,7 +6,7 @@ import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { observer } from 'mobx-react'
import { Image as TaroifyImage, Button, ActionSheet } from '@taroify/core'
import { updateCustomer } from '../../api/customer'
import { updateCustomer, readImageAsBase64 } from '../../api/customer'
import { userStore } from '../../store'
import myAvatar from '../../assets/icon/my_avatar.png'
import './index.scss'
@ -16,6 +16,7 @@ const EditProfile = observer(() => { @@ -16,6 +16,7 @@ const EditProfile = observer(() => {
const [nickname, setNickname] = useState(info?.name || info?.nickname || '')
const [phone, setPhone] = useState(info?.phone || '')
const [wchart, setWchart] = useState(info?.wchart || '')
const [avatarUrl, setAvatarUrl] = useState(info?.avatar || myAvatar)
// 是否选了新的本地头像(需要上传)
const [newAvatarPath, setNewAvatarPath] = useState<string>('')
@ -87,21 +88,32 @@ const EditProfile = observer(() => { @@ -87,21 +88,32 @@ const EditProfile = observer(() => {
setLoading(true)
try {
const result = await updateCustomer(userId, {
// 如果选了新头像,先转成 Base64
let avatarImage: string | undefined
if (newAvatarPath) {
try {
avatarImage = await readImageAsBase64(newAvatarPath)
} catch (e) {
console.warn('头像转 Base64 失败,跳过头像更新:', e)
}
}
await updateCustomer(userId, {
id: userId,
name: nickname.trim(),
phone: phone.trim() || undefined,
avatarFilePath: newAvatarPath || undefined,
wchart: wchart.trim() || undefined,
avatarImage,
})
// 用服务端返回的数据更新 store(头像使用服务端 URL,非本地临时路径)
const serverAvatar = result.data?.wxAvatarUrl || newAvatarPath || userStore.userInfo?.avatar
// 同步更新本地 store
userStore.setUserInfo({
...userStore.userInfo!,
name: nickname.trim(),
nickname: nickname.trim(),
phone: phone.trim() || userStore.userInfo?.phone,
avatar: serverAvatar,
wchart: wchart.trim() || userStore.userInfo?.wchart,
avatar: newAvatarPath || userStore.userInfo?.avatar,
})
Taro.showToast({ title: '保存成功', icon: 'success', duration: 2000 })
@ -165,17 +177,18 @@ const EditProfile = observer(() => { @@ -165,17 +177,18 @@ const EditProfile = observer(() => {
/>
</View>
{/* 微信号(接口已移除该字段,暂不展示)
{/* 微信号 */}
<View className="form-item">
<Text className="form-label">微信号</Text>
<Input
className="form-input"
value={wchart}
onInput={(e) => setWchart(e.detail.value)}
placeholder="请输入微信号"
placeholderClass="form-placeholder"
maxlength={30}
/>
</View>
*/}
</View>
{/* 保存按钮 */}

150
src/services/tableApi.ts

@ -0,0 +1,150 @@ @@ -0,0 +1,150 @@
/**
* 球台相关 API
* 包含开台、关台、续费等接口
*/
import { post } from './request'
/**
* 开台操作类型
*/
export enum TableOperationType {
/** 押金开台 */
SelfServiceDeposit = 1,
/** 余额开台 */
SelfServiceBalance = 2,
/** 定时开台 */
Timed = 3,
/** 信用开台 */
Credit = 4,
/** 团购券开台 */
GroupBuy = 5,
/** 立即关台 */
ImmediateClose = 6,
/** 续费 */
Renew = 7,
/** 关灯重开 */
ReopenAfterClose = 8,
}
/**
* 支付渠道
*/
export enum PaymentChannel {
/** 微信支付 */
WechatPay = 1,
/** 支付宝 */
Alipay = 2,
/** 银联支付 */
UnionPay = 3,
/** 余额支付 */
Balance = 4,
/** 银行卡支付 */
BankCard = 5,
}
/**
* 开台请求参数
*/
export interface StartTableRequest {
/** 设备编码 */
grpSn: string
/** 用户 OpenId */
openId: string
/** 开台操作类型 */
ctrlType: TableOperationType
/** 消费金额 */
amount?: number
/** 支付渠道 */
payType?: PaymentChannel
/** 开台时长(小时),定时开台使用 */
durationHours?: number
/** 团购券码,团购券开台使用 */
couponCode?: string
}
/**
* 开台响应数据
*/
export interface StartTableResponse {
/** 机构编码 */
instSn: string
/** 店铺编码 */
bpSn: string
/** 球台编码 */
grpSn: string
/** 开台设备id */
operationId: number
/** 开始时间 */
startTime: string
/** 结束时间 */
expectedEndTime: string
/** 开台操作详情id */
detailIds: number[]
/** 控制id */
controlMessageId: number
/** 商户订单号 */
outTradeNo: string
/** 支付参数(用于调起微信支付) */
payParameters: WechatPayParameters | null
/** 支付URL(用于扫码支付) */
payUrl: string
}
/**
* 微信支付参数
*/
export interface WechatPayParameters {
/** 时间戳 */
timeStamp: string
/** 随机字符串 */
nonceStr: string
/** 统一下单接口返回的 prepay_id */
package: string
/** 签名方式 */
signType: string
/** 签名 */
paySign: string
}
/**
* 开台 API
* @param data 开台请求参数
* @returns 开台响应
*/
export const startTable = (data: StartTableRequest) => {
return post<StartTableResponse>('/Tables/start', data, {
showLoading: true,
loadingText: '开台中...',
})
}
/**
* 调起微信支付
* @param payParams 支付参数
* @returns Promise
*/
export const invokeWechatPay = (payParams: WechatPayParameters): Promise<void> => {
return new Promise((resolve, reject) => {
// 使用 wx.requestPayment 调起微信支付
wx.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType as 'MD5' | 'HMAC-SHA256' | 'RSA',
paySign: payParams.paySign,
success: () => {
resolve()
},
fail: (err) => {
reject(err)
},
})
})
}
export default {
startTable,
invokeWechatPay,
TableOperationType,
PaymentChannel,
}

42
src/store/table.ts

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
*/
import { makeAutoObservable } from 'mobx'
import Taro from '@tarojs/taro'
import { TableScanResponseDto } from '../types/startDevice'
import { TableInfo, OrderInfo, TableScanResponseDto } from '../types/startDevice'
class TableStore {
/** 当前设备编码 */
@ -12,6 +12,12 @@ class TableStore { @@ -12,6 +12,12 @@ class TableStore {
/** 当前操作记录ID */
currentOperationId: number = 0
/** 台球桌信息(旧字段,保留兼容) */
tableInfo: TableInfo | null = null
/** 订单信息(旧字段,保留兼容) */
orderInfo: OrderInfo | null = null
/** 扫码后获取的完整开台信息(包含球台、价格、开台选项、当前订单) */
scanInfo: TableScanResponseDto | null = null
@ -30,10 +36,15 @@ class TableStore { @@ -30,10 +36,15 @@ class TableStore {
try {
const grpSn = Taro.getStorageSync('currentGrpSn')
const operationId = Taro.getStorageSync('currentOperationId')
const tableInfo = Taro.getStorageSync('tableInfo')
const orderInfo = Taro.getStorageSync('orderInfo')
const scanInfo = Taro.getStorageSync('scanInfo')
if (grpSn) this.currentGrpSn = grpSn
if (operationId) this.currentOperationId = operationId
if (tableInfo) this.tableInfo = tableInfo
if (orderInfo) this.orderInfo = orderInfo
if (scanInfo) this.scanInfo = scanInfo
} catch (error) {
console.error('读取台球桌信息失败:', error)
@ -56,6 +67,30 @@ class TableStore { @@ -56,6 +67,30 @@ class TableStore {
Taro.setStorageSync('currentOperationId', operationId)
}
/**
* 设置台球桌信息
*/
setTableInfo = (info: TableInfo | null) => {
this.tableInfo = info
if (info) {
Taro.setStorageSync('tableInfo', info)
} else {
Taro.removeStorageSync('tableInfo')
}
}
/**
* 设置订单信息
*/
setOrderInfo = (info: OrderInfo | null) => {
this.orderInfo = info
if (info) {
Taro.setStorageSync('orderInfo', info)
} else {
Taro.removeStorageSync('orderInfo')
}
}
/**
* 设置扫码开台信息(扫码后由 qrHandler 调用)
*/
@ -86,9 +121,14 @@ class TableStore { @@ -86,9 +121,14 @@ class TableStore {
clearAll = () => {
this.currentGrpSn = ''
this.currentOperationId = 0
this.tableInfo = null
this.orderInfo = null
this.scanInfo = null
Taro.removeStorageSync('currentGrpSn')
Taro.removeStorageSync('currentOperationId')
Taro.removeStorageSync('tableInfo')
Taro.removeStorageSync('orderInfo')
Taro.removeStorageSync('scanInfo')
}

51
src/types/login.ts

@ -2,11 +2,12 @@ @@ -2,11 +2,12 @@
* 微信登录相关类型定义
*/
// 微信登录请求参数 - 根据接口文档更新
export interface WechatLoginRequest {
code: string
code: string // 微信登录凭证code,通过 wx.login() 获取的临时登录凭证
}
/** 客户信息(兼容新旧接口;新接口可能缺会员/门店/余额等) */
// 客户信息
export interface CustomerDto {
id: number
sn: string
@ -25,19 +26,16 @@ export interface CustomerDto { @@ -25,19 +26,16 @@ export interface CustomerDto {
lastLoginTime: string
wxNickName: string
wxAvatarUrl: string
birthday?: string
}
/** 登录 data:可能是扁平客户字段,也可能嵌套 customer */
// 微信登录结果数据
export interface WechatAuthResultDto {
/** 兼容旧结构 */
customer?: CustomerDto
customer: CustomerDto
isNewCustomer: boolean
message?: string
/** 扁平字段时 login 直接带在 data 上,由 normalize 填入 customer */
[key: string]: unknown
message: string
}
// 微信登录响应
export interface WechatAuthResponse {
message: string
code: number
@ -45,36 +43,17 @@ export interface WechatAuthResponse { @@ -45,36 +43,17 @@ export interface WechatAuthResponse {
data: WechatAuthResultDto
}
/**
* 客户跨店余额列表单项(GET /customer/balancelist/{openId})
* 来源:后端 2026-07-29 更新的《小程序接口与功能(4).docx》§3.3.3
*/
export interface CustomerBalanceListItem {
/** 店编码 */
bpSn: string
/** 店名称 */
bpName: string
/** 余额 */
amount: number
/** 积分 */
points: number
/** 存杆柜数量 */
lockerCount: number
/** VIP 到期时间,无有效会员时为 null */
vipEndTime: string | null
}
/** 更新客户信息:新接口走 multipart,avatar 传本地文件路径 */
// 更新客户信息请求参数
export interface UpdateCustomerDto {
id: number
name?: string
phone?: string
birthday?: string
/** 新接口:本地临时文件路径,走 uploadFile */
avatarFilePath?: string
/** 旧接口:Base64(保留兼容,优先用 avatarFilePath) */
/** 头像(Base64 编码字符串) */
avatarImage?: string
/** 名称 */
name?: string
/** 客户类型 */
type?: number
/** 新接口文档暂无此字段,传了也可能被忽略 */
/** 微信号 */
wchart?: string
/** 手机号 */
phone?: string
}

91
src/types/magdev.ts

@ -1,91 +0,0 @@ @@ -1,91 +0,0 @@
/**
* magdev(开台/关台)接口相关类型定义
* 对应接口:/api/v2/magdev/peroperate、/api/v2/magdev/operate、/api/v2/magdev/getopstatus
*/
import { PriceInfoDto, CtrlType, PayType, WechatPayParameters } from './startDevice'
/** 去开台(预开台)请求参数 */
export interface PreOperateRequestDto {
type: number
grpSn: string
openId: string
}
/** 支付方式DTO(peroperate 返回) */
export interface PaymentMethodDto {
type: number
name: string
available: boolean
balanceDisplay: string | null
}
/** 设备简要信息(peroperate 返回) */
export interface DevInfoDto {
id: number
sn: string
name: string
number: string
wstatus: number
status: number
shared: number
}
/** 去开台响应数据(对应 data 字段) */
export interface PreOperateResponseData {
/** 余额充足时直接开台返回订单号,为空表示需进子页支付 */
outTradeNo: string
message: string
isMember: boolean
balance: number
bpName: string
bpSn: string
instSn: string
devType: number
devInfo: DevInfoDto
price: PriceInfoDto
paymentMethods: PaymentMethodDto[]
}
/** operate 统一操作请求参数 */
export interface OperateRequestDto {
grpSn: string
openId: string
ctrlType: CtrlType
amount: number
payType: PayType
tradeType?: number
couponCode?: string
remark?: string
attach?: string
}
/** operate 响应中的支付信息 */
export interface OperatePaymentResponseDto {
outTradeNo: string
channel: number
payParameters: WechatPayParameters | null
payUrl: string
}
/** operate 响应数据(对应 data 字段) */
export interface OperateResponseData {
consumptionId: number
errorMessage: string
paymentResponseDto: OperatePaymentResponseDto | null
}
/** getopstatus 响应中的操作选项 */
export interface OpenOptionItem {
title: string
type: number
desc: string
available: boolean
}
/** getopstatus 响应数据(对应 data 字段) */
export interface GetOpStatusResponseData {
isOpened: boolean
openOptions: OpenOptionItem[]
}
export { CtrlType, PayType }

147
src/types/order.ts

@ -61,150 +61,3 @@ export interface CloseOrderDto { @@ -61,150 +61,3 @@ export interface CloseOrderDto {
/** 商户订单号 */
outTradeNo: string
}
/* ======================================================================
* 以下为新接口:/api/v2/order/records(订单记录列表)、/api/v2/order/detail/{id}(订单详情)
* 来源:后端 2026-07-29 更新的《小程序接口与功能(4).docx》「订单管理」章节
* 与上面旧版 /Order/query、/Order/close 无关,属于独立的新业务域
* ====================================================================== */
/** 项目品类(categorytype),对应 GroupDev.Type / 字典 DictKindType.ItemCategory */
export enum OrderCategoryType {
/** 球台(台球) */
Table = 0,
/** 存杆柜 */
Locker = 1,
/** 棋牌 */
ChessRoom = 2,
}
/** order/records 查询参数 status(进行中/已完成筛选,与返回字段的 statusText 是两回事) */
export enum OrderQueryStatus {
/** 进行中 */
Ongoing = 0,
/** 已完成 */
Completed = 1,
}
/**
* 支付状态枚举(0-10),文档明确:适用于 recharge/records 和 order/records 中的 status 字段
* 与 utils/rechargeRecords.ts 的 RechargePaymentStatus 定义一致,此处仅类型层面重复声明供 order 相关代码引用
*/
export enum OrderPaymentStatus {
Invalid = 0,
Pending = 1,
Initialized = 2,
Paying = 3,
Success = 4,
Failed = 5,
Cancelled = 6,
Refunding = 7,
Refunded = 8,
RefundFailed = 9,
Closed = 10,
}
/** 查询订单记录(消费记录列表)请求参数 */
export interface OrderRecordQueryParams {
/** 微信 OpenId(必填) */
openId: string
/** 店编码 */
bpSn?: string
/** 开始时间 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss */
startDate?: string
/** 结束时间 */
endDate?: string
/** 支付方式,可多选(1微信/2支付宝/3银联/4余额/5银行卡) */
payType?: number[]
/** 设施编码(如台球桌编号),精确匹配 */
grpSn?: string
/** 项目品类:0球台,1存杆柜,2棋牌 */
categoryType?: OrderCategoryType
/** 状态筛选:0进行中,1已完成 */
status?: OrderQueryStatus
/** 当前页码,默认 1 */
pageIndex?: number
/** 每页记录数,默认 10 */
pageSize?: number
}
/**
* 订单记录列表项(新结构,后端已直接给出展示文案,不再需要前端拼枚举)
*/
export interface OrderRecordItem {
/** 订单记录 ID,用于跳转详情 order/detail/{id} */
id: number
/** 标题(含球台名称),如"普通球台123132 1号台" */
title: string
/** 订单状态文案,如"已完成"/"进行中" */
statusText: string
/** 消费门店名称 */
bpName: string
/** 开台时间 */
startTime: string
/** 时长文案,如"1小时0分钟" */
durationText: string
/** 实付金额 */
actualAmount: number
}
/** 订单记录分页结果 */
export interface OrderRecordPagedResult {
data: OrderRecordItem[]
totalCount: number
pageIndex: number
pageSize: number
totalPages: number
hasPrevious: boolean
hasNext: boolean
}
/**
* 订单详情(GET /api/v2/order/detail/{id})
*/
export interface OrderDetailDto {
/** 订单记录 id */
id: number
/** 标题(含球台名称) */
projectName: string
/** 订单状态文案 */
statusText: string
/** 店长电话 */
storePhone: string
/** 开台时长文案 */
durationText: string
/** 实付金额 */
actualAmount: number | null
/** 价格(元/小时) */
pricePerHour: number
/** 折扣价(元/小时) */
discountPricePerHour: number
/** 在线总支付 */
onlineTotalPayment: number
/** 押金 */
deposit: number
/** 订单原价 */
originalAmount: number
/** 会员价,null 表示"-" */
memberPrice: number | null
/** 优惠券抵扣金额 */
couponDeduction: number | null
/** 优惠券描述 */
couponDescription: string | null
/** 门店名称 */
bpName: string
/** 门店地址 */
bpAddress: string
/** 桌台信息(如"1号台") */
tableName: string
/** 开台时间 */
startTime: string
/** 结束时间 */
stopTime: string
/** 订单编号 */
outTradeNo: string
/** 收费标准描述 */
chargingStandard: string
/** 支付方式名称 */
payTypeName: string
}

85
src/types/recharge.ts

@ -2,11 +2,26 @@ @@ -2,11 +2,26 @@
* 充值相关类型定义
*/
/**
* 创建充值订单请求参数
*/
export interface CreateRechargePaymentDto {
/** 用户ID */
userId?: string
/** 客户编码(充值对象) */
customerSn?: string
/** 充值金额 */
amount?: number
/** 店铺编码(必填) */
/** 赠送金额 */
giftAmount?: number
/** 充值类型 */
rcgType?: number
/** 交易类型:1-JSAPI/小程序 */
tradeType?: number
/** 店铺编码 */
bpSn?: string
/** 机构编码 */
instSn?: string
/** OpenId(微信支付必传) */
openId?: string
/** 支付标题 */
@ -15,13 +30,23 @@ export interface CreateRechargePaymentDto { @@ -15,13 +30,23 @@ export interface CreateRechargePaymentDto {
body?: string
}
/**
* 创建充值订单响应(data 字段内容)
*/
export interface RechargePaymentResponseDto {
/** 商户订单号 */
outTradeNo: string
/** 支付渠道:1-微信、2-支付宝等 */
channel: number
/** 调起微信支付所需参数 */
payParameters: RechargeWechatPayParameters | null
/** 支付链接(Native扫码支付用) */
payUrl: string
}
/**
* 微信支付参数(充值场景)
*/
export interface RechargeWechatPayParameters {
timeStamp: string
nonceStr: string
@ -30,57 +55,7 @@ export interface RechargeWechatPayParameters { @@ -30,57 +55,7 @@ export interface RechargeWechatPayParameters {
paySign: string
}
export interface RechargeStatusResult {
amount: number
giftAmount: number
balance: number
}
export interface RechargeRuleItem {
recharge: number
gift: number
desc: string
}
export interface RechargeRuleQueryResult {
instName: string
bpName: string
rules: RechargeRuleItem[]
}
export interface RechargeRecordQueryParams {
customerSn: string
startDate?: string
endDate?: string
payType?: number
bpSn?: string
pageIndex?: number
pageSize?: number
}
export interface RechargeRecordItem {
id: number
customerSn: string
customerName: string
amount: number
giftAmount: number
payType: number
rcgType: number
rcgTypeName: string
bpSn: string
bpName: string
instSn: string
instName: string
logTime: string
updateTime: string
/** 状态(文档未给枚举,前端做兜底文案) */
status: number
outTradeNo: string
}
export interface RechargeRecordPagedResult {
data: RechargeRecordItem[]
totalCount: number
pageIndex: number
pageSize: number
}
/**
* 查询支付状态的支付渠道枚举
*/
export type PaymentChannel = 'WechatPay' | 'Alipay' | 'UnionPay' | 'Balance' | 'BankCard'

216
src/types/startDevice.ts

@ -1,12 +1,6 @@ @@ -1,12 +1,6 @@
/**
* 开台/关台/扫码相关类型定义
* 对应新版接口(磁力开台):
* - GET /api/v2/magdev/qrcode 扫码获取台桌信息
* - POST /api/v2/magdev/peroperate 去开台(预检)
* - POST /api/v2/magdev/operate 开台/关台/续费/关灯重开/分享等统一操作
* - GET /api/v2/magdev/getopstatus 查询开台结果
* 旧版 /api/v2/TablesDev/* 系列接口已废弃,相关类型与方法已移除,
* 具体操作请求参数见 src/types/magdev.ts 的 OperateRequestDto。
* 开台操作相关类型定义
* 对应接口:/api/v2/TablesDev/start_device
*/
/**
@ -14,8 +8,6 @@ @@ -14,8 +8,6 @@
* 注意:余额开台通过 ctrlType=DepositStart + payType=Balance 组合实现
*/
export enum CtrlType {
/** 分享球台 / 取消分享(toggle) */
Share = 0,
/** 押金开台(余额开台时配合 payType=Balance 使用) */
DepositStart = 1,
/** 定时开台 */
@ -32,12 +24,6 @@ export enum CtrlType { @@ -32,12 +24,6 @@ export enum CtrlType {
TurnOffRestart = 7,
/** 替人付费关台 */
PayForOthersClose = 8,
/** AA制付款关台(棋牌室场景,台球暂未接入) */
AaPayClose = 9,
/** 打开存杆柜(存杆柜场景,暂未接入) */
OpenLocker = 10,
/** 打开棋牌室(开台过程中有人中途加入,暂未接入) */
OpenChessRoom = 11,
}
/**
@ -56,6 +42,32 @@ export enum PayType { @@ -56,6 +42,32 @@ export enum PayType {
BankCard = 5,
}
/**
* 开台请求参数
*/
export interface StartGroupDevRequestDto {
/** 设备编码(必填) */
grpSn: string
/** OpenId(必填) */
openId: string
/** 操作类型:押金开台(1)、定时开台(2)、信用开台(3)、团购券开台(4)、立即关台(5)、续费(6)、关灯重开(7)、替人付费关台(8) */
ctrlType?: CtrlType
/** 消费金额 */
amount?: number
/** 支付方式:微信支付(1)、支付宝(2)、银联支付(3)、余额支付(4)、银行卡支付(5) */
payType?: PayType
/** 交易类型:1-JSAPI/小程序、2-Native扫码、3-APP、4-付款码、5-H5、6-刷脸、7-小程序支付 */
tradeType?: number
/** 开台时长(小时),定时开台使用 */
durationHours?: number
/** 团购券码,团购券开台使用 */
couponCode?: string
/** 备注 */
remark?: string
/** 附加数据(JSON格式) */
attach?: string
}
/**
* 微信支付参数
*/
@ -72,7 +84,91 @@ export interface WechatPayParameters { @@ -72,7 +84,91 @@ export interface WechatPayParameters {
paySign: string
}
// ─── 获取开台信息接口相关类型(GET /api/v2/magdev/qrcode)──────────────────
/**
* 支付响应数据(嵌套在 StartDeviceResponse 中)
*/
export interface PaymentResponseDto {
/** 商户订单号 */
outTradeNo: string
/** 支付渠道:1-微信、2-支付宝、3-银联等 */
channel: number
/** 调起支付所需参数(微信支付时使用) */
payParameters: WechatPayParameters | null
/** 支付链接(扫码支付时使用) */
payUrl: string
}
/**
* 开台响应数据(对应 data 字段内容)
*/
export interface StartDeviceResponse {
/** 消费记录ID */
consumptionId: number
/** 错误描述(业务失败时有值) */
errorMessage: string
/** 支付信息(需要支付时有值,余额支付等无需支付时为 null) */
paymentResponseDto: PaymentResponseDto | null
}
/**
* 台球桌信息
*/
export interface TableInfo {
/** 设备编码 */
grpSn: string
/** 台球桌名称 */
tableName: string
/** 台球桌类型 */
tableType: string
/** 门店名称 */
storeName: string
/** 门店电话 */
storePhone: string
/** 价格(元/小时) */
price: number
/** VIP价格(元/小时) */
vipPrice: number
/** 押金金额 */
deposit: number
/** 价格时段 */
pricePeriod: string
}
/**
* 订单信息
*/
export interface OrderInfo {
/** 操作记录ID */
operationId: number
/** 设备编码 */
grpSn: string
/** 开始时间 */
startTime: string
/** 已消费时长(分钟) */
consumedMinutes: number
/** 已消费金额 */
consumedAmount: number
/** 剩余时长(分钟) */
remainingMinutes?: number
/** 订单编号 */
orderNo: string
/** 开台方式 */
startMethod: string
/** 收费标准 */
pricingRule: string
}
/**
* 获取台球桌价格请求参数
*/
export interface PriceRequestDto {
/** 用户微信 OpenId */
openId: string
/** 设备序列号 */
groupDevSn: string
}
// ─── 获取开台信息接口相关类型 ───────────────────────────────────────────────
/**
* 分时价格
@ -178,13 +274,9 @@ export interface OpInfoDto { @@ -178,13 +274,9 @@ export interface OpInfoDto {
export interface TableScanResponseDto {
/** open-可开台;close-已开台需关台 */
scanType: 'open' | 'close'
/** 是否本人开台,默认 true;false 表示扫的是别人正在使用的台(关台页需展示"帮他付款/AA制付款") */
isOwner: boolean
shopName: string
shopCode: string
instSn: string
/** 店长/门店联系电话(来自 qrcode.phone) */
phone: string
tableInfo: TableInfoDto
price: PriceInfoDto
businessHours: string
@ -194,3 +286,85 @@ export interface TableScanResponseDto { @@ -194,3 +286,85 @@ export interface TableScanResponseDto {
/** 已开台时有值,可开台时为 null */
opInfo: OpInfoDto | null
}
// ─── 关台接口(/api/v2/TablesDev/stop)相关类型 ───────────────────────────────
/**
* 关台操作请求参数(对应 /api/v2/TablesDev/stop 接口)
* ctrlType 使用 CtrlType 枚举:5-立即关台、6-续费、7-关灯重开、8-替人付费关台
*/
export interface StopGroupDevRequestDto {
/** 设备序列号(必填) */
grpSn: string
/** 用户 OpenId(必填) */
openId: string
/** 操作类型:5-立即关台、6-续费、7-关灯重开、8-替人付费关台 */
ctrlType?: CtrlType
/** 支付方式:1-微信支付、2-支付宝、3-银联支付、4-余额支付、5-银行卡支付(可选) */
payType?: PayType
/** 交易类型:1-JSAPI小程序、2-Native扫码、3-APP、4-付款码、5-H5(可选) */
tradeType?: number
/** 续费金额(单位:元),仅 ctrlType=6(续费) 时需要 */
renewalAmount?: number
}
/**
* 关台操作响应数据(对应 /api/v2/TablesDev/stop 接口 data 字段)
*/
export interface StopGroupDevResponseDto {
/** 设备序列号 */
grpSn: string
/** 操作 ID(用于支付轮询) */
operationId: number
/** 关台时间 */
stopTime: string
/** 最终消费金额 */
finalAmount: number
/** 退款金额 */
refundAmount: number
/** 操作类型(对应 CtrlType:5-立即关台、6-续费、7-关灯重开) */
stopType: CtrlType
/** 续费时返回新的结束时间 */
newStopTime: string
/** 微信支付参数(needPay=true 时有值,否则为 null) */
payParameters: WechatPayParameters | null
/** 是否需要前端调起支付 */
needPay: boolean
/** 商户订单号,前端轮询用 */
outTradeNo: string
}
// ─── 查询开台状态接口(/api/v2/TablesDev/check-open-status)相关类型 ──────────
/**
* 开台状态枚举
* Pending → Processing → Opened / Failed
*/
export type OpenStatus = 'Pending' | 'Processing' | 'Opened' | 'Failed'
/**
* 查询开台状态响应(对应 data 字段)
* 接口:GET /api/v2/TablesDev/check-open-status?outTradeNo=xxx
*/
export interface CheckOpenStatusResponse {
/** 商户订单号 */
outTradeNo: string
/** 是否已成功开台 */
isOpened: boolean
/** 开台状态:Pending-等待中、Processing-处理中、Opened-已开台、Failed-失败 */
openStatus: OpenStatus
/** 设备编码 */
grpSn: string
/** 设备工作状态:0-空闲,1-使用中,2-已预约,3-维护中,4-开台中 */
workStatus: number
/** 工作状态描述 */
workStatusDesc: string
/** 当前开台记录 ID(开台成功后有值,用于关台页) */
operationId: number
/** 开台时间 */
startTime: string
/** 预计结束时间(定时开台时有值) */
expectedEndTime: string
/** 开台人用户编码 */
userSn: string
}

24
src/utils/apiNormalize.ts

@ -1,24 +0,0 @@ @@ -1,24 +0,0 @@
/**
* 新接口字段大小写兼容:Word/Swagger 多为全小写,现网前端用 camelCase
*/
export const pickField = <T = unknown>(
source: Record<string, any> | null | undefined,
...keys: string[]
): T | undefined => {
if (!source) return undefined
for (const key of keys) {
if (source[key] !== undefined && source[key] !== null) {
return source[key] as T
}
}
return undefined
}
export const toNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && !Number.isNaN(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const n = Number(value)
return Number.isNaN(n) ? fallback : n
}
return fallback
}

15
src/utils/callShopPhone.ts

@ -1,15 +0,0 @@ @@ -1,15 +0,0 @@
/**
* 拨打门店/店长电话(来自扫码 qrcode.phone)
*/
import Taro from '@tarojs/taro'
export const callShopPhone = (phone?: string | null): void => {
const phoneNumber = (phone || '').trim()
if (!phoneNumber) {
Taro.showToast({ title: '暂无店长电话', icon: 'none', duration: 2000 })
return
}
Taro.makePhoneCall({ phoneNumber }).catch((err) => {
console.error('拨打电话失败:', err)
})
}

58
src/utils/customerNormalize.ts

@ -1,58 +0,0 @@ @@ -1,58 +0,0 @@
/**
* 客户字段归一化(新接口全小写 / 旧 camelCase)
*/
import { CustomerDto, CustomerBalanceListItem } from '../types/login'
import { pickField, toNumber } from '../utils/apiNormalize'
export const normalizeCustomer = (raw: Record<string, any> | null | undefined): CustomerDto => {
const src = raw || {}
return {
id: toNumber(pickField(src, 'id')),
sn: String(pickField(src, 'sn') ?? ''),
wxOpenId: String(pickField(src, 'wxOpenId', 'wxopenid') ?? ''),
name: String(pickField(src, 'name') ?? ''),
type: toNumber(pickField(src, 'type')),
typeName: String(pickField(src, 'typeName', 'typename') ?? ''),
amount: toNumber(pickField(src, 'amount')),
wchart: String(pickField(src, 'wchart') ?? ''),
phone: String(pickField(src, 'phone') ?? ''),
bpSn: String(pickField(src, 'bpSn', 'bpsn') ?? ''),
bpInfoName: String(pickField(src, 'bpInfoName', 'bpinfoname', 'bpname') ?? ''),
instSn: String(pickField(src, 'instSn', 'instsn') ?? ''),
ownerName: String(pickField(src, 'ownerName', 'ownername') ?? ''),
createTime: String(pickField(src, 'createTime', 'createtime') ?? ''),
lastLoginTime: String(pickField(src, 'lastLoginTime', 'lastlogintime') ?? ''),
wxNickName: String(pickField(src, 'wxNickName', 'wxnickname', 'name') ?? ''),
wxAvatarUrl: String(pickField(src, 'wxAvatarUrl', 'wxavatarurl') ?? ''),
birthday: pickField<string>(src, 'birthday'),
}
}
/** 归一化单条门店余额(balancelist 数组项) */
export const normalizeBalanceListItem = (raw: Record<string, any>): CustomerBalanceListItem => ({
bpSn: String(pickField(raw, 'bpSn', 'bpsn') ?? ''),
bpName: String(pickField(raw, 'bpName', 'bpname') ?? ''),
amount: toNumber(pickField(raw, 'amount')),
points: toNumber(pickField(raw, 'points')),
lockerCount: toNumber(pickField(raw, 'lockerCount', 'lockercount')),
vipEndTime: pickField<string | null>(raw, 'vipEndTime', 'vipendtime') ?? null,
})
/** 归一化客户跨店余额列表 */
export const normalizeBalanceList = (
raw: Record<string, any>[] | null | undefined
): CustomerBalanceListItem[] => {
if (!Array.isArray(raw)) return []
return raw.map(normalizeBalanceListItem)
}
/** 登录 data:扁平(Word)或嵌套 customer(旧) */
export const normalizeLoginData = (data: Record<string, any> | null | undefined) => {
const src = data || {}
const nested = pickField<Record<string, any>>(src, 'customer')
const customer = normalizeCustomer(nested || src)
const isNewCustomer = Boolean(
pickField(src, 'isNewCustomer', 'isnewcustomer') ?? false
)
return { customer, isNewCustomer, message: String(pickField(src, 'message') ?? '') }
}

135
src/utils/operateNormalize.ts

@ -1,135 +0,0 @@ @@ -1,135 +0,0 @@
/**
* magdev operate / peroperate / getopstatus 响应字段归一化
*/
import { pickField, toNumber } from './apiNormalize'
import { normalizeWechatPayParams } from './rechargeNormalize'
import { PriceInfoDto, TimePriceDto } from '../types/startDevice'
import {
OperateResponseData,
OperatePaymentResponseDto,
PreOperateResponseData,
PaymentMethodDto,
DevInfoDto,
GetOpStatusResponseData,
OpenOptionItem,
} from '../types/magdev'
const normalizeTimePrice = (raw: Record<string, any>): TimePriceDto => ({
startTime: String(pickField(raw, 'startTime', 'starttime') ?? ''),
endTime: String(pickField(raw, 'endTime', 'endtime') ?? ''),
price: toNumber(pickField(raw, 'price')),
description: String(pickField(raw, 'description') ?? ''),
})
const normalizePrice = (raw: Record<string, any> | null | undefined): PriceInfoDto => {
const src = raw || {}
const timePrices = pickField<Record<string, any>[]>(src, 'timePrices', 'timeprices') || []
return {
standard: toNumber(pickField(src, 'standard')),
memberPrice: toNumber(pickField(src, 'memberPrice', 'memberprice')),
unit: String(pickField(src, 'unit') ?? ''),
deposit: toNumber(pickField(src, 'deposit')),
pricePerMinute: toNumber(pickField(src, 'pricePerMinute', 'pricePerminute', 'priceperminute')),
memberPricePerMinute: toNumber(pickField(src, 'memberPricePerMinute', 'memberpriceperminute')),
currentPrice: toNumber(pickField(src, 'currentPrice', 'currentprice')),
descript: String(pickField(src, 'descript') ?? ''),
pricePeriod: String(pickField(src, 'pricePeriod', 'priceperiod') ?? ''),
timePrices: Array.isArray(timePrices) ? timePrices.map(normalizeTimePrice) : [],
}
}
const normalizeDevInfo = (raw: Record<string, any> | null | undefined): DevInfoDto => {
const src = raw || {}
return {
id: toNumber(pickField(src, 'id')),
sn: String(pickField(src, 'sn') ?? ''),
name: String(pickField(src, 'name') ?? ''),
number: String(pickField(src, 'number') ?? ''),
wstatus: toNumber(pickField(src, 'wstatus')),
status: toNumber(pickField(src, 'status')),
shared: toNumber(pickField(src, 'shared')),
}
}
const normalizePaymentMethods = (raw: any[]): PaymentMethodDto[] => {
if (!Array.isArray(raw)) return []
return raw.map(item => ({
type: toNumber(pickField(item, 'type')),
name: String(pickField(item, 'name') ?? ''),
available: !!pickField(item, 'available'),
balanceDisplay: pickField<string>(item, 'balanceDisplay', 'balancedisplay') ?? null,
}))
}
const normalizeOperatePaymentResponse = (
raw: Record<string, any> | null | undefined
): OperatePaymentResponseDto | null => {
if (!raw) return null
const payParams = normalizeWechatPayParams(
pickField<Record<string, any>>(raw, 'payParameters', 'payparameters')
)
if (!payParams && !pickField(raw, 'outTradeNo', 'outtradeno')) return null
return {
outTradeNo: String(pickField(raw, 'outTradeNo', 'outtradeno') ?? ''),
channel: toNumber(pickField(raw, 'channel')),
payParameters: payParams,
payUrl: String(pickField(raw, 'payUrl', 'payurl') ?? ''),
}
}
/** 归一化 operate(开台/关台/续费)响应 data */
export const normalizeOperateResponse = (
raw: Record<string, any> | null | undefined
): OperateResponseData => {
const src = raw || {}
return {
consumptionId: toNumber(pickField(src, 'consumptionId', 'consumptionid')),
errorMessage: String(pickField(src, 'errorMessage', 'errormessage') ?? ''),
paymentResponseDto: normalizeOperatePaymentResponse(
pickField<Record<string, any>>(src, 'paymentResponseDto', 'paymentresponsedto')
),
}
}
/** 归一化 peroperate(去开台)响应 data */
export const normalizePreOperateResponse = (
raw: Record<string, any> | null | undefined
): PreOperateResponseData => {
const src = raw || {}
return {
message: String(pickField(src, 'message') ?? ''),
outTradeNo: String(pickField(src, 'outTradeNo', 'outtradeno') ?? ''),
isMember: !!pickField(src, 'isMember', 'ismember'),
balance: toNumber(pickField(src, 'balance')),
bpName: String(pickField(src, 'bpName', 'bpname') ?? ''),
bpSn: String(pickField(src, 'bpSn', 'bpsn') ?? ''),
instSn: String(pickField(src, 'instSn', 'instsn') ?? ''),
devType: toNumber(pickField(src, 'devType', 'devtype')),
devInfo: normalizeDevInfo(
pickField<Record<string, any>>(src, 'devInfo', 'devinfo')
),
price: normalizePrice(pickField<Record<string, any>>(src, 'price')),
paymentMethods: normalizePaymentMethods(
pickField<any[]>(src, 'paymentMethods', 'paymentmethods') ?? []
),
}
}
/** 归一化 getopstatus 响应 data */
export const normalizeGetOpStatusResponse = (
raw: Record<string, any> | null | undefined
): GetOpStatusResponseData => {
const src = raw || {}
const opts = pickField<any[]>(src, 'openOptions', 'openoptions') ?? []
return {
isOpened: !!pickField(src, 'isOpened', 'isopened'),
openOptions: Array.isArray(opts)
? opts.map((item): OpenOptionItem => ({
title: String(pickField(item, 'title') ?? ''),
type: toNumber(pickField(item, 'type')),
desc: String(pickField(item, 'desc', 'description') ?? ''),
available: !!pickField(item, 'available'),
}))
: [],
}
}

70
src/utils/orderNormalize.ts

@ -1,70 +0,0 @@ @@ -1,70 +0,0 @@
/**
* 订单记录 / 订单详情 字段归一化
* 对应接口:/api/v2/order/records、/api/v2/order/detail/{id}
*/
import { pickField, toNumber } from './apiNormalize'
import { OrderRecordItem, OrderRecordPagedResult, OrderDetailDto } from '../types/order'
/** 归一化单条订单记录(列表项) */
export const normalizeOrderRecordItem = (raw: Record<string, any>): OrderRecordItem => ({
id: toNumber(pickField(raw, 'id')),
title: String(pickField(raw, 'title') ?? ''),
statusText: String(pickField(raw, 'statusText', 'statustext') ?? ''),
bpName: String(pickField(raw, 'bpName', 'bpname') ?? ''),
startTime: String(pickField(raw, 'startTime', 'starttime') ?? ''),
durationText: String(pickField(raw, 'durationText', 'durationtext') ?? ''),
actualAmount: toNumber(pickField(raw, 'actualAmount', 'actualamount')),
})
/** 归一化订单记录分页响应 */
export const normalizeOrderRecordPagedResult = (
raw: Record<string, any> | null | undefined
): OrderRecordPagedResult => {
const src = raw || {}
const list = pickField<Record<string, any>[]>(src, 'data') || []
return {
data: Array.isArray(list) ? list.map(normalizeOrderRecordItem) : [],
totalCount: toNumber(pickField(src, 'totalCount', 'totalcount')),
pageIndex: toNumber(pickField(src, 'pageIndex', 'pageindex'), 1),
pageSize: toNumber(pickField(src, 'pageSize', 'pagesize'), 10),
totalPages: toNumber(pickField(src, 'totalPages', 'totalpages')),
hasPrevious: Boolean(pickField(src, 'hasPrevious', 'hasprevious') ?? false),
hasNext: Boolean(pickField(src, 'hasNext', 'hasnext') ?? false),
}
}
/** 归一化订单详情 */
export const normalizeOrderDetail = (
raw: Record<string, any> | null | undefined
): OrderDetailDto => {
const src = raw || {}
const memberPrice = pickField<number | null>(src, 'memberPrice', 'memberprice')
const couponDeduction = pickField<number | null>(src, 'couponDeduction', 'coupondeduction')
const couponDescription = pickField<string | null>(src, 'couponDescription', 'coupondescription')
const actualAmount = pickField<number | null>(src, 'actualAmount', 'actualamount')
return {
id: toNumber(pickField(src, 'id')),
projectName: String(pickField(src, 'projectName', 'projectname') ?? ''),
statusText: String(pickField(src, 'statusText', 'statustext') ?? ''),
storePhone: String(pickField(src, 'storePhone', 'storephone') ?? ''),
durationText: String(pickField(src, 'durationText', 'durationtext') ?? ''),
actualAmount: actualAmount === null || actualAmount === undefined ? null : toNumber(actualAmount),
pricePerHour: toNumber(pickField(src, 'pricePerHour', 'priceperhour')),
discountPricePerHour: toNumber(pickField(src, 'discountPricePerHour', 'discountpriceperhour')),
onlineTotalPayment: toNumber(pickField(src, 'onlineTotalPayment', 'onlinetotalpayment')),
deposit: toNumber(pickField(src, 'deposit')),
originalAmount: toNumber(pickField(src, 'originalAmount', 'originalamount')),
memberPrice: memberPrice === null || memberPrice === undefined ? null : toNumber(memberPrice),
couponDeduction:
couponDeduction === null || couponDeduction === undefined ? null : toNumber(couponDeduction),
couponDescription: couponDescription === null || couponDescription === undefined ? null : String(couponDescription),
bpName: String(pickField(src, 'bpName', 'bpname') ?? ''),
bpAddress: String(pickField(src, 'bpAddress', 'bpaddress') ?? ''),
tableName: String(pickField(src, 'tableName', 'tablename') ?? ''),
startTime: String(pickField(src, 'startTime', 'starttime') ?? ''),
stopTime: String(pickField(src, 'stopTime', 'stoptime') ?? ''),
outTradeNo: String(pickField(src, 'outTradeNo', 'outtradeno') ?? ''),
chargingStandard: String(pickField(src, 'chargingStandard', 'chargingstandard') ?? ''),
payTypeName: String(pickField(src, 'payTypeName', 'paytypename') ?? ''),
}
}

69
src/utils/rechargeNormalize.ts

@ -1,69 +0,0 @@ @@ -1,69 +0,0 @@
/**
* 充值相关字段归一化
*/
import {
RechargePaymentResponseDto,
RechargeWechatPayParameters,
RechargeStatusResult,
RechargeRuleItem,
RechargeRuleQueryResult,
} from '../types/recharge'
import { pickField, toNumber } from './apiNormalize'
export const normalizeWechatPayParams = (
raw: Record<string, any> | null | undefined
): RechargeWechatPayParameters | null => {
if (!raw) return null
const timeStamp = pickField<string>(raw, 'timeStamp', 'timestamp')
if (!timeStamp) return null
return {
timeStamp: String(timeStamp),
nonceStr: String(pickField(raw, 'nonceStr', 'noncestr') ?? ''),
package: String(pickField(raw, 'package') ?? ''),
signType: String(pickField(raw, 'signType', 'signtype') ?? 'RSA'),
paySign: String(pickField(raw, 'paySign', 'paysign') ?? ''),
}
}
export const normalizeRechargePaymentResponse = (
raw: Record<string, any> | null | undefined
): RechargePaymentResponseDto => {
const src = raw || {}
return {
outTradeNo: String(pickField(src, 'outTradeNo', 'outtradeno') ?? ''),
channel: toNumber(pickField(src, 'channel')),
payParameters: normalizeWechatPayParams(
pickField<Record<string, any>>(src, 'payParameters', 'payparameters')
),
payUrl: String(pickField(src, 'payUrl', 'payurl') ?? ''),
}
}
export const normalizeRechargeStatus = (
raw: Record<string, any> | null | undefined
): RechargeStatusResult => {
const src = raw || {}
return {
amount: toNumber(pickField(src, 'amount')),
giftAmount: toNumber(pickField(src, 'giftAmount', 'giftamount')),
balance: toNumber(pickField(src, 'balance')),
}
}
export const normalizeRechargeRuleItem = (raw: Record<string, any>): RechargeRuleItem => ({
recharge: toNumber(pickField(raw, 'recharge')),
gift: toNumber(pickField(raw, 'gift')),
desc: String(pickField(raw, 'desc', 'description') ?? ''),
})
export const normalizeRechargeRules = (
raw: Record<string, any> | null | undefined
): RechargeRuleQueryResult => {
const src = raw || {}
const rules = pickField<Record<string, any>[]>(src, 'rules') || []
return {
instName: String(pickField(src, 'instName', 'instname') ?? ''),
bpName: String(pickField(src, 'bpName', 'bpname') ?? ''),
rules: Array.isArray(rules) ? rules.map(normalizeRechargeRuleItem) : [],
}
}

111
src/utils/rechargeRecords.ts

@ -1,111 +0,0 @@ @@ -1,111 +0,0 @@
/**
* 充值记录:月份区间、状态文案、列表展示映射
*/
import { getRechargeRecords } from '../api/recharge'
import { RechargeRecordItem } from '../types/recharge'
import { userStore } from '../store'
/** 列表项(页面展示结构) */
export interface RechargeRecordViewItem {
id: string
storeName: string
rechargeTime: string
rechargeType: string
amount: number
status: string
}
/** 当前年月 YYYY-MM */
export const getCurrentYearMonth = (): string => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
/** 某月起止时间(供 records 接口过滤) */
export const getMonthDateRange = (yearMonth: string): { startDate: string; endDate: string } => {
const [yStr, mStr] = yearMonth.split('-')
const year = Number(yStr)
const month = Number(mStr)
const lastDay = new Date(year, month, 0).getDate()
const day = String(lastDay).padStart(2, '0')
return {
startDate: `${yearMonth}-01 00:00:00`,
endDate: `${yearMonth}-${day} 23:59:59`,
}
}
/**
* 支付状态(后端枚举,适用于 recharge/records.status 等)
* 来源:后端「支付状态定义」
*/
export enum RechargePaymentStatus {
Invalid = 0,
Pending = 1,
Initialized = 2,
Paying = 3,
Success = 4,
Failed = 5,
Cancelled = 6,
Refunding = 7,
Refunded = 8,
RefundFailed = 9,
Closed = 10,
}
export const RECHARGE_PAYMENT_STATUS_LABEL: Record<number, string> = {
[RechargePaymentStatus.Invalid]: '无效',
[RechargePaymentStatus.Pending]: '待支付',
[RechargePaymentStatus.Initialized]: '已初始化',
[RechargePaymentStatus.Paying]: '支付中',
[RechargePaymentStatus.Success]: '支付成功',
[RechargePaymentStatus.Failed]: '支付失败',
[RechargePaymentStatus.Cancelled]: '已取消',
[RechargePaymentStatus.Refunding]: '退款中',
[RechargePaymentStatus.Refunded]: '已退款',
[RechargePaymentStatus.RefundFailed]: '退款失败',
[RechargePaymentStatus.Closed]: '已关闭',
}
/**
* 充值状态文案(后端支付状态定义)
*/
export const formatRechargeStatus = (status: number): string => {
if (RECHARGE_PAYMENT_STATUS_LABEL[status] !== undefined) {
return RECHARGE_PAYMENT_STATUS_LABEL[status]
}
return '未知状态'
}
export const mapRechargeRecordToView = (item: RechargeRecordItem): RechargeRecordViewItem => ({
id: String(item.id || item.outTradeNo || item.logTime),
storeName: item.bpName || '--',
rechargeTime: item.logTime || '--',
rechargeType: item.rcgTypeName || '账户充值',
amount: item.amount,
status: formatRechargeStatus(item.status),
})
/**
* 拉取指定月份充值记录(需已登录且有客户 sn)
*/
export const fetchRechargeRecordsByMonth = async (
yearMonth: string
): Promise<RechargeRecordViewItem[]> => {
const customerSn = userStore.userInfo?.sn
if (!customerSn) {
throw new Error('NO_CUSTOMER_SN')
}
const { startDate, endDate } = getMonthDateRange(yearMonth)
const res = await getRechargeRecords({
customerSn,
startDate,
endDate,
pageIndex: 1,
pageSize: 100,
})
return (res.data?.data || []).map(mapRechargeRecordToView)
}

119
src/utils/scanNormalize.ts

@ -1,119 +0,0 @@ @@ -1,119 +0,0 @@
/**
* 扫码(qrcode)响应字段归一化
*/
import {
TableScanResponseDto,
TableInfoDto,
PriceInfoDto,
TimePriceDto,
OptionDto,
OpInfoDto,
} from '../types/startDevice'
import { pickField, toNumber } from './apiNormalize'
const normalizeTimePrice = (raw: Record<string, any>): TimePriceDto => ({
startTime: String(pickField(raw, 'startTime', 'starttime') ?? ''),
endTime: String(pickField(raw, 'endTime', 'endtime') ?? ''),
price: toNumber(pickField(raw, 'price')),
description: String(pickField(raw, 'description') ?? ''),
})
const normalizePrice = (raw: Record<string, any> | null | undefined): PriceInfoDto => {
const src = raw || {}
const timePrices = pickField<Record<string, any>[]>(src, 'timePrices', 'timeprices') || []
return {
standard: toNumber(pickField(src, 'standard')),
memberPrice: toNumber(pickField(src, 'memberPrice', 'memberprice')),
unit: String(pickField(src, 'unit') ?? ''),
deposit: toNumber(pickField(src, 'deposit')),
pricePerMinute: toNumber(pickField(src, 'pricePerMinute', 'pricePerminute', 'priceperminute')),
memberPricePerMinute: toNumber(
pickField(src, 'memberPricePerMinute', 'memberpriceperminute')
),
currentPrice: toNumber(pickField(src, 'currentPrice', 'currentprice')),
descript: String(pickField(src, 'descript') ?? ''),
pricePeriod: String(pickField(src, 'pricePeriod', 'priceperiod') ?? ''),
timePrices: Array.isArray(timePrices) ? timePrices.map(normalizeTimePrice) : [],
}
}
const normalizeTableInfo = (raw: Record<string, any> | null | undefined): TableInfoDto => {
const src = raw || {}
return {
id: toNumber(pickField(src, 'id')),
sn: String(pickField(src, 'sn', 'grpsn') ?? ''),
name: String(pickField(src, 'name', 'tablename') ?? ''),
tableNumber: String(pickField(src, 'tableNumber', 'tablenumber', 'number') ?? ''),
status: toNumber(pickField(src, 'status', 'wstatus')),
statusDescription: String(
pickField(src, 'statusDescription', 'statusdescription', 'statusdesc') ?? ''
),
isShared: Boolean(pickField(src, 'isShared', 'isshared', 'share')),
currentSession: String(pickField(src, 'currentSession', 'currentsession') ?? ''),
area: String(pickField(src, 'area', 'class') ?? ''),
chanNum: toNumber(pickField(src, 'chanNum', 'channum')),
startChan: toNumber(pickField(src, 'startChan', 'startchan')),
}
}
const normalizeOpenOption = (raw: Record<string, any>): OptionDto => ({
title: String(pickField(raw, 'title') ?? ''),
type: toNumber(pickField(raw, 'type')),
description: String(pickField(raw, 'description', 'desc') ?? ''),
actionText: String(pickField(raw, 'actionText', 'actiontext') ?? ''),
refundPolicy: String(pickField(raw, 'refundPolicy', 'refundpolicy') ?? ''),
available: pickField(raw, 'available') !== false,
optionCode: toNumber(pickField(raw, 'optionCode', 'optioncode', 'type')),
icon: String(pickField(raw, 'icon') ?? ''),
condition: String(pickField(raw, 'condition') ?? ''),
actionUrl: String(pickField(raw, 'actionUrl', 'actionurl') ?? ''),
})
const normalizeOpInfo = (raw: Record<string, any> | null | undefined): OpInfoDto | null => {
if (!raw) return null
return {
opId: toNumber(pickField(raw, 'opId', 'opid')),
orderNo: String(pickField(raw, 'orderNo', 'orderno') ?? ''),
startTime: String(pickField(raw, 'startTime', 'starttime') ?? ''),
usedTime: String(pickField(raw, 'usedTime', 'usedtime') ?? ''),
usedMinutes: toNumber(pickField(raw, 'usedMinutes', 'usedminutes')),
ctrlType: toNumber(pickField(raw, 'ctrlType', 'ctrltype')),
ctrlTypeDescription: String(
pickField(raw, 'ctrlTypeDescription', 'ctrltypedescription') ?? ''
),
payType: toNumber(pickField(raw, 'payType', 'paytype')),
payTypeDescription: String(
pickField(raw, 'payTypeDescription', 'paytypedescription') ?? ''
),
currentAmount: toNumber(pickField(raw, 'currentAmount', 'currentamount')),
deposit: toNumber(pickField(raw, 'deposit')),
depositStatus: String(pickField(raw, 'depositStatus', 'depositstatus') ?? ''),
discountAmount: toNumber(pickField(raw, 'discountAmount', 'discountamount')),
payableAmount: toNumber(pickField(raw, 'payableAmount', 'payableamount')),
}
}
export const normalizeTableScanResponse = (
raw: Record<string, any> | null | undefined
): TableScanResponseDto => {
const src = raw || {}
const openOptions = pickField<Record<string, any>[]>(src, 'openOptions', 'openoptions') || []
const devInfo = pickField<Record<string, any>>(src, 'tableInfo', 'devinfo', 'tableinfo')
return {
scanType: (pickField<string>(src, 'scanType', 'scantype') || 'open') as 'open' | 'close',
// 后端默认 true(本人开台),只有明确返回 false 时才判定为"别人开的台"
isOwner: pickField(src, 'isOwner', 'isowner') !== false,
shopName: String(pickField(src, 'shopName', 'bpname', 'shopname') ?? ''),
shopCode: String(pickField(src, 'shopCode', 'bpsn', 'shopcode') ?? ''),
instSn: String(pickField(src, 'instSn', 'instsn') ?? ''),
phone: String(pickField(src, 'phone') ?? ''),
tableInfo: normalizeTableInfo(devInfo),
price: normalizePrice(pickField(src, 'price')),
businessHours: String(pickField(src, 'businessHours', 'businesshours') ?? ''),
openOptions: Array.isArray(openOptions) ? openOptions.map(normalizeOpenOption) : [],
recommendedOption: String(pickField(src, 'recommendedOption', 'recommendedoption') ?? ''),
scanTime: String(pickField(src, 'scanTime', 'scantime') ?? ''),
opInfo: normalizeOpInfo(pickField(src, 'opInfo', 'opinfo')),
}
}

41
src/utils/storeNormalize.ts

@ -1,41 +0,0 @@ @@ -1,41 +0,0 @@
/**
* 门店列表字段归一化(新接口 BpInfoSimpleDto → 现网 StoreInfoDto)
*/
import { StoreInfoDto } from '../api/basicInfo'
import { pickField, toNumber } from './apiNormalize'
const hashSn = (sn: string): number => {
let h = 0
for (let i = 0; i < sn.length; i++) {
h = (Math.imul(31, h) + sn.charCodeAt(i)) | 0
}
return Math.abs(h)
}
export const normalizeStoreItem = (raw: Record<string, any>): StoreInfoDto => {
const inst = pickField<Record<string, any>>(raw, 'inst') || {}
const sn = String(pickField(raw, 'sn') ?? '')
return {
id: hashSn(sn || String(pickField(raw, 'id') ?? '')),
sn,
name: String(pickField(raw, 'name') ?? ''),
type: toNumber(pickField(raw, 'type')),
typeName: String(pickField(raw, 'typeName', 'typename') ?? ''),
isUnion: toNumber(pickField(raw, 'isUnion', 'isunion')),
address: String(pickField(raw, 'address', 'add') ?? ''),
manager: String(pickField(raw, 'manager') ?? ''),
phone: String(pickField(raw, 'phone') ?? ''),
inst_Sn: String(pickField(inst, 'sn', 'instsn') ?? pickField(raw, 'instSn', 'instsn') ?? ''),
instName: String(pickField(inst, 'name', 'instname') ?? pickField(raw, 'instName', 'instname') ?? ''),
rgn_Sn: String(pickField(raw, 'rgn_Sn', 'rgnsn') ?? ''),
regionName: String(pickField(raw, 'regionName', 'regionname', 'city') ?? pickField(raw, 'city') ?? ''),
longItude: toNumber(pickField(raw, 'longItude', 'longitude')),
latItude: toNumber(pickField(raw, 'latItude', 'latitude')),
createTime: String(pickField(raw, 'createTime', 'createtime') ?? ''),
}
}
export const normalizeStoreList = (list: Record<string, any>[] | null | undefined): StoreInfoDto[] => {
if (!Array.isArray(list)) return []
return list.map(normalizeStoreItem)
}
Loading…
Cancel
Save