refactor: migrate mini program to Taro React

This commit is contained in:
zhangjianjun 2026-07-14 11:18:50 +08:00
parent c484e6997a
commit 1a31c85e27
39 changed files with 13098 additions and 783 deletions

26
.eslintrc.cjs Normal file
View File

@ -0,0 +1,26 @@
module.exports = {
root: true,
env: {
es2021: true,
node: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
plugins: ['@typescript-eslint', 'react-hooks'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist/', 'node_modules/'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
}

8
.gitignore vendored
View File

@ -1 +1,7 @@
unpackage/ node_modules/
dist/
.temp/
.cache/
.swc/
unpackage/
*.log

20
App.vue
View File

@ -1,20 +0,0 @@
<script>
export default {
globalData: {
token: '',
},
onLaunch: function() {
console.log('App Launch')
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
}
}
</script>
<style>
/*每个页面公共css */
</style>

23
README.md Normal file
View File

@ -0,0 +1,23 @@
# batiao-pay
基于 Taro 4、React 18 和 TypeScript 的微信/支付宝支付小程序。
## 开发与构建
```bash
pnpm install
pnpm dev:weapp
pnpm dev:alipay
pnpm build:weapp
pnpm build:alipay
```
微信构建产物位于 `dist/weapp`,支付宝构建产物位于 `dist/alipay`
## 质量检查
```bash
pnpm typecheck
pnpm lint
pnpm test
```

12
babel.config.cjs Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
presets: [
[
'taro',
{
framework: 'react',
ts: true,
compiler: 'webpack5',
},
],
],
}

59
config/index.ts Normal file
View File

@ -0,0 +1,59 @@
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
import path from 'node:path'
const outputRoot = `dist/${process.env.TARO_ENV ?? 'weapp'}`
const config: UserConfigExport<'webpack5'> = {
projectName: 'batiao-pay',
date: '2026-07-14',
designWidth: 750,
deviceRatio: {
640: 2.34 / 2,
750: 1,
828: 1.81 / 2,
},
sourceRoot: 'src',
outputRoot,
framework: 'react',
compiler: 'webpack5',
plugins: ['@tarojs/plugin-framework-react'],
alias: {
'@': path.resolve(__dirname, '..', 'src'),
},
defineConstants: {},
copy: {
patterns: [
{
from: 'static/image',
to: `${outputRoot}/static/image`,
},
],
options: {},
},
cache: {
enable: false,
},
mini: {
postcss: {
pxtransform: {
enable: true,
config: {},
},
url: {
enable: true,
config: {
limit: 1024,
},
},
cssModules: {
enable: false,
config: {
namingPattern: 'module',
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
},
},
}
export default defineConfig<'webpack5'>(() => config)

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>

View File

@ -1,3 +0,0 @@
// const BASE_URL = 'https://u.batiao8.com' //测试
const BASE_URL = 'https://pay.batiao8.com' //正式
export default BASE_URL

View File

@ -1,47 +0,0 @@
import BASE_URL from './baseUrl.js'
const request = async ({
url,
method,
data
}) => {
try {
const app = getApp();
const globalData = app.globalData || {}
let header = {};
if (globalData.token) {
header['x-token'] = globalData.token;
}
let res = await uni.request({
url: BASE_URL + url,
method: method,
data: data,
// header: header
})
console.log('--res', res)
if (res.data) {
if(res.data.code && res.data.code !== 200) {
uni.showToast({
title: res.data.message.split(',')[0],
icon: 'none',
})
}
return res.data
} else {
uni.showToast({
title: res.data.message,
icon: 'none',
})
return {}
}
} catch (err) {
console.log('--err', err)
uni.showToast({
title: err.errMsg,
icon: 'none',
})
return {}
}
}
export default request

22
main.js
View File

@ -1,22 +0,0 @@
import App from './App'
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
// #endif

View File

@ -1,73 +0,0 @@
{
"name" : "batiao-pay",
"appid" : "",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wxb0e5bc0e275ba55c",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true,
"appid" : "2021005194639068"
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
}

63
package.json Normal file
View File

@ -0,0 +1,63 @@
{
"name": "batiao-pay",
"version": "1.0.0",
"private": true,
"description": "Batiao payment mini program",
"packageManager": "pnpm@10.28.2",
"engines": {
"node": ">=18"
},
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"@swc/core",
"@tarojs/binding",
"@tarojs/cli",
"core-js",
"core-js-pure",
"esbuild"
]
},
"scripts": {
"dev:weapp": "taro build --type weapp --watch",
"dev:alipay": "taro build --type alipay --watch",
"build:weapp": "taro build --type weapp",
"build:alipay": "taro build --type alipay",
"typecheck": "tsc --noEmit",
"lint": "eslint src config vitest.config.ts --ext .ts,.tsx",
"test": "vitest run"
},
"dependencies": {
"@babel/runtime": "^7.28.4",
"@tarojs/components": "4.2.0",
"@tarojs/helper": "4.2.0",
"@tarojs/plugin-framework-react": "4.2.0",
"@tarojs/plugin-platform-alipay": "4.2.0",
"@tarojs/plugin-platform-weapp": "4.2.0",
"@tarojs/react": "4.2.0",
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-react": "^7.28.5",
"@tarojs/cli": "4.2.0",
"@tarojs/taro-loader": "4.2.0",
"@tarojs/webpack5-runner": "4.2.0",
"@types/node": "^24.0.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "8.64.0",
"@typescript-eslint/parser": "8.64.0",
"babel-preset-taro": "4.2.0",
"eslint": "8.57.1",
"eslint-plugin-react-hooks": "^5.2.0",
"less": "4.6.4",
"typescript": "5.9.3",
"vite": "4.5.14",
"vitest": "1.6.1"
}
}

View File

@ -1,17 +0,0 @@
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": ""
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"uniIdRouter": {}
}

View File

@ -1,414 +0,0 @@
<template>
<view class="wrapper" style="height: 100vh">
<!-- 浏览器不支持提示 -->
<view class="wrapper" id="pay-result" style="background: #fff" v-if="envErrType">
<view class="pay-result-container">
<image src="/static/image/payerr.png" style="width: 96px; height: 96px"></image>
<view class="env-err-text">{{ envErrText }}</view>
</view>
</view>
<view class="wrapper" id="pay-result" style="background: #fff" v-else-if="orderInfo.status === 2">
<view class="pay-result-container">
<image v-if="browerEnv === 'alipay'" src="/static/image/ali-payok.png" style="width: 96px; height: 96px"></image>
<image v-if="browerEnv === 'weixin'" src="/static/image/wx-payok.png" style="width: 96px; height: 96px"></image>
<view class="pay-result-text">支付已完成</view>
</view>
</view>
<!-- 支付页面 -->
<view class="wrapper" id="pay-page" v-else-if="currentPage === 'pay-page'">
<view class="item-name">{{ orderInfo.goodsName }}</view>
<view class="item-count">
<text style="font-size: 26px">¥&nbsp;</text>{{ numberFormat(orderInfo.totalFee) }}
</view>
<!-- <view class="company">重庆八条科技有限公司</view> -->
<!-- 支付按钮 -->
<view class="pay-methods">
<view v-for="item in payMethodList" :key="item.id" class="pay-method-item">
<view class="pay-method-item-left">
<image :src="item.logo" style="width: 30px; height: 30px"></image>
<text class="pay-method-name">{{ item.name }}</text>
</view>
<view class="pay-method-item-right">
<image :src="item.checkIcon" style="width: 18px; height: 18px"></image>
</view>
</view>
</view>
<button id="pay-btn" :class="[`${browerEnv}-btn`]" @click="goPay">
立即支付
</button>
</view>
<!-- 支付结果页面 -->
<view class="wrapper" id="pay-result" style="background: #fff" v-else-if="currentPage === 'pay-result'">
<view class="pay-result-container">
<image :src="payResultItem.icon" style="width: 96px; height: 96px"></image>
<view class="pay-result-text">{{ payResultItem.resultText }}</view>
<button v-if="fromApp" class="back-btn" open-type="launchApp" app-parameter="wechat" :binderror="launchAppError">返回APP</button>
<button v-else class="back-btn" :style="payResultItem.style" @click="goBackPay">
{{ payResultItem.backText }}
</button>
</view>
</view>
</view>
</template>
<script>
import request from "@/lib/request.js";
export default {
data() {
return {
authCode: '',
encOrderId: "",
outTradeNo: "",
browerEnv: "",
orderInfo: {},
currentPage: "pay-page",
paySuccess: true,
payMethodList: [{
name: "支付宝支付",
id: "alipay",
logo: "/static/image/ali-logo.png",
checkIcon: "/static/image/ali-check.png",
},
{
name: "微信支付",
id: "weixin",
logo: "/static/image/wx-logo.png",
checkIcon: "/static/image/wx-check.png",
},
],
timer: null,
fromApp: false,
};
},
computed: {
payResultList() {
return [{
icon: "/static/image/payerr.png",
resultText: "抱歉,订单支付失败",
backText: "重新支付",
style: {background: this.browerEnv === "weixin" ? '#05bf5e' : '#2c78fe', color: '#fff'},
},
{
icon: this.browerEnv === "weixin" ?
"/static/image/wx-payok.png" : "/static/image/ali-payok.png",
resultText: "支付成功",
backText: "返回",
},
];
},
payResultItem() {
return this.paySuccess ? this.payResultList[1] : this.payResultList[0];
},
envErrType() {
if(!this.browerEnv) return '';
if (this.browerEnv === "browser") {
return "envErr:browser";
} else if (!this.encOrderId && !this.outTradeNo) {
return "envErr:noParams";
} else {
return ''
}
},
envErrText() {
const group = {
"envErr:browser": "不支持浏览器,请使用支付宝或微信重新扫码",
"envErr:noParams": "请通过扫描二维码使用小程序",
"envErr:outTradeNo": "获取订单信息失败",
};
return group[this.envErrType];
},
},
methods: {
launchAppError(e) {
console.log(e.detail.errMsg)
},
numberFormat(value) {
if (value) return value.toFixed(2);
return "0.00";
},
//
getAliUserInfo() {
//
// #ifdef MP-ALIPAY
my.getAuthCode({
scopes: ["auth_base"],
success: (res) => {
console.log("获取授权码成功", res);
this.authCode = res.authCode;
},
fail: (error) => {
console.error("获取授权码失败", error);
uni.showToast({
icon: "none",
title: "获取授权码失败",
});
},
});
// #endif
},
//
getWxUserInfo() {
// #ifdef MP-WEIXIN
uni.getProvider({
service: 'oauth',
success: (res) => {
console.log('getProvider', res.provider)
uni.login({
provider: res.provider,
success: (loginRes) => {
this.authCode = loginRes.code
console.log('loginRes', this.authCode)
}
})
}
})
// #endif
},
//
async goPay() {
uni.showLoading()
try {
//
const {
outTradeNo,
paySource = "jsapi"
} = this.orderInfo;
let extra = {};
// #ifdef MP-ALIPAY
extra.alipayAuthCode = this.authCode;
// #endif
// #ifdef MP-WEIXIN
extra.weixinAuthCode = this.authCode;
// #endif
console.log('authCode',this.authCode)
if (!this.authCode) {
console.error("没有授权码");
return;
}
let orderData = await this.handleOrder({
outTradeNo,
payType: this.browerEnv,
paySource: paySource,
extra,
});
let tradeNo = orderData.tradeNo;
console.log("tradeNo", tradeNo);
if(this.browerEnv === 'alipay') {
//
let res = await this.handleAlipay({
tradeNO: tradeNo,
});
console.log('支付宝支付成功', res)
} else if(this.browerEnv === 'weixin') {
let res = await this.handleWechatPay(orderData);
console.log('微信支付成功', res)
}
//
let orderRes = await this.getOrderInfo()
this.currentPage = "pay-result";
if (orderRes.status === 2) {
this.paySuccess = true;
// clearInterval(this.timer)
// this.timer = null
} else {
this.paySuccess = false;
}
uni.hideLoading()
} catch (error) {
console.log('error', error)
this.currentPage = "pay-result";
this.paySuccess = false; //
uni.hideLoading()
}
},
//
async getOrderInfo() {
let res = await request({
url: "/api/pay/order",
method: "GET",
data: {
encOrderId: this.encOrderId,
outTradeNo: this.outTradeNo
},
});
const data = {
...res.data,
totalFee: res.data.totalFee / 100,
};
return data;
},
//
async handleOrder(data) {
let res = await request({
url: "/api/pay/order",
method: "POST",
data: data,
});
console.log("handleOrder", res.data);
return res.data || {};
},
//
handleWechatPay(payParams) {
return new Promise((resolve, reject) => {
// Uniapp使uni.requestPayment API
uni.requestPayment({
provider: "wxpay",
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType,
paySign: payParams.paySign,
success: (res) => {
console.log("支付成功:", res);
resolve();
},
fail: (err) => {
console.error("支付失败:", err);
reject(new Error("支付失败"));
},
});
});
},
//
handleAlipay(payParams) {
console.log("支付宝支付处理");
return new Promise((resolve, reject) => {
// Uniapp使uni.requestPayment API
uni.requestPayment({
provider: "alipay",
orderInfo: payParams.tradeNO, //
success: (res) => {
if (res.resultCode === "9000") {
console.log("支付成功", res);
//
resolve();
} else {
console.log("支付处理中或失败", res);
//
resolve();
}
},
fail: (err) => {
console.error("支付失败:", err);
reject(new Error("支付失败"));
},
});
});
},
async goBackPay() {
this.orderInfo = await this.getOrderInfo();
this.currentPage = "pay-page";
},
// URL
getQuery() {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const options = currentPage.options || {};
return options;
},
// //
getBrowserEnv() {
// H5
// #ifdef MP-WEIXIN
return "weixin";
// #endif
// #ifdef MP-ALIPAY
return "alipay";
// #endif
},
extractUrlParams(url) {
if (!url) {
return {};
}
// 使URL
const params = {};
const queryString = url.split("?")[1];
if (queryString) {
const pairs = queryString.split("&");
for (let i = 0; i < pairs.length; i++) {
const pair = pairs[i].split("=");
const key = decodeURIComponent(pair[0]);
const value = decodeURIComponent(pair[1] || "");
params[key] = value;
}
}
return params;
},
},
async onShow() {
console.log("on Show");
//
// #ifdef MP-ALIPAY
if (my) {
let res = my.getLaunchOptionsSync();
console.log("res", res);
const qrCode = res.query ? res.query.qrCode : '';
let {
encOrderId
} = this.extractUrlParams(qrCode);
if (encOrderId) {
this.encOrderId = encOrderId;
this.orderInfo = await this.getOrderInfo();
}
this.getAliUserInfo()
}
// #endif
},
async onLoad(options) {
console.log("options", options);
const env = this.getBrowserEnv();
console.log('env', env)
this.browerEnv = env;
if (env === "browser") return;
//
this.payMethodList = this.payMethodList.filter((item) => item.id === env);
// #ifdef MP-WEIXIN
if(options.q) { // q:
let {
encOrderId
} = this.extractUrlParams(decodeURIComponent(options.q));
console.log('encOrderId', encOrderId)
if (encOrderId) {
this.encOrderId = encOrderId;
this.orderInfo = await this.getOrderInfo();
}
}
// options.outTradeNo = "10050_20260608161529904_10018"
options.outTradeNo = "10044_20260608164234963_1884228"
if(options.outTradeNo) {
// app
this.fromApp = true;
this.outTradeNo = options.outTradeNo;
this.orderInfo = await this.getOrderInfo();
}
console.log('encOrderId', this.encOrderId)
console.log('outTradeNo', this.outTradeNo)
this.getWxUserInfo()
// #endif
},
};
</script>
<style>
@import url("/static/app.css");
</style>

12000
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

8
project.alipay.json Normal file
View File

@ -0,0 +1,8 @@
{
"appid": "2021005194639068",
"projectname": "batiao-pay",
"miniprogramRoot": "dist/alipay/",
"component2": true,
"enableAppxNg": true,
"axmlStrictCheck": false
}

14
project.config.json Normal file
View File

@ -0,0 +1,14 @@
{
"description": "Batiao payment mini program",
"miniprogramRoot": "dist/weapp/",
"projectname": "batiao-pay",
"appid": "wxb0e5bc0e275ba55c",
"compileType": "miniprogram",
"setting": {
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": true,
"minified": true
}
}

10
src/app.config.ts Normal file
View File

@ -0,0 +1,10 @@
export default defineAppConfig({
pages: ['pages/index/index'],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#F8F8F8',
navigationBarTitleText: '',
navigationBarTextStyle: 'black',
backgroundColor: '#F8F8F8',
},
})

5
src/app.css Normal file
View File

@ -0,0 +1,5 @@
page {
min-height: 100%;
background-color: #f2f2f2;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}

9
src/app.tsx Normal file
View File

@ -0,0 +1,9 @@
import type { PropsWithChildren } from 'react'
import './app.css'
function App({ children }: PropsWithChildren) {
return children
}
export default App

18
src/global.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
declare const my: {
getAuthCode(options: {
scopes: string[]
success: (result: { authCode?: string }) => void
fail: (error: { errorMessage?: string; errMsg?: string }) => void
}): void
getLaunchOptionsSync(): {
query?: {
qrCode?: string
}
}
}
declare namespace NodeJS {
interface ProcessEnv {
TARO_ENV?: 'weapp' | 'alipay'
}
}

View File

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

View File

@ -1,61 +1,46 @@
body, page { page {
font-family: -apple-system, BlinkMacSystemFont, sans-serif; height: 100%;
text-align: center;
background-color: #f2f2f2;
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100vh;
overflow: hidden; overflow: hidden;
background-color: #f2f2f2;
text-align: center;
} }
.wrapper { .wrapper {
width: 100%; width: 100%;
height: 100%; height: 100vh;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-flow: column; flex-flow: column;
align-items: center; align-items: center;
} }
.loading-placeholder,
.pay-result {
background: #fff;
}
.item-name { .item-name {
/* width: 160rpx; */
height: 56rpx; height: 56rpx;
margin-top: 140rpx; margin-top: 140rpx;
font-family: PingFang SC, PingFang SC; font-family: PingFang SC, sans-serif;
font-weight: 500; font-weight: 500;
font-size: 40rpx; font-size: 40rpx;
color: #1A1A1A; color: #1a1a1a;
line-height: 56rpx; line-height: 56rpx;
text-align: center; text-align: center;
font-style: normal;
text-transform: none;
} }
.item-count { .item-count {
/* width: 250rpx; */
height: 92rpx; height: 92rpx;
font-family: D-DIN-PRO, D-DIN-PRO; margin: 12rpx 0 16rpx;
font-family: D-DIN-PRO, sans-serif;
font-weight: 500; font-weight: 500;
font-size: 80rpx; font-size: 80rpx;
color: #FF2C2C; color: #ff2c2c;
line-height: 72rpx; line-height: 72rpx;
text-align: center; text-align: center;
font-style: normal;
text-transform: none;
margin: 12rpx 0 16rpx 0;
}
.company {
/* width: 280rpx; */
height: 40rpx;
font-family: PingFang SC, PingFang SC;
font-weight: 400;
font-size: 28rpx;
color: #53896D;
line-height: 40rpx;
text-align: center;
font-style: normal;
text-transform: none;
} }
.pay-methods { .pay-methods {
@ -66,21 +51,17 @@ body, page {
.pay-method-item { .pay-method-item {
width: 640rpx; width: 640rpx;
height: 120rpx; height: 120rpx;
background: #FFFFFF;
box-shadow: 0rpx 0rpx 20rpx 0rpx rgba(36, 36, 36, 0.05);
border-radius: 16rpx;
padding: 0 28rpx; padding: 0 28rpx;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20rpx; background: #fff;
box-shadow: 0 0 20rpx rgba(36, 36, 36, 0.05);
border-radius: 16rpx;
} }
.pay-method-item:last-child { .pay-method-item-left,
margin-bottom: 0; .pay-method-item-right {
}
.pay-method-item-left {
display: flex; display: flex;
align-items: center; align-items: center;
} }
@ -89,79 +70,78 @@ body, page {
margin-left: 16rpx; margin-left: 16rpx;
} }
#pay-btn { .pay-btn,
width: 686rpx; .back-btn {
height: 104rpx; padding: 0;
border-radius: 1842rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-family: PingFang SC, PingFang SC; font-family: PingFang SC, sans-serif;
font-weight: 500;
font-size: 32rpx; font-size: 32rpx;
color: #FFFFFF;
line-height: 48rpx; line-height: 48rpx;
text-align: center; text-align: center;
font-style: normal; }
text-transform: none;
.pay-btn::after,
.back-btn::after {
border: none;
}
.pay-btn {
width: 686rpx;
height: 104rpx;
border-radius: 1842rpx;
font-weight: 500;
color: #fff;
} }
.weixin-btn { .weixin-btn {
background: #06BF5E; background: #06bf5e;
} }
.alipay-btn { .alipay-btn {
background: #2c78fe; background: #2c78fe;
} }
.pay-btn-disabled {
opacity: 0.55;
}
.pay-result-container { .pay-result-container {
margin-top: 140rpx; margin-top: 140rpx;
display: flex; display: flex;
flex-flow: column; flex-flow: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.pay-result-text { .pay-result-text {
font-family: PingFang SC, PingFang SC; margin: 52rpx 0 140rpx;
font-family: PingFang SC, sans-serif;
font-weight: 400; font-weight: 400;
font-size: 36rpx; font-size: 36rpx;
color: #1A1A1A; color: #1a1a1a;
line-height: 52rpx; line-height: 52rpx;
text-align: center; text-align: center;
font-style: normal;
text-transform: none;
margin: 52rpx 0 140rpx 0;
} }
.back-btn { .back-btn {
width: 312rpx; width: 312rpx;
height: 104rpx; height: 104rpx;
background: #E7E7E7; background: #e7e7e7;
border-radius: 1842rpx; border-radius: 1842rpx;
display: flex;
align-items: center;
justify-content: center;
font-family: PingFang SC, PingFang SC;
font-weight: 400; font-weight: 400;
font-size: 32rpx; color: #666;
color: #666666;
line-height: 48rpx;
text-align: center;
font-style: normal;
text-transform: none;
} }
.env-err-text { .env-err-text {
margin-top: 82rpx;
width: 504rpx; width: 504rpx;
height: 112rpx; min-height: 112rpx;
font-family: AlibabaPuHuiTi, AlibabaPuHuiTi; margin-top: 82rpx;
font-family: AlibabaPuHuiTi, sans-serif;
font-weight: 500; font-weight: 500;
font-size: 36rpx; font-size: 36rpx;
color: #F64F50; color: #f64f50;
line-height: 56rpx; line-height: 56rpx;
text-align: center; text-align: center;
font-style: normal; }
text-transform: none;
}

271
src/pages/index/index.tsx Normal file
View File

@ -0,0 +1,271 @@
import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import { Button, Image, Text, View } from '@tarojs/components'
import { useRef, useState } from 'react'
import {
getAlipayLaunchQrCode,
getPlatformAuthCode,
PAY_ENVIRONMENT,
requestPlatformPayment,
} from '@/platform/payment'
import { createPaymentOrder, getOrderInfo } from '@/services/payment'
import type { OrderInfo, OrderLookup } from '@/types/payment'
import {
createSubmissionGuard,
formatAmount,
isPaid,
resolveAlipayLaunchParams,
resolveWeappLaunchParams,
type LaunchResolution,
} from '@/utils/payment'
import './index.css'
type PageView = 'loading' | 'pay' | 'result' | 'completed' | 'error'
const IMAGE_PATHS = {
alipayCheck: '/static/image/ali-check.png',
alipayLogo: '/static/image/ali-logo.png',
alipaySuccess: '/static/image/ali-payok.png',
error: '/static/image/payerr.png',
weixinCheck: '/static/image/wx-check.png',
weixinLogo: '/static/image/wx-logo.png',
weixinSuccess: '/static/image/wx-payok.png',
} as const
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) {
return error.message
}
return fallback
}
function IndexPage() {
const [authCode, setAuthCode] = useState('')
const [errorText, setErrorText] = useState('')
const [fromApp, setFromApp] = useState(false)
const [lookup, setLookup] = useState<OrderLookup>({})
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null)
const [paySuccess, setPaySuccess] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [view, setView] = useState<PageView>('loading')
const initializingRef = useRef(false)
const submissionGuardRef = useRef(createSubmissionGuard())
const payMethod =
PAY_ENVIRONMENT === 'weixin'
? {
name: '微信支付',
logo: IMAGE_PATHS.weixinLogo,
checkIcon: IMAGE_PATHS.weixinCheck,
}
: {
name: '支付宝支付',
logo: IMAGE_PATHS.alipayLogo,
checkIcon: IMAGE_PATHS.alipayCheck,
}
const successIcon =
PAY_ENVIRONMENT === 'weixin' ? IMAGE_PATHS.weixinSuccess : IMAGE_PATHS.alipaySuccess
async function initializePage(resolution: LaunchResolution | null): Promise<void> {
if (initializingRef.current || submissionGuardRef.current.isLocked()) {
return
}
if (!resolution) {
setErrorText('请通过扫描二维码使用小程序')
setView('error')
return
}
initializingRef.current = true
setLookup(resolution.lookup)
setFromApp(resolution.fromApp)
setView('loading')
Taro.showLoading({ title: '加载中', mask: true })
try {
const [nextOrderInfo, nextAuthCode] = await Promise.all([
getOrderInfo(resolution.lookup),
getPlatformAuthCode(),
])
setOrderInfo(nextOrderInfo)
setAuthCode(nextAuthCode)
setView(isPaid(nextOrderInfo.status) ? 'completed' : 'pay')
} catch (error) {
const message = getErrorMessage(error, '获取订单信息失败')
setErrorText(message)
setView('error')
Taro.showToast({ title: message, icon: 'none' })
} finally {
initializingRef.current = false
Taro.hideLoading()
}
}
useLoad((options) => {
if (process.env.TARO_ENV !== 'weapp') {
return
}
void initializePage(resolveWeappLaunchParams(options))
})
useDidShow(() => {
if (process.env.TARO_ENV !== 'alipay') {
return
}
const resolution = resolveAlipayLaunchParams(getAlipayLaunchQrCode())
void initializePage(resolution)
})
async function handlePay(): Promise<void> {
if (!submissionGuardRef.current.tryLock()) {
return
}
if (!orderInfo || !authCode) {
submissionGuardRef.current.unlock()
Taro.showToast({ title: '支付信息尚未准备完成', icon: 'none' })
return
}
setSubmitting(true)
Taro.showLoading({ title: '支付中', mask: true })
try {
const paymentOrder = await createPaymentOrder({
outTradeNo: orderInfo.outTradeNo,
payType: PAY_ENVIRONMENT,
paySource: orderInfo.paySource || 'jsapi',
extra:
PAY_ENVIRONMENT === 'weixin'
? { weixinAuthCode: authCode }
: { alipayAuthCode: authCode },
})
await requestPlatformPayment(paymentOrder)
const latestOrderInfo = await getOrderInfo(lookup)
const paid = isPaid(latestOrderInfo.status)
setPaySuccess(paid)
setView('result')
} catch (error) {
const message = getErrorMessage(error, '支付失败')
setPaySuccess(false)
setView('result')
Taro.showToast({ title: message, icon: 'none' })
} finally {
setSubmitting(false)
submissionGuardRef.current.unlock()
Taro.hideLoading()
}
}
async function handleBackToPay(): Promise<void> {
Taro.showLoading({ title: '加载中', mask: true })
try {
const latestOrderInfo = await getOrderInfo(lookup)
setOrderInfo(latestOrderInfo)
setView(isPaid(latestOrderInfo.status) ? 'completed' : 'pay')
} catch (error) {
const message = getErrorMessage(error, '获取订单信息失败')
setErrorText(message)
setView('error')
Taro.showToast({ title: message, icon: 'none' })
} finally {
Taro.hideLoading()
}
}
function handleLaunchAppError(event: { detail?: { errMsg?: string } }): void {
const message = event.detail?.errMsg || '返回 APP 失败'
Taro.showToast({ title: message, icon: 'none' })
}
if (view === 'loading') {
return <View className='wrapper loading-placeholder' />
}
if (view === 'error') {
return (
<View className='wrapper pay-result'>
<View className='pay-result-container'>
<Image src={IMAGE_PATHS.error} style={{ width: '96px', height: '96px' }} />
<View className='env-err-text'>{errorText}</View>
</View>
</View>
)
}
if (view === 'completed') {
return (
<View className='wrapper pay-result'>
<View className='pay-result-container'>
<Image src={successIcon} style={{ width: '96px', height: '96px' }} />
<View className='pay-result-text'></View>
</View>
</View>
)
}
if (view === 'result') {
const resultIcon = paySuccess ? successIcon : IMAGE_PATHS.error
const buttonStyle = paySuccess
? undefined
: {
background: PAY_ENVIRONMENT === 'weixin' ? '#05bf5e' : '#2c78fe',
color: '#fff',
}
return (
<View className='wrapper pay-result'>
<View className='pay-result-container'>
<Image src={resultIcon} style={{ width: '96px', height: '96px' }} />
<View className='pay-result-text'>{paySuccess ? '支付成功' : '抱歉,订单支付失败'}</View>
{fromApp && process.env.TARO_ENV === 'weapp' ? (
<Button
className='back-btn'
openType='launchApp'
appParameter='wechat'
onError={handleLaunchAppError}
>
APP
</Button>
) : (
<Button className='back-btn' style={buttonStyle} onClick={handleBackToPay}>
{paySuccess ? '返回' : '重新支付'}
</Button>
)}
</View>
</View>
)
}
const paymentDisabled = submitting || !orderInfo || !authCode
return (
<View className='wrapper pay-page'>
<View className='item-name'>{orderInfo?.goodsName || ''}</View>
<View className='item-count'>
<Text style={{ fontSize: '26px' }}>¥&nbsp;</Text>
{formatAmount(orderInfo?.totalFee)}
</View>
<View className='pay-methods'>
<View className='pay-method-item'>
<View className='pay-method-item-left'>
<Image src={payMethod.logo} style={{ width: '30px', height: '30px' }} />
<Text className='pay-method-name'>{payMethod.name}</Text>
</View>
<View className='pay-method-item-right'>
<Image src={payMethod.checkIcon} style={{ width: '18px', height: '18px' }} />
</View>
</View>
</View>
<Button
className={`pay-btn ${PAY_ENVIRONMENT}-btn${paymentDisabled ? ' pay-btn-disabled' : ''}`}
disabled={paymentDisabled}
onClick={handlePay}
>
</Button>
</View>
)
}
export default IndexPage

View File

@ -0,0 +1,46 @@
import Taro from '@tarojs/taro'
import type { PaymentOrderResponse } from '@/types/payment'
import { PlatformPaymentError } from './payment.types'
export const PAY_ENVIRONMENT = 'alipay' as const
export function getPlatformAuthCode(): Promise<string> {
return new Promise((resolve, reject) => {
my.getAuthCode({
scopes: ['auth_base'],
success: (result) => {
if (result.authCode) {
resolve(result.authCode)
return
}
reject(new PlatformPaymentError('获取支付宝授权码失败'))
},
fail: (error) => {
reject(
new PlatformPaymentError(error.errorMessage || error.errMsg || '获取支付宝授权码失败'),
)
},
})
})
}
export function getAlipayLaunchQrCode(): string {
return my.getLaunchOptionsSync().query?.qrCode || ''
}
export async function requestPlatformPayment(params: PaymentOrderResponse): Promise<void> {
if (!params.tradeNo) {
throw new PlatformPaymentError('支付宝支付参数不完整')
}
const result = await Taro.tradePay({ tradeNO: params.tradeNo })
const resultCode = String(result.response?.resultCode || '')
if (resultCode !== '9000') {
const message = resultCode === '6001' ? '支付已取消' : '支付宝支付未完成'
throw new PlatformPaymentError(message, resultCode)
}
}
export { PlatformPaymentError } from './payment.types'

29
src/platform/payment.ts Normal file
View File

@ -0,0 +1,29 @@
import type { PaymentOrderResponse } from '@/types/payment'
import type { PaymentAdapter } from './payment.types'
const adapterModule =
process.env.TARO_ENV === 'alipay'
// Taro removes the non-target require branch at compile time.
// eslint-disable-next-line @typescript-eslint/no-require-imports
? (require('./payment.alipay') as { default: PaymentAdapter })
// eslint-disable-next-line @typescript-eslint/no-require-imports
: (require('./payment.weapp') as { default: PaymentAdapter })
const adapter = adapterModule.default
export const PAY_ENVIRONMENT = adapter.environment
export function getPlatformAuthCode(): Promise<string> {
return adapter.getAuthCode()
}
export function getAlipayLaunchQrCode(): string {
return adapter.getLaunchQrCode()
}
export function requestPlatformPayment(params: PaymentOrderResponse): Promise<void> {
return adapter.requestPayment(params)
}
export { PlatformPaymentError } from './payment.types'

View File

@ -0,0 +1,18 @@
import type { PayEnvironment, PaymentOrderResponse } from '@/types/payment'
export interface PaymentAdapter {
environment: PayEnvironment
getAuthCode(): Promise<string>
getLaunchQrCode(): string
requestPayment(params: PaymentOrderResponse): Promise<void>
}
export class PlatformPaymentError extends Error {
readonly resultCode?: string
constructor(message: string, resultCode?: string) {
super(message)
this.name = 'PlatformPaymentError'
this.resultCode = resultCode
}
}

View File

@ -0,0 +1,41 @@
import Taro from '@tarojs/taro'
import type { PaymentOrderResponse } from '@/types/payment'
import { PlatformPaymentError } from './payment.types'
export const PAY_ENVIRONMENT = 'weixin' as const
function validatePaymentParameters(params: PaymentOrderResponse): asserts params is Required<
Pick<PaymentOrderResponse, 'timeStamp' | 'nonceStr' | 'package' | 'paySign'>
> &
PaymentOrderResponse {
if (!params.timeStamp || !params.nonceStr || !params.package || !params.paySign) {
throw new PlatformPaymentError('微信支付参数不完整')
}
}
export async function getPlatformAuthCode(): Promise<string> {
const result = await Taro.login()
if (!result.code) {
throw new PlatformPaymentError('获取微信登录凭证失败')
}
return result.code
}
export function getAlipayLaunchQrCode(): string {
return ''
}
export async function requestPlatformPayment(params: PaymentOrderResponse): Promise<void> {
validatePaymentParameters(params)
await Taro.requestPayment({
timeStamp: params.timeStamp,
nonceStr: params.nonceStr,
package: params.package,
signType: params.signType || 'MD5',
paySign: params.paySign,
})
}
export { PlatformPaymentError } from './payment.types'

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { ApiError, unwrapApiEnvelope } from './api-envelope'
describe('unwrapApiEnvelope', () => {
it('returns data for a successful response', () => {
expect(unwrapApiEnvelope({ code: 200, data: { id: 1 } })).toEqual({ id: 1 })
})
it('supports a successful response without a code for backwards compatibility', () => {
expect(unwrapApiEnvelope({ data: 'ok' })).toBe('ok')
})
it('throws the first backend message for a business error', () => {
expect(() => unwrapApiEnvelope({ code: 500, message: '订单失效,请重试', data: {} })).toThrow(
'订单失效',
)
})
it('throws a typed error for an empty or malformed response', () => {
expect(() => unwrapApiEnvelope(null)).toThrow(ApiError)
expect(() => unwrapApiEnvelope({ code: 200 })).toThrow('服务响应缺少数据')
})
})

View File

@ -0,0 +1,33 @@
export interface ApiEnvelope<T> {
code?: number
data?: T
message?: string
}
export class ApiError extends Error {
readonly code?: number
constructor(message: string, code?: number) {
super(message)
this.name = 'ApiError'
this.code = code
}
}
export function unwrapApiEnvelope<T>(value: unknown): T {
if (!value || typeof value !== 'object') {
throw new ApiError('服务响应异常')
}
const envelope = value as ApiEnvelope<T>
if (envelope.code !== undefined && envelope.code !== 200) {
const message = envelope.message?.split(',')[0] || '请求失败'
throw new ApiError(message, envelope.code)
}
if (envelope.data === undefined || envelope.data === null) {
throw new ApiError(envelope.message || '服务响应缺少数据')
}
return envelope.data
}

41
src/services/payment.ts Normal file
View File

@ -0,0 +1,41 @@
import type {
CreatePaymentRequest,
OrderInfo,
OrderInfoResponse,
OrderLookup,
PaymentOrderResponse,
} from '@/types/payment'
import { ApiError } from './api-envelope'
import { request } from './request'
function normalizeOrderInfo(data: OrderInfoResponse): OrderInfo {
if (!Number.isFinite(data.totalFee) || !Number.isFinite(data.status) || !data.outTradeNo) {
throw new ApiError('订单数据异常')
}
return {
goodsName: data.goodsName || '',
totalFee: Number(data.totalFee) / 100,
status: Number(data.status),
outTradeNo: data.outTradeNo,
paySource: data.paySource,
}
}
export async function getOrderInfo(lookup: OrderLookup): Promise<OrderInfo> {
const data = await request<OrderInfoResponse, OrderLookup>({
url: '/api/pay/order',
method: 'GET',
data: lookup,
})
return normalizeOrderInfo(data)
}
export function createPaymentOrder(data: CreatePaymentRequest): Promise<PaymentOrderResponse> {
return request<PaymentOrderResponse, CreatePaymentRequest>({
url: '/api/pay/order',
method: 'POST',
data,
})
}

37
src/services/request.ts Normal file
View File

@ -0,0 +1,37 @@
import Taro from '@tarojs/taro'
import { ApiError, type ApiEnvelope, unwrapApiEnvelope } from './api-envelope'
const API_BASE_URL = 'https://pay.batiao8.com'
interface RequestOptions<TData> {
url: string
method: 'GET' | 'POST'
data: TData
}
function getNetworkErrorMessage(error: unknown): string {
if (error && typeof error === 'object') {
const candidate = error as { errMsg?: string; errorMessage?: string; message?: string }
return candidate.errorMessage || candidate.errMsg || candidate.message || '网络请求失败'
}
return '网络请求失败'
}
export async function request<TResponse, TData extends object>(
options: RequestOptions<TData>,
): Promise<TResponse> {
try {
const response = await Taro.request<ApiEnvelope<TResponse>, TData>({
url: `${API_BASE_URL}${options.url}`,
method: options.method,
data: options.data,
})
return unwrapApiEnvelope<TResponse>(response.data)
} catch (error) {
if (error instanceof ApiError) {
throw error
}
throw new ApiError(getNetworkErrorMessage(error))
}
}

41
src/types/payment.ts Normal file
View File

@ -0,0 +1,41 @@
export type PayEnvironment = 'weixin' | 'alipay'
export interface OrderLookup {
encOrderId?: string
outTradeNo?: string
}
export interface OrderInfoResponse {
goodsName?: string
totalFee?: number
status?: number
outTradeNo?: string
paySource?: string
}
export interface OrderInfo {
goodsName: string
totalFee: number
status: number
outTradeNo: string
paySource?: string
}
export interface CreatePaymentRequest {
outTradeNo: string
payType: PayEnvironment
paySource: string
extra: {
alipayAuthCode?: string
weixinAuthCode?: string
}
}
export interface PaymentOrderResponse {
tradeNo?: string
timeStamp?: string
nonceStr?: string
package?: string
signType?: 'MD5' | 'HMAC-SHA256' | 'RSA'
paySign?: string
}

76
src/utils/payment.test.ts Normal file
View File

@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import {
createSubmissionGuard,
extractUrlParams,
formatAmount,
isPaid,
resolveAlipayLaunchParams,
resolveWeappLaunchParams,
} from './payment'
describe('payment launch utilities', () => {
it('extracts decoded URL parameters', () => {
expect(extractUrlParams('https://pay.test/path?encOrderId=abc%20123&empty=')).toEqual({
encOrderId: 'abc 123',
empty: '',
})
})
it('keeps malformed encoded values instead of throwing', () => {
expect(extractUrlParams('https://pay.test/path?encOrderId=%E0%A4%A')).toEqual({
encOrderId: '%E0%A4%A',
})
})
it('gives direct app outTradeNo precedence over QR parameters', () => {
const resolution = resolveWeappLaunchParams({
outTradeNo: 'order-from-app',
q: encodeURIComponent('https://pay.test/path?encOrderId=order-from-qr'),
})
expect(resolution).toEqual({
lookup: { outTradeNo: 'order-from-app' },
fromApp: true,
})
})
it('resolves WeChat and Alipay QR launch parameters', () => {
expect(
resolveWeappLaunchParams({
q: encodeURIComponent('https://pay.test/path?encOrderId=wechat-order'),
}),
).toEqual({ lookup: { encOrderId: 'wechat-order' }, fromApp: false })
expect(resolveAlipayLaunchParams('https://pay.test/path?encOrderId=alipay-order')).toEqual({
lookup: { encOrderId: 'alipay-order' },
fromApp: false,
})
})
it('returns null when launch parameters are missing', () => {
expect(resolveWeappLaunchParams({})).toBeNull()
expect(resolveAlipayLaunchParams('')).toBeNull()
})
})
describe('payment state helpers', () => {
it('formats zero and invalid monetary values safely', () => {
expect(formatAmount(0)).toBe('0.00')
expect(formatAmount(12.5)).toBe('12.50')
expect(formatAmount(Number.NaN)).toBe('0.00')
})
it('only treats status 2 as paid', () => {
expect(isPaid(2)).toBe(true)
expect(isPaid(1)).toBe(false)
expect(isPaid(undefined)).toBe(false)
})
it('prevents duplicate submission until unlocked', () => {
const guard = createSubmissionGuard()
expect(guard.tryLock()).toBe(true)
expect(guard.tryLock()).toBe(false)
expect(guard.isLocked()).toBe(true)
guard.unlock()
expect(guard.tryLock()).toBe(true)
})
})

96
src/utils/payment.ts Normal file
View File

@ -0,0 +1,96 @@
import type { OrderLookup } from '@/types/payment'
export interface LaunchResolution {
lookup: OrderLookup
fromApp: boolean
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value.replace(/\+/g, ' '))
} catch {
return value
}
}
export function extractUrlParams(url: string): Record<string, string> {
if (!url) {
return {}
}
const questionMark = url.indexOf('?')
if (questionMark < 0) {
return {}
}
const hashMark = url.indexOf('#', questionMark)
const queryString = url.slice(questionMark + 1, hashMark < 0 ? undefined : hashMark)
return queryString.split('&').reduce<Record<string, string>>((result, entry) => {
if (!entry) {
return result
}
const separator = entry.indexOf('=')
const rawKey = separator < 0 ? entry : entry.slice(0, separator)
const rawValue = separator < 0 ? '' : entry.slice(separator + 1)
const key = safeDecode(rawKey)
if (key) {
result[key] = safeDecode(rawValue)
}
return result
}, {})
}
export function resolveWeappLaunchParams(
options: Record<string, string | undefined>,
): LaunchResolution | null {
const outTradeNo = options.outTradeNo?.trim()
if (outTradeNo) {
return {
lookup: { outTradeNo },
fromApp: true,
}
}
const encodedQrCode = options.q?.trim()
if (!encodedQrCode) {
return null
}
const encOrderId = extractUrlParams(safeDecode(encodedQrCode)).encOrderId?.trim()
return encOrderId ? { lookup: { encOrderId }, fromApp: false } : null
}
export function resolveAlipayLaunchParams(qrCode: string): LaunchResolution | null {
const encOrderId = extractUrlParams(qrCode).encOrderId?.trim()
return encOrderId ? { lookup: { encOrderId }, fromApp: false } : null
}
export function formatAmount(value: number | undefined): string {
return Number.isFinite(value) ? Number(value).toFixed(2) : '0.00'
}
export function isPaid(status: number | undefined): boolean {
return status === 2
}
export interface SubmissionGuard {
isLocked(): boolean
tryLock(): boolean
unlock(): void
}
export function createSubmissionGuard(): SubmissionGuard {
let locked = false
return {
isLocked: () => locked,
tryLock: () => {
if (locked) {
return false
}
locked = true
return true
},
unlock: () => {
locked = false
},
}
}

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "ESNext",
"moduleResolution": "Node",
"allowJs": false,
"strict": true,
"noEmit": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["@tarojs/taro", "node"]
},
"include": ["src", "config", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@ -1,13 +0,0 @@
uni.addInterceptor({
returnValue (res) {
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
return res;
}
return new Promise((resolve, reject) => {
res.then((res) => {
if (!res) return resolve(res)
return res[0] ? reject(res[0]) : resolve(res[1])
});
});
},
});

View File

@ -1,76 +0,0 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场https://ext.dcloud.net.cn上很多三方插件均使用了这些样式变量
* 如果你是插件开发者建议你使用scss预处理并在插件代码中直接使用这些变量无需 import 这个文件方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理你也可以直接在你的 scss 代码中使用如下变量同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;

9
vitest.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
clearMocks: true,
},
})