This commit is contained in:
Austin 2026-07-14 20:37:42 +08:00
parent 0ac3fd5645
commit be81b63b0e
19 changed files with 937 additions and 151 deletions

11
App.vue
View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onLaunch, onShow } from '@dcloudio/uni-app' import { onLaunch, onShow } from '@dcloudio/uni-app'
import { APP_CONFIG, DEFAULT_MERCHANT } from '@/config' import { APP_CONFIG, DEFAULT_MERCHANT } from '@/config'
import { captureMerchantUrlFromLaunchOptions } from '@/utils/merchant-context'
import { VersionUpdate } from '@/utils/common' import { VersionUpdate } from '@/utils/common'
// //
@ -10,16 +11,18 @@
appid: APP_CONFIG.APP_ID, appid: APP_CONFIG.APP_ID,
appId: APP_CONFIG.APP_ID, appId: APP_CONFIG.APP_ID,
title: '卓享汇收银台', title: '卓享汇收银台',
defaultMerchant: DEFAULT_MERCHANT defaultMerchant: DEFAULT_MERCHANT,
pendingMerchantUrl: ''
} }
// 使 uni // 使 uni
// @ts-ignore // @ts-ignore
uni.$globalData = $globalData uni.$globalData = $globalData
onLaunch(() => { onLaunch((options) => {
console.log('Cashier App Launch') captureMerchantUrlFromLaunchOptions(options || {})
}) })
onShow(() => { onShow((options) => {
captureMerchantUrlFromLaunchOptions(options || {})
// //
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
VersionUpdate() VersionUpdate()

View File

@ -4,7 +4,9 @@
## 页面 ## 页面
- `pages/cashier/index`:收银台首页,支持动态商家名称、顶部背景图和物业费公积金比例。 - `pages/pay/pay`:收银台首页,支持动态商家名称、顶部背景图和物业费公积金比例。
- `pages/pay/checkout`:半屏确认支付页。
- `pages/cashier/result`:扫码支付结果页。
- `pages/records/index`:付款记录占位页。 - `pages/records/index`:付款记录占位页。
- `pages/house/bind`:绑定房源占位页。 - `pages/house/bind`:绑定房源占位页。
@ -13,7 +15,7 @@
收银台首页支持通过页面参数临时覆盖商家信息: 收银台首页支持通过页面参数临时覆盖商家信息:
```text ```text
/pages/cashier/index?merchant_id=xxx&name=商家名&background=图片地址&rate=0.04 /pages/pay/pay?merchant_id=xxx&name=商家名&background=图片地址&rate=0.04
``` ```
如果传入 `merchant_id``scene`,页面会尝试请求 `v1/cashier/merchant` 获取商家配置;接口失败时使用 `App.vue` 中的默认商家配置。 如果传入 `merchant_id``scene`,页面会尝试请求 `v1/cashier/merchant` 获取商家配置;接口失败时使用 `App.vue` 中的默认商家配置。

View File

@ -10,13 +10,33 @@ export interface LoginResponse {
}; };
} }
export const loginByWechatCode = async (code: string) => { export type LoginPlatform = 'mp-weixin' | 'mp-alipay';
export const resolveLoginPlatform = (): LoginPlatform => {
// #ifdef MP-ALIPAY
return 'mp-alipay';
// #endif
// #ifdef MP-WEIXIN
return 'mp-weixin';
// #endif
// #ifndef MP-WEIXIN
// #ifndef MP-ALIPAY
return 'mp-weixin';
// #endif
// #endif
};
/** 微信 code / 支付宝 authCode 都走同一登录接口 */
export const loginByCode = async (code: string) => {
const response = await httpRequest<LoginResponse>({ const response = await httpRequest<LoginResponse>({
url: `${APP_CONFIG.API_BASE_URL}/user/v3/login.code`, url: `${APP_CONFIG.API_BASE_URL}/user/v3/login.code`,
method: 'POST', method: 'POST',
data: toFormUrlEncoded({ data: toFormUrlEncoded({
code, code,
app_id: APP_CONFIG.APP_ID app_id: APP_CONFIG.APP_ID,
platform: resolveLoginPlatform()
}), }),
header: { header: {
'content-type': 'application/x-www-form-urlencoded' 'content-type': 'application/x-www-form-urlencoded'
@ -29,3 +49,34 @@ export const loginByWechatCode = async (code: string) => {
return response.data; return response.data;
}; };
/** @deprecated 使用 loginByCode */
export const loginByWechatCode = loginByCode;
export interface MobileAuthBindResponse {
code: number | string;
msg?: string;
data?: unknown;
}
/** 小程序授权绑定手机号(微信/支付宝) */
export const mobileAuthBind = async (code: string) => {
const response = await httpRequest<MobileAuthBindResponse>({
url: `${APP_CONFIG.API_BASE_URL}/user/v1/mobile.auth.bind`,
method: 'POST',
data: toFormUrlEncoded({
code,
app_id: APP_CONFIG.APP_ID,
platform: resolveLoginPlatform()
}),
header: {
'content-type': 'application/x-www-form-urlencoded'
}
});
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new HttpStatusError('手机号绑定失败', response.statusCode, response.data);
}
return response.data;
};

View File

@ -1,4 +1,5 @@
import { get, post, postForm } from '../utils/request'; import { get, post, postForm } from '../utils/request';
import { resolveLoginPlatform } from './auth';
export interface MerchantConfigQuery { export interface MerchantConfigQuery {
merchant_id?: string; merchant_id?: string;
@ -161,9 +162,9 @@ export const cashierRecordList = (data: any = {}) => get('v1/cashier/records', d
export const tradeList = (data: TradeListQuery = {}) => export const tradeList = (data: TradeListQuery = {}) =>
get<ApiResult<TradeListItem[]>>('user/v1/trade.list', { get<ApiResult<TradeListItem[]>>('user/v1/trade.list', {
trade_no: data.trade_no ?? '', trade_no: data.trade_no != null ? data.trade_no : '',
page: data.page ?? 1, page: data.page != null ? data.page : 1,
limit: data.limit ?? 10 limit: data.limit != null ? data.limit : 10
}); });
export const bindMerchantByUrl = (url: string) => export const bindMerchantByUrl = (url: string) =>
@ -188,8 +189,8 @@ export const platformScore = () => get<ApiResult<PlatformScoreResult>>('user/v1/
export const platformScoreRecord = (data: ScoreRecordQuery = {}) => export const platformScoreRecord = (data: ScoreRecordQuery = {}) =>
get<ApiResult<ScoreRecordItem[]>>('user/v1/platform.score.record', { get<ApiResult<ScoreRecordItem[]>>('user/v1/platform.score.record', {
page: data.page ?? 1, page: data.page != null ? data.page : 1,
limit: data.limit ?? 10 limit: data.limit != null ? data.limit : 10
}); });
export const cashTradeInfo = (tradeNo: string) => export const cashTradeInfo = (tradeNo: string) =>
@ -198,6 +199,6 @@ export const cashTradeInfo = (tradeNo: string) =>
export const cashTradePay = (data: CashTradePayPayload) => export const cashTradePay = (data: CashTradePayPayload) =>
post<ApiResult<CashTradePayResult>>('user/v1/cash.trade', { post<ApiResult<CashTradePayResult>>('user/v1/cash.trade', {
trade_no: data.trade_no, trade_no: data.trade_no,
platform: data.platform ?? 'mp-weixin', platform: data.platform != null ? data.platform : resolveLoginPlatform(),
remark: data.remark ?? '' remark: data.remark != null ? data.remark : ''
}); });

View File

@ -1,15 +1,31 @@
// #ifdef MP-ALIPAY
const APP_ID = '123019';
// #endif
// #ifdef MP-WEIXIN
const APP_ID = '123018';
// #endif
// #ifndef MP-WEIXIN
// #ifndef MP-ALIPAY
const APP_ID = '123018';
// #endif
// #endif
export const APP_CONFIG = { export const APP_CONFIG = {
API_BASE_URL: 'https://dev.api.leapy.cn', API_BASE_URL: 'https://dev.api.leapy.cn',
APP_ID: '123018', APP_ID,
RESOURCE_URL: 'https://resource.leapy.cn/staff/', RESOURCE_URL: 'https://resource.leapy.cn/staff/',
PROPERTY_IMG_URL: 'https://resource.leapy.cn/', PROPERTY_IMG_URL: 'https://resource.leapy.cn/',
STORAGE_TOKEN_KEY: 'cashier:auth:token', STORAGE_TOKEN_KEY: 'cashier:auth:token',
// 小程序代码可被反编译,密钥仅用于避免 token 明文落盘,不替代服务端的有效期与失效策略。 // 小程序代码可被反编译,密钥仅用于避免 token 明文落盘,不替代服务端的有效期与失效策略。
STORAGE_ENCRYPTION_KEY: 'cashier-storage-key-123018-v1-32' STORAGE_ENCRYPTION_KEY: 'cashier-storage-key-v1-32'
} as const; } as const;
export const PROPERTY_APP = { export const PROPERTY_APP = {
APP_ID: 'wx42076f086669c49e', APP_ID: 'wx42076f086669c49e',
/** 物业微信小程序对外名称,支付宝端引导复制用 */
WECHAT_NAME: '乐享云空间服务',
/** 物业支付宝小程序 appId配置后支付宝端可跳转 */
ALIPAY_APP_ID: '',
BIND_HOUSE_PATH: 'pagesC/certification/index?from=cashier', BIND_HOUSE_PATH: 'pagesC/certification/index?from=cashier',
MINE_PATH: 'pages/mine/mine', MINE_PATH: 'pages/mine/mine',
WECHAT_APP_ID: 'wx02de0e1de9f4bda3' WECHAT_APP_ID: 'wx02de0e1de9f4bda3'

View File

@ -18,12 +18,14 @@
"minified" : true "minified" : true
}, },
"usingComponents" : true, "usingComponents" : true,
"navigateToMiniProgramAppIdList" : [ "navigateToMiniProgramAppIdList" : [ "wx42076f086669c49e" ]
"wx42076f086669c49e"
]
}, },
"mp-alipay" : { "mp-alipay" : {
"usingComponents" : true "usingComponents" : true,
"appid" : "2021006169600123",
"component2" : true,
"styleIsolation" : "shared",
"enableAppxNg" : true
}, },
"mp-baidu" : { "mp-baidu" : {
"usingComponents" : true "usingComponents" : true

11
mini.project.json Normal file
View File

@ -0,0 +1,11 @@
{
"format": 2,
"compileOptions": {
"component2": true,
"enableAppxNg": true,
"transpile": {}
},
"developOptions": {
"skipTranspile": true
}
}

View File

@ -1,7 +1,7 @@
{ {
"name": "cashier", "name": "cashier",
"description": "卓享汇收银台", "description": "卓享汇收银台",
"dependencies": { "dependencies": {
"crypto-js": "^4.2.0" "crypto-js": "^4.2.0"
} }
} }

View File

@ -1,7 +1,7 @@
{ {
"pages": [ "pages": [
{ {
"path": "pages/cashier/index", "path": "pages/pay/pay",
"style": { "style": {
"enablePullDownRefresh": false, "enablePullDownRefresh": false,
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",

View File

@ -54,7 +54,7 @@ import { onLoad } from '@dcloudio/uni-app';
import { queryTrade } from '@/api/cashier'; import { queryTrade } from '@/api/cashier';
const POLL_INTERVAL_MS = 1000; const POLL_INTERVAL_MS = 1000;
const POLL_MAX_ATTEMPTS = 120; const POLL_MAX_ATTEMPTS = 240;
type ResultStatus = 'pending' | 'success' | 'failed'; type ResultStatus = 'pending' | 'success' | 'failed';
@ -142,7 +142,7 @@ const goCashier = () => {
return; return;
} }
uni.reLaunch({ uni.reLaunch({
url: `/pages/cashier/index?url=${encodeURIComponent(merchantUrl.value)}` url: `/pages/pay/pay?url=${encodeURIComponent(merchantUrl.value)}`
}); });
}; };

View File

@ -10,7 +10,7 @@
<image class="fund-icon-image" :src="fundIconUrl" mode="aspectFit" /> <image class="fund-icon-image" :src="fundIconUrl" mode="aspectFit" />
<view class="fund-text"> <view class="fund-text">
<text class="fund-title">物业基金</text> <text class="fund-title">物业基金</text>
<text class="fund-desc">查看我的物业基金余额</text> <text class="fund-desc">{{ propertyFundDesc }}</text>
</view> </view>
</view> </view>
<view class="fund-action"> <view class="fund-action">
@ -36,11 +36,17 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'; import { onPullDownRefresh, onShow } from '@dcloudio/uni-app';
import { platformScore } from '@/api/cashier'; import { platformScore } from '@/api/cashier';
import { resolveLoginPlatform } from '@/api/auth';
import { APP_CONFIG, PROPERTY_APP } from '@/config'; import { APP_CONFIG, PROPERTY_APP } from '@/config';
const IS_ALIPAY = resolveLoginPlatform() === 'mp-alipay';
const platformScoreText = ref('0.00'); const platformScoreText = ref('0.00');
const loading = ref(false); const loading = ref(false);
const fundIconUrl = `${APP_CONFIG.PROPERTY_IMG_URL}property/new/cashier/fangwu.png`; const fundIconUrl = `${APP_CONFIG.PROPERTY_IMG_URL}property/new/cashier/fangwu.png`;
const propertyFundDesc = IS_ALIPAY
? `请前往微信「${PROPERTY_APP.WECHAT_NAME}」查看`
: '查看我的物业基金余额';
const loadPlatformScore = async () => { const loadPlatformScore = async () => {
const response = await platformScore(); const response = await platformScore();
@ -66,7 +72,56 @@ const refreshPage = async () => {
} }
}; };
const copyPropertyWechatName = () => {
uni.setClipboardData({
data: PROPERTY_APP.WECHAT_NAME,
success: () => {
uni.showToast({
title: '已复制,请到微信搜索',
icon: 'none'
});
},
fail: () => {
uni.showToast({
title: '复制失败,请手动搜索',
icon: 'none'
});
}
});
};
const showPropertyFundWechatTip = () => {
uni.showModal({
title: '查看物业基金',
content: `请打开微信,搜索并进入「${PROPERTY_APP.WECHAT_NAME}」小程序,在「我的」中查看物业基金。`,
cancelText: '知道了',
confirmText: '复制名称',
success: (res) => {
if (res.confirm) {
copyPropertyWechatName();
}
}
});
};
const goPropertyFund = () => { const goPropertyFund = () => {
if (IS_ALIPAY) {
if (PROPERTY_APP.ALIPAY_APP_ID) {
uni.navigateToMiniProgram({
appId: PROPERTY_APP.ALIPAY_APP_ID,
path: PROPERTY_APP.MINE_PATH,
extraData: { from: 'cashier' },
envVersion: APP_CONFIG.API_BASE_URL.includes('dev.') ? 'trial' : 'release',
fail: () => {
showPropertyFundWechatTip();
}
});
return;
}
showPropertyFundWechatTip();
return;
}
uni.navigateToMiniProgram({ uni.navigateToMiniProgram({
appId: PROPERTY_APP.APP_ID, appId: PROPERTY_APP.APP_ID,
path: PROPERTY_APP.MINE_PATH, path: PROPERTY_APP.MINE_PATH,

View File

@ -51,7 +51,7 @@ const formatMoney = (value?: string | number) => {
}; };
const formatRecordType = (item: ScoreRecordItem) => { const formatRecordType = (item: ScoreRecordItem) => {
const type = String(item.type ?? ''); const type = String(item.type != null ? item.type : '');
const map: Record<string, string> = { const map: Record<string, string> = {
'1': '积分收入', '1': '积分收入',
'2': '积分支出', '2': '积分支出',
@ -61,7 +61,7 @@ const formatRecordType = (item: ScoreRecordItem) => {
}; };
const formatScoreChange = (item: ScoreRecordItem) => { const formatScoreChange = (item: ScoreRecordItem) => {
const change = item.change_score ?? item.score; const change = item.change_score != null ? item.change_score : item.score;
const amount = Number(change || 0); const amount = Number(change || 0);
if (!Number.isFinite(amount)) return '0.00'; if (!Number.isFinite(amount)) return '0.00';
const prefix = amount > 0 ? '+' : ''; const prefix = amount > 0 ? '+' : '';
@ -69,11 +69,13 @@ const formatScoreChange = (item: ScoreRecordItem) => {
}; };
const scoreClass = (item: ScoreRecordItem) => { const scoreClass = (item: ScoreRecordItem) => {
const change = Number(item.change_score ?? item.score ?? 0); const raw = item.change_score != null ? item.change_score : item.score;
const change = Number(raw != null ? raw : 0);
return change >= 0 ? 'score-plus' : 'score-minus'; return change >= 0 ? 'score-plus' : 'score-minus';
}; };
const recordKey = (item: ScoreRecordItem, index: number) => String(item.id ?? `${item.create_time || 'record'}-${index}`); const recordKey = (item: ScoreRecordItem, index: number) =>
String(item.id != null ? item.id : `${item.create_time || 'record'}-${index}`);
const fetchRecords = async (reset = false) => { const fetchRecords = async (reset = false) => {
if (loading.value) return; if (loading.value) return;

View File

@ -77,11 +77,12 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onLoad, onUnload } from '@dcloudio/uni-app'; import { onLoad, onUnload } from '@dcloudio/uni-app';
import { cancelTrade, cashTradeInfo, cashTradePay, queryTrade } from '@/api/cashier'; import { cancelTrade, cashTradeInfo, cashTradePay, queryTrade } from '@/api/cashier';
import { resolveLoginPlatform } from '@/api/auth';
import { isCheckoutClosing, returnToHostMiniProgram, resetCheckoutClosing } from '@/utils/checkout-return'; import { isCheckoutClosing, returnToHostMiniProgram, resetCheckoutClosing } from '@/utils/checkout-return';
import { isPaymentCancelled, requestWxPayment } from '@/utils/wxpay'; import { formatPaymentError, isPaymentCancelled, requestPlatformPayment } from '@/utils/wxpay';
const POLL_INTERVAL_MS = 1000; const POLL_INTERVAL_MS = 1000;
const POLL_MAX_ATTEMPTS = 120; const POLL_MAX_ATTEMPTS = 240;
const tradeNo = ref(''); const tradeNo = ref('');
const subjectText = ref(''); const subjectText = ref('');
@ -249,7 +250,7 @@ const handlePay = async () => {
try { try {
const response = await cashTradePay({ const response = await cashTradePay({
trade_no: tradeNo.value, trade_no: tradeNo.value,
platform: 'mp-weixin', platform: resolveLoginPlatform(),
remark: remark.value.trim() remark: remark.value.trim()
}); });
@ -263,20 +264,9 @@ const handlePay = async () => {
} }
const payload = response.data || {}; const payload = response.data || {};
const params = {
timeStamp: String(payload.timestamp || payload.timeStamp || ''),
nonceStr: payload.nonce_str || payload.nonceStr || '',
package: payload.package || '',
signType: payload.sign_type || payload.signType || 'RSA',
paySign: payload.pay_sign || payload.paySign || ''
};
if (!params.paySign) {
throw new Error('支付参数不完整');
}
try { try {
await requestWxPayment(params); await requestPlatformPayment(payload);
} catch (error) { } catch (error) {
if (isPaymentCancelled(error)) { if (isPaymentCancelled(error)) {
await finishCancelledAndReturn(); await finishCancelledAndReturn();
@ -295,7 +285,7 @@ const handlePay = async () => {
await backToHost('success', 0); await backToHost('success', 0);
} catch (error: any) { } catch (error: any) {
const message = error?.message || '支付失败'; const message = formatPaymentError(error);
if (isTradeNotPayable(message)) { if (isTradeNotPayable(message)) {
await finishCancelledAndReturn(); await finishCancelledAndReturn();
return; return;

View File

@ -68,6 +68,43 @@
:disabled="paying" :disabled="paying"
/> />
</view> </view>
<!-- #ifdef MP-ALIPAY -->
<button
v-if="showAlipayPhoneAuthBtn"
class="property-row property-row-btn"
hover-class="property-row-hover"
open-type="getAuthorize"
scope="phoneNumber"
:disabled="paying || bindingPhone"
@tap="handleAlipayPhoneAuthTap"
@getAuthorize="handleAlipayPhoneAuthorize"
@error="handleAlipayPhoneAuthError"
>
<text class="property-name li-single-line property-name-action">
{{ propertyRowText }}
</text>
<text v-if="showPropertyArrow" class="ri-arrow-right-s-line property-arrow"></text>
</button>
<view
v-else
class="property-row"
:class="{ 'property-row--disabled': alipayPropertyUnbound }"
hover-class="property-row-hover"
@tap.stop="handlePropertyRow"
>
<text
class="property-name li-single-line"
:class="{
'property-name-action': isPropertyActionText && !alipayPropertyUnbound,
'property-name-tip': alipayPropertyUnbound
}"
>
{{ propertyRowText }}
</text>
<text v-if="showPropertyArrow" class="ri-arrow-right-s-line property-arrow"></text>
</view>
<!-- #endif -->
<!-- #ifndef MP-ALIPAY -->
<view <view
class="property-row" class="property-row"
hover-class="property-row-hover" hover-class="property-row-hover"
@ -79,8 +116,9 @@
> >
{{ propertyRowText }} {{ propertyRowText }}
</text> </text>
<text class="ri-arrow-right-s-line property-arrow"></text> <text v-if="showPropertyArrow" class="ri-arrow-right-s-line property-arrow"></text>
</view> </view>
<!-- #endif -->
</view> </view>
</view> </view>
</view> </view>
@ -106,7 +144,7 @@
></text> ></text>
</view> </view>
</scroll-view> </scroll-view>
<view class="property-sheet-footer" @tap="goBindProperty"> <view v-if="!IS_ALIPAY" class="property-sheet-footer" @tap="goBindProperty">
<text class="ri-add-line property-sheet-footer-icon"></text> <text class="ri-add-line property-sheet-footer-icon"></text>
<text>绑定新房源</text> <text>绑定新房源</text>
</view> </view>
@ -151,6 +189,7 @@ import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app'; import { onLoad, onShow } from '@dcloudio/uni-app';
import { bindMerchantByUrl, cancelTrade, changeMerchant, merchantListByUrl, supplierByUrl } from '@/api/cashier'; import { bindMerchantByUrl, cancelTrade, changeMerchant, merchantListByUrl, supplierByUrl } from '@/api/cashier';
import type { MerchantListItem } from '@/api/cashier'; import type { MerchantListItem } from '@/api/cashier';
import { mobileAuthBind, resolveLoginPlatform } from '@/api/auth';
import { postForm } from '@/utils/request'; import { postForm } from '@/utils/request';
import { DEFAULT_MERCHANT, APP_CONFIG, PROPERTY_APP } from '@/config'; import { DEFAULT_MERCHANT, APP_CONFIG, PROPERTY_APP } from '@/config';
import { import {
@ -158,12 +197,14 @@ import {
getPropertyBindingCacheKeyByHash, getPropertyBindingCacheKeyByHash,
getSupplierCacheKey, getSupplierCacheKey,
getSupplierCacheKeyByHash, getSupplierCacheKeyByHash,
resolveMerchantUrlFromOptions, clearPendingMerchantUrl,
clearActiveMerchantContext,
resolveMerchantUrlFromEntry,
resolveMerchantUrlFromScanResult, resolveMerchantUrlFromScanResult,
syncActiveMerchantContext syncActiveMerchantContext
} from '@/utils/merchant-context'; } from '@/utils/merchant-context';
import { encryptedStorage } from '@/utils/storage'; import { encryptedStorage } from '@/utils/storage';
import { isPaymentCancelled, requestWxPayment } from '@/utils/wxpay'; import { formatPaymentError, isPaymentCancelled, requestPlatformPayment } from '@/utils/wxpay';
interface MerchantInfo { interface MerchantInfo {
id: string; id: string;
@ -177,6 +218,7 @@ const PROPERTY_CACHE_TTL_SECONDS = 10 * 60;
const SUPPLIER_CACHE_TTL_SECONDS = 10 * 60; const SUPPLIER_CACHE_TTL_SECONDS = 10 * 60;
const emptyStateBackground = '/static/cashier/bg-image.png'; const emptyStateBackground = '/static/cashier/bg-image.png';
const brandName = '卓享汇支付'; const brandName = '卓享汇支付';
const IS_ALIPAY = resolveLoginPlatform() === 'mp-alipay';
const hasMerchantUrl = ref(false); const hasMerchantUrl = ref(false);
const merchant = ref<MerchantInfo>({ ...defaultMerchant }); const merchant = ref<MerchantInfo>({ ...defaultMerchant });
@ -190,7 +232,11 @@ const propertyMerchantId = ref('');
const propertyList = ref<MerchantListItem[]>([]); const propertyList = ref<MerchantListItem[]>([]);
const propertySheetVisible = ref(false); const propertySheetVisible = ref(false);
const switchingProperty = ref(false); const switchingProperty = ref(false);
const bindingPhone = ref(false);
const alipayPropertyUnbound = ref(false);
const scanning = ref(false); const scanning = ref(false);
/** 已判定无效的进页码,避免 onShow 用同一 launch qrCode 反复弹窗 */
const rejectedMerchantUrl = ref('');
const numberKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; const numberKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
const canPay = computed(() => Number(amount.value) > 0 && !paying.value); const canPay = computed(() => Number(amount.value) > 0 && !paying.value);
@ -204,9 +250,17 @@ const benefitTitle = computed(() => `预计获得${scoreName.value || '物业公
const propertyRowText = computed(() => { const propertyRowText = computed(() => {
if (propertyName.value) return propertyName.value; if (propertyName.value) return propertyName.value;
if (propertyList.value.length) return '选择物业'; if (propertyList.value.length) return '选择物业';
if (IS_ALIPAY) {
if (alipayPropertyUnbound.value) return '暂未绑定,请去微信小程序绑定';
return '绑定手机号查看房源';
}
return '业主去绑定'; return '业主去绑定';
}); });
const isPropertyActionText = computed(() => !propertyName.value); const isPropertyActionText = computed(() => !propertyName.value);
const showAlipayPhoneAuthBtn = computed(
() => IS_ALIPAY && !propertyName.value && !propertyList.value.length && !alipayPropertyUnbound.value
);
const showPropertyArrow = computed(() => !IS_ALIPAY || !alipayPropertyUnbound.value);
const propertySheetTitle = computed(() => (propertyName.value ? '切换物业' : '选择物业')); const propertySheetTitle = computed(() => (propertyName.value ? '切换物业' : '选择物业'));
interface PropertyBindingCache { interface PropertyBindingCache {
@ -234,7 +288,7 @@ const hasFeeRate = (feeRate: unknown) => feeRate !== undefined && feeRate !== nu
const resolveFeeRateFromPayload = (payload: Record<string, any> = {}) => { const resolveFeeRateFromPayload = (payload: Record<string, any> = {}) => {
const supplierData = payload.supplier || {}; const supplierData = payload.supplier || {};
return supplierData.fee_rate ?? payload.fee_rate; return supplierData.fee_rate != null ? supplierData.fee_rate : payload.fee_rate;
}; };
const resolvePropertyFromPayload = (payload: Record<string, any> = {}) => { const resolvePropertyFromPayload = (payload: Record<string, any> = {}) => {
@ -301,10 +355,23 @@ const isSameMerchantId = (left: string | number, right: string | number) => {
const buildSupplierCache = (payload: Record<string, any> = {}, previous?: SupplierCache | null): SupplierCache => { const buildSupplierCache = (payload: Record<string, any> = {}, previous?: SupplierCache | null): SupplierCache => {
const supplierData = payload.supplier || {}; const supplierData = payload.supplier || {};
return { return {
supplier_id: supplierData.supplier_id ?? payload.supplier_id ?? previous?.supplier_id, supplier_id:
supplier_name: supplierData.supplier_name ?? payload.supplier_name ?? previous?.supplier_name, supplierData.supplier_id != null
logo: supplierData.logo ?? payload.logo ?? previous?.logo, ? supplierData.supplier_id
fee_rate: resolveFeeRateFromPayload(payload) ?? previous?.fee_rate : payload.supplier_id != null
? payload.supplier_id
: previous?.supplier_id,
supplier_name:
supplierData.supplier_name != null
? supplierData.supplier_name
: payload.supplier_name != null
? payload.supplier_name
: previous?.supplier_name,
logo: supplierData.logo != null ? supplierData.logo : payload.logo != null ? payload.logo : previous?.logo,
fee_rate: (() => {
const rate = resolveFeeRateFromPayload(payload);
return rate != null ? rate : previous?.fee_rate;
})()
}; };
}; };
@ -354,22 +421,26 @@ const syncBindingCacheForUrl = (merchantUrl: string) => {
return getPropertyBindingCacheKey(merchantUrl); return getPropertyBindingCacheKey(merchantUrl);
}; };
const initWithMerchantUrl = async (merchantUrl: string) => { const resetToEmptyMerchantState = () => {
hasMerchantUrl.value = true; hasMerchantUrl.value = false;
activeMerchantUrl.value = merchantUrl; activeMerchantUrl.value = '';
merchant.value = { ...defaultMerchant };
amount.value = '';
remark.value = '';
propertyName.value = ''; propertyName.value = '';
propertyMerchantId.value = ''; propertyMerchantId.value = '';
propertyList.value = []; propertyList.value = [];
propertySheetVisible.value = false; propertySheetVisible.value = false;
alipayPropertyUnbound.value = false;
scoreName.value = '物业公积金'; scoreName.value = '物业公积金';
await loadPropertyBinding(merchantUrl); clearPendingMerchantUrl();
await loadSupplier(merchantUrl); clearActiveMerchantContext();
}; };
const promptScan = () => { const promptScan = (message?: string) => {
uni.showModal({ uni.showModal({
title: '提示', title: '提示',
content: '未识别到商家码,请扫描商家收款码进入收银台', content: message || '未识别到商家码,请扫描商家收款码进入收银台',
confirmText: '去扫码', confirmText: '去扫码',
showCancel: false, showCancel: false,
success: (res) => { success: (res) => {
@ -380,6 +451,84 @@ const promptScan = () => {
}); });
}; };
const failInvalidMerchant = (merchantUrl: string, message?: string) => {
rejectedMerchantUrl.value = merchantUrl;
resetToEmptyMerchantState();
promptScan(message);
};
const fetchSupplierByUrl = async (merchantUrl: string) => {
const response = await supplierByUrl(merchantUrl);
if (Number(response?.code) !== 200 || !response?.data) {
return {
ok: false as const,
msg: response?.msg || '未识别到商家码,请扫描商家收款码进入收银台'
};
}
const payload = response.data || {};
const cacheKey = getSupplierCacheKey(merchantUrl);
const cached = encryptedStorage.get<SupplierCache>(cacheKey);
const latestSupplier = buildSupplierCache(payload, cached);
if (!latestSupplier.supplier_name && !latestSupplier.supplier_id) {
return {
ok: false as const,
msg: response?.msg || '未识别到商家码,请扫描商家收款码进入收银台'
};
}
applyPropertyInfo(resolvePropertyFromPayload(payload));
applySupplierInfo(latestSupplier);
encryptedStorage.set(cacheKey, latestSupplier, SUPPLIER_CACHE_TTL_SECONDS);
return { ok: true as const, supplier: latestSupplier };
};
const initWithMerchantUrl = async (
merchantUrl: string,
options: { force?: boolean } = {}
) => {
if (!options.force && rejectedMerchantUrl.value === merchantUrl) {
resetToEmptyMerchantState();
return;
}
try {
const [supplierResult, bindResponse] = await Promise.all([
fetchSupplierByUrl(merchantUrl),
bindMerchantByUrl(merchantUrl).catch(() => null)
]);
if (!supplierResult.ok) {
failInvalidMerchant(merchantUrl, supplierResult.msg);
return;
}
const bindCode = Number(bindResponse?.code);
const bindMsg = String(bindResponse?.msg || '');
if (bindResponse && bindCode !== 200 && /重新扫码/.test(bindMsg)) {
failInvalidMerchant(merchantUrl, bindMsg);
return;
}
rejectedMerchantUrl.value = '';
hasMerchantUrl.value = true;
activeMerchantUrl.value = merchantUrl;
propertyName.value = '';
propertyMerchantId.value = '';
propertyList.value = [];
propertySheetVisible.value = false;
alipayPropertyUnbound.value = false;
scoreName.value = '物业公积金';
applySupplierInfo(supplierResult.supplier);
await loadPropertyBinding(merchantUrl);
} catch (error: any) {
failInvalidMerchant(
merchantUrl,
error?.message || '未识别到商家码,请扫描商家收款码进入收银台'
);
}
};
const handleScan = () => { const handleScan = () => {
if (scanning.value) return; if (scanning.value) return;
@ -397,7 +546,7 @@ const handleScan = () => {
promptScan(); promptScan();
return; return;
} }
await initWithMerchantUrl(merchantUrl); await initWithMerchantUrl(merchantUrl, { force: true });
}, },
fail: (err: any) => { fail: (err: any) => {
const errMsg = err?.errMsg || ''; const errMsg = err?.errMsg || '';
@ -468,16 +617,12 @@ const loadSupplier = async (merchantUrl: string) => {
} }
try { try {
const response = await supplierByUrl(merchantUrl); const result = await fetchSupplierByUrl(merchantUrl);
if (Number(response?.code) !== 200 || !response?.data) return; if (!result.ok) return false;
return true;
const payload = response.data || {};
const latestSupplier = buildSupplierCache(payload, cached);
applyPropertyInfo(resolvePropertyFromPayload(payload));
applySupplierInfo(latestSupplier);
encryptedStorage.set(cacheKey, latestSupplier, SUPPLIER_CACHE_TTL_SECONDS);
} catch (error) { } catch (error) {
console.log('supplier fallback cache', error); console.log('supplier fallback cache', error);
return false;
} }
}; };
@ -506,7 +651,118 @@ const deleteKey = () => {
amount.value = amount.value.slice(0, -1); amount.value = amount.value.slice(0, -1);
}; };
const showAlipayUnboundTip = () => {
uni.showModal({
title: '暂未绑定房源',
content: '您在微信小程序中暂无绑定房源,请前往微信小程序完成绑定后再使用。',
showCancel: false,
confirmText: '知道了'
});
};
const refreshPropertyAfterPhoneBind = async () => {
if (!activeMerchantUrl.value) return;
alipayPropertyUnbound.value = false;
clearPropertyBinding();
propertyList.value = [];
encryptedStorage.remove(getPropertyBindingCacheKey(activeMerchantUrl.value));
await loadPropertyBinding(activeMerchantUrl.value);
if (propertyName.value || propertyList.value.length) {
uni.showToast({
title: '已匹配到物业信息',
icon: 'none'
});
return;
}
alipayPropertyUnbound.value = true;
};
const extractAlipayPhoneCode = (phoneRes: any) => {
const response = phoneRes?.response;
if (typeof response === 'string') return response;
if (response && typeof response === 'object') {
return response.response || response.code || '';
}
return phoneRes?.code || '';
};
const requestAlipayPhoneNumber = () =>
new Promise<any>((resolve, reject) => {
// #ifdef MP-ALIPAY
if (typeof my !== 'undefined' && typeof my.getPhoneNumber === 'function') {
my.getPhoneNumber({
success: resolve,
fail: reject
});
return;
}
// #endif
uni.getPhoneNumber({
success: resolve,
fail: reject
});
});
const handleAlipayPhoneAuthTap = () => {
console.log('[收银台][手机号授权] 按钮点击');
};
const handleAlipayPhoneAuthError = (event?: any) => {
console.log('[收银台][手机号授权] error 回调', event);
};
const handleAlipayPhoneAuthorize = async (event?: any) => {
console.log('[收银台][手机号授权] getAuthorize 回调', event);
if (paying.value || bindingPhone.value) return;
bindingPhone.value = true;
try {
const phoneRes = await requestAlipayPhoneNumber();
console.log('[收银台][手机号授权] getPhoneNumber 成功', phoneRes);
const code = extractAlipayPhoneCode(phoneRes);
console.log('[收银台][手机号授权] 解析 code', {
code,
response: phoneRes?.response,
phoneCode: phoneRes?.code
});
if (!code) {
throw new Error('手机号授权失败');
}
const response = await mobileAuthBind(code);
console.log('[收银台][手机号授权] mobileAuthBind 响应', response);
if (Number(response?.code) !== 200) {
throw new Error(response?.msg || '手机号绑定失败');
}
await refreshPropertyAfterPhoneBind();
} catch (error: any) {
console.log('[收银台][手机号授权] 授权失败', error);
const errMsg = error?.errMsg || error?.message || '';
if (errMsg.includes('cancel') || errMsg.includes('拒绝')) return;
uni.showToast({
title: errMsg || '手机号授权失败',
icon: 'none'
});
} finally {
bindingPhone.value = false;
}
};
const goBindProperty = () => { const goBindProperty = () => {
if (IS_ALIPAY) {
showAlipayUnboundTip();
return;
}
if (paying.value) return; if (paying.value) return;
propertySheetVisible.value = false; propertySheetVisible.value = false;
@ -543,6 +799,12 @@ const openPropertySheet = async () => {
const list = await fetchMerchantList(activeMerchantUrl.value); const list = await fetchMerchantList(activeMerchantUrl.value);
if (!list.length) { if (!list.length) {
if (IS_ALIPAY) {
if (alipayPropertyUnbound.value) {
showAlipayUnboundTip();
}
return;
}
goBindProperty(); goBindProperty();
return; return;
} }
@ -552,9 +814,18 @@ const openPropertySheet = async () => {
}; };
const handlePropertyRow = async () => { const handlePropertyRow = async () => {
if (paying.value || switchingProperty.value) return; if (paying.value || switchingProperty.value || bindingPhone.value) return;
if (!activeMerchantUrl.value) return; if (!activeMerchantUrl.value) return;
if (IS_ALIPAY && alipayPropertyUnbound.value) {
showAlipayUnboundTip();
return;
}
if (IS_ALIPAY && showAlipayPhoneAuthBtn.value) {
return;
}
if (!propertyName.value) { if (!propertyName.value) {
try { try {
if (propertyList.value.length) { if (propertyList.value.length) {
@ -612,7 +883,9 @@ const switchToProperty = async (
}, },
PROPERTY_CACHE_TTL_SECONDS PROPERTY_CACHE_TTL_SECONDS
); );
await loadPropertyBinding(merchantUrl, { skipFallback: options.skipBindingFallback ?? true }); await loadPropertyBinding(merchantUrl, {
skipFallback: options.skipBindingFallback != null ? options.skipBindingFallback : true
});
await loadSupplier(merchantUrl); await loadSupplier(merchantUrl);
return true; return true;
} catch (error: any) { } catch (error: any) {
@ -652,10 +925,10 @@ const handlePay = async () => {
throw new Error('缺少支付参数:商户码 url'); throw new Error('缺少支付参数:商户码 url');
} }
// jspay requestPayment // jspay platform
const payload = { const payload = {
amount: payableAmount.value, amount: payableAmount.value,
platform: 'mp-weixin', platform: resolveLoginPlatform(),
remark: remark.value.trim(), remark: remark.value.trim(),
url: activeMerchantUrl.value url: activeMerchantUrl.value
}; };
@ -665,26 +938,14 @@ const handlePay = async () => {
throw new Error(payRes?.msg || '获取支付参数失败'); throw new Error(payRes?.msg || '获取支付参数失败');
} }
const wechat = payRes?.data || {}; const payData = payRes?.data || {};
const params: any = { const tradeNo = payData.trade_no || payData.tradeNo || '';
timeStamp: wechat.timeStamp || wechat.time_stamp || wechat.timestamp || '',
nonceStr: wechat.nonceStr || wechat.nonce_str || '',
package: wechat.package || wechat.pack || '',
signType: wechat.signType || wechat.sign_type || '',
paySign: wechat.paySign || wechat.pay_sign || ''
};
if (!params.paySign) {
throw new Error('支付参数不完整');
}
const tradeNo = wechat.trade_no || wechat.tradeNo || '';
if (!tradeNo) { if (!tradeNo) {
throw new Error('缺少交易单号'); throw new Error('缺少交易单号');
} }
try { try {
await requestWxPayment(params); await requestPlatformPayment(payData);
} catch (error) { } catch (error) {
if (isPaymentCancelled(error)) { if (isPaymentCancelled(error)) {
await cancelTrade(tradeNo).catch(() => {}); await cancelTrade(tradeNo).catch(() => {});
@ -698,7 +959,7 @@ const handlePay = async () => {
}); });
} catch (error: any) { } catch (error: any) {
uni.showToast({ uni.showToast({
title: error?.message || '支付失败', title: formatPaymentError(error),
icon: 'none' icon: 'none'
}); });
} finally { } finally {
@ -707,15 +968,25 @@ const handlePay = async () => {
}; };
onLoad((options: any) => { onLoad((options: any) => {
const merchantUrl = resolveMerchantUrlFromOptions(options || {}); const merchantUrl = resolveMerchantUrlFromEntry(options || {});
if (!merchantUrl) { if (!merchantUrl) {
handleMissingMerchantUrl(); handleMissingMerchantUrl();
return; return;
} }
clearPendingMerchantUrl();
initWithMerchantUrl(merchantUrl); initWithMerchantUrl(merchantUrl);
}); });
onShow(() => { onShow(() => {
if (!hasMerchantUrl.value || !activeMerchantUrl.value) {
const merchantUrl = resolveMerchantUrlFromEntry({});
if (merchantUrl && merchantUrl !== rejectedMerchantUrl.value) {
clearPendingMerchantUrl();
initWithMerchantUrl(merchantUrl);
return;
}
}
if (!hasMerchantUrl.value || !activeMerchantUrl.value) return; if (!hasMerchantUrl.value || !activeMerchantUrl.value) return;
loadPropertyBinding(activeMerchantUrl.value); loadPropertyBinding(activeMerchantUrl.value);
loadSupplier(activeMerchantUrl.value); loadSupplier(activeMerchantUrl.value);
@ -1037,6 +1308,7 @@ onShow(() => {
} }
.property-row { .property-row {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
@ -1044,6 +1316,27 @@ onShow(() => {
padding: 12rpx 4rpx; padding: 12rpx 4rpx;
} }
.property-row--disabled {
opacity: 0.92;
}
.property-row-btn {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
margin: 0;
padding: 12rpx 4rpx;
border: none;
background: transparent;
line-height: 1;
text-align: left;
}
.property-row-btn::after {
border: none;
}
.property-row-hover { .property-row-hover {
opacity: 0.88; opacity: 0.88;
} }
@ -1060,6 +1353,11 @@ onShow(() => {
color: #1F5BD8; color: #1F5BD8;
} }
.property-name-tip {
color: #8B929E;
font-weight: 500;
}
.property-arrow { .property-arrow {
flex-shrink: 0; flex-shrink: 0;
margin-left: 4rpx; margin-left: 4rpx;

View File

@ -0,0 +1,93 @@
/**
* 可选手动对支付宝产物再跑一遍降级
* 日常运行/发行到支付宝会由 vite.config.ts alipayEsCompatPlugin 自动处理
*
* 用法node scripts/alipay-es-compat.js [dir...]
*/
const fs = require('fs');
const path = require('path');
function resolveTransformSync() {
try {
return require('esbuild').transformSync;
} catch (error) {
try {
const vitePath = require.resolve('vite');
const esbuildPath = require.resolve('esbuild', { paths: [path.dirname(vitePath)] });
return require(esbuildPath).transformSync;
} catch (inner) {
throw new Error('找不到 esbuild请先通过 vite/uni 运行支付宝构建,或安装 esbuild');
}
}
}
const transformSync = resolveTransformSync();
const TRANSFORM_OPTIONS = {
loader: 'js',
target: 'es2015',
sourcemap: false,
supported: {
arrow: false,
'template-literal': false,
'optional-chain': false,
'nullish-coalescing': false,
'optional-catch-binding': false,
'object-rest-spread': false,
'class-field': false
}
};
function walkJsFiles(dir, files = []) {
if (!fs.existsSync(dir)) return files;
for (const name of fs.readdirSync(dir)) {
const fullPath = path.join(dir, name);
if (fs.statSync(fullPath).isDirectory()) {
walkJsFiles(fullPath, files);
} else if (name.endsWith('.js')) {
files.push(fullPath);
}
}
return files;
}
function needsTransform(source) {
return (
source.includes('=>') ||
source.includes('`') ||
source.includes('?.') ||
source.includes('??') ||
/catch\s*\{/.test(source)
);
}
function transformFile(filePath) {
const source = fs.readFileSync(filePath, 'utf8');
if (!needsTransform(source)) return false;
const result = transformSync(source, TRANSFORM_OPTIONS);
if (!result.code || result.code === source) return false;
fs.writeFileSync(filePath, result.code, 'utf8');
return true;
}
function run(roots) {
let changed = 0;
for (const root of roots) {
const abs = path.resolve(__dirname, '..', root);
for (const file of walkJsFiles(abs)) {
try {
if (transformFile(file)) changed += 1;
} catch (error) {
console.warn('[alipay-es-compat] skip', file, error && error.message);
}
}
}
console.log(`[alipay-es-compat] transformed ${changed} file(s)`);
}
const cliRoots = process.argv.slice(2);
run(
cliRoots.length
? cliRoots
: ['unpackage/dist/dev/mp-alipay', 'unpackage/dist/build/mp-alipay']
);

View File

@ -1,26 +1,33 @@
import { loginByWechatCode } from '@/api/auth'; import { loginByCode } from '@/api/auth';
import { authStorage } from '@/utils/storage'; import { authStorage } from '@/utils/storage';
let loginPromise: Promise<string> | null = null; let loginPromise: Promise<string> | null = null;
const getWechatCode = () => { const getLoginCode = () => {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN || MP-ALIPAY
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
uni.login({ uni.login({
// #ifdef MP-ALIPAY
provider: 'alipay',
scopes: 'auth_base',
// #endif
success: (result) => { success: (result) => {
if (!result.code) { const code = result.code || (result as any).authCode;
reject(new Error('微信登录凭证获取失败')); if (!code) {
reject(new Error('登录凭证获取失败'));
return; return;
} }
resolve(result.code); resolve(code);
}, },
fail: () => reject(new Error('微信登录凭证获取失败')) fail: () => reject(new Error('登录凭证获取失败'))
}); });
}); });
// #endif // #endif
// #ifndef MP-WEIXIN // #ifndef MP-WEIXIN
return Promise.reject(new Error('当前端暂不支持微信无感登录')); // #ifndef MP-ALIPAY
return Promise.reject(new Error('当前端暂不支持无感登录'));
// #endif
// #endif // #endif
}; };
@ -28,8 +35,8 @@ export const ensureLogin = () => {
if (loginPromise) return loginPromise; if (loginPromise) return loginPromise;
const pendingLogin = (async () => { const pendingLogin = (async () => {
const code = await getWechatCode(); const code = await getLoginCode();
const result = await loginByWechatCode(code); const result = await loginByCode(code);
const token = result?.data?.token; const token = result?.data?.token;
if (Number(result?.code) !== 200 || !token) { if (Number(result?.code) !== 200 || !token) {

View File

@ -3,6 +3,7 @@ import { encryptedStorage } from '@/utils/storage';
export const ACTIVE_QR_URL_CACHE_KEY = 'cashier:property:active-url'; export const ACTIVE_QR_URL_CACHE_KEY = 'cashier:property:active-url';
export const ACTIVE_QR_HASH_CACHE_KEY = 'cashier:property:active-url-hash'; export const ACTIVE_QR_HASH_CACHE_KEY = 'cashier:property:active-url-hash';
export const PENDING_MERCHANT_URL_KEY = 'cashier:pending-merchant-url';
const decodeOption = (value?: string) => { const decodeOption = (value?: string) => {
if (!value) return ''; if (!value) return '';
@ -39,6 +40,95 @@ export const resolveMerchantUrlFromScanResult = (raw: string) => {
return ''; return '';
}; };
export const setPendingMerchantUrl = (merchantUrl: string) => {
if (!merchantUrl) return;
encryptedStorage.set(PENDING_MERCHANT_URL_KEY, merchantUrl);
// @ts-ignore
if (uni.$globalData) {
// @ts-ignore
uni.$globalData.pendingMerchantUrl = merchantUrl;
}
};
export const getPendingMerchantUrl = () => {
// @ts-ignore
const fromMemory = uni.$globalData?.pendingMerchantUrl;
if (fromMemory) return String(fromMemory);
return encryptedStorage.get<string>(PENDING_MERCHANT_URL_KEY) || '';
};
export const clearPendingMerchantUrl = () => {
encryptedStorage.remove(PENDING_MERCHANT_URL_KEY);
// @ts-ignore
if (uni.$globalData) {
// @ts-ignore
uni.$globalData.pendingMerchantUrl = '';
}
};
const resolveMerchantUrlFromRawEntry = (raw?: string) => {
if (!raw) return '';
return resolveMerchantUrlFromScanResult(decodeOption(raw));
};
const getLaunchQuery = () => {
try {
const launchOptions = uni.getLaunchOptionsSync?.();
return (launchOptions?.query || {}) as Record<string, string>;
} catch (error) {
return {};
}
};
/** App.onLaunch / App.onShow捕获支付宝普通二维码 qrCode */
export const captureMerchantUrlFromLaunchOptions = (options: Record<string, any> = {}) => {
const query = options.query || {};
const qrCode = query.qrCode || query.qr_code || '';
if (!qrCode) return '';
const merchantUrl = resolveMerchantUrlFromRawEntry(String(qrCode));
if (!merchantUrl) return '';
setPendingMerchantUrl(merchantUrl);
return merchantUrl;
};
/**
* merchantUrl
* 1. ?url= / ?qr_url=
* 2. options.q
* 3. query.qrCode / getLaunchOptionsSync
* 4. App pendingMerchantUrl
*/
export const resolveMerchantUrlFromEntry = (options: Record<string, any> = {}) => {
const direct = resolveMerchantUrlFromOptions(options as Record<string, string>);
if (direct) {
const merchantUrl = resolveMerchantUrlFromScanResult(direct);
if (merchantUrl) return merchantUrl;
}
if (options.q) {
const fromQ = resolveMerchantUrlFromRawEntry(String(options.q));
if (fromQ) return fromQ;
}
const pageQrCode = options.qrCode || options.qr_code;
if (pageQrCode) {
const fromPageQr = resolveMerchantUrlFromRawEntry(String(pageQrCode));
if (fromPageQr) return fromPageQr;
}
const launchQuery = getLaunchQuery();
const launchQrCode = launchQuery.qrCode || launchQuery.qr_code;
if (launchQrCode) {
const fromLaunch = resolveMerchantUrlFromRawEntry(String(launchQrCode));
if (fromLaunch) return fromLaunch;
}
return getPendingMerchantUrl();
};
export const getMerchantUrlHash = (merchantUrl: string) => { export const getMerchantUrlHash = (merchantUrl: string) => {
return CryptoJS.SHA256(merchantUrl).toString(); return CryptoJS.SHA256(merchantUrl).toString();
}; };
@ -74,3 +164,8 @@ export const syncActiveMerchantContext = (merchantUrl: string) => {
}; };
export const getActiveMerchantUrl = () => encryptedStorage.get<string>(ACTIVE_QR_URL_CACHE_KEY) || ''; export const getActiveMerchantUrl = () => encryptedStorage.get<string>(ACTIVE_QR_URL_CACHE_KEY) || '';
export const clearActiveMerchantContext = () => {
encryptedStorage.remove(ACTIVE_QR_URL_CACHE_KEY);
encryptedStorage.remove(ACTIVE_QR_HASH_CACHE_KEY);
};

View File

@ -1,3 +1,5 @@
import { resolveLoginPlatform } from '@/api/auth';
export interface WxPayParams { export interface WxPayParams {
timeStamp: string; timeStamp: string;
nonceStr: string; nonceStr: string;
@ -6,6 +8,47 @@ export interface WxPayParams {
paySign: string; paySign: string;
} }
export const extractWxPayParams = (data: Record<string, any> = {}): WxPayParams => ({
timeStamp: String(data.timeStamp || data.time_stamp || data.timestamp || ''),
nonceStr: data.nonceStr || data.nonce_str || '',
package: data.package || data.pack || '',
signType: data.signType || data.sign_type || 'RSA',
paySign: data.paySign || data.pay_sign || ''
});
/** 支付宝小程序 tradeNO后端 jspay 通常放在 prepay_id 字段 */
export const extractAlipayTradeNo = (data: Record<string, any> = {}) =>
String(data.prepay_id || data.tradeNO || '');
const ALIPAY_RESULT_MESSAGES: Record<string, string> = {
'4000': '订单处理失败,请确认 tradeNO 与 buyer_id 是否正确',
'6001': '已取消支付',
'6002': '网络连接出错,请稍后重试',
'6004': '支付结果未知,请在订单页查询',
'8000': '支付处理中,请在订单页查询'
};
export const formatPaymentError = (error: unknown) => {
const err = error as any;
if (err?.message && !/^支付失败$/.test(String(err.message))) {
return String(err.message);
}
const memo = err?.memo || err?.errMsg || err?.errorMessage || '';
if (memo && !/^requestPayment:fail/.test(String(memo))) {
return String(memo);
}
const resultCode = String(err?.resultCode || '');
if (resultCode && ALIPAY_RESULT_MESSAGES[resultCode]) {
return ALIPAY_RESULT_MESSAGES[resultCode];
}
if (memo) return String(memo);
return '支付失败';
};
export const requestWxPayment = (params: WxPayParams) => { export const requestWxPayment = (params: WxPayParams) => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
uni.requestPayment({ uni.requestPayment({
@ -21,7 +64,74 @@ export const requestWxPayment = (params: WxPayParams) => {
}); });
}; };
export const requestAlipayPayment = (tradeNO: string) => {
console.log('[收银台][支付宝支付] 调起 tradeNO', tradeNO);
return new Promise<void>((resolve, reject) => {
// #ifdef MP-ALIPAY
if (typeof my !== 'undefined' && typeof my.tradePay === 'function') {
my.tradePay({
tradeNO,
success: (res: any) => {
console.log('[收银台][支付宝支付] success', res);
const resultCode = String(res?.resultCode || '');
if (resultCode === '9000' || resultCode === '8000' || resultCode === '6004') {
resolve();
return;
}
if (resultCode === '6001') {
reject({ resultCode, errMsg: 'requestPayment:cancel', memo: '用户取消支付' });
return;
}
reject({
resultCode,
memo: res?.memo || ALIPAY_RESULT_MESSAGES[resultCode] || '支付失败'
});
},
fail: (error: any) => {
console.log('[收银台][支付宝支付] fail', error);
reject(error);
}
});
return;
}
// #endif
uni.requestPayment({
provider: 'alipay',
orderInfo: tradeNO,
success: (res) => {
console.log('[收银台][支付宝支付] uni.requestPayment success', res);
resolve();
},
fail: (error) => {
console.log('[收银台][支付宝支付] uni.requestPayment fail', error);
reject(error);
}
});
});
};
/** 按当前端解析 jspay / cash.trade 返回并调起支付 */
export const requestPlatformPayment = async (data: Record<string, any> = {}) => {
if (resolveLoginPlatform() === 'mp-alipay') {
const tradeNO = extractAlipayTradeNo(data);
if (!tradeNO) {
throw new Error('支付参数不完整');
}
await requestAlipayPayment(tradeNO);
return;
}
const params = extractWxPayParams(data);
if (!params.paySign) {
throw new Error('支付参数不完整');
}
await requestWxPayment(params);
};
export const isPaymentCancelled = (error: unknown) => { export const isPaymentCancelled = (error: unknown) => {
const message = String((error as any)?.errMsg || ''); const message = String((error as any)?.errMsg || '');
return message.includes('cancel'); const resultCode = String((error as any)?.resultCode || '');
return message.includes('cancel') || resultCode === '6001';
}; };

View File

@ -1,77 +1,127 @@
import { defineConfig } from 'vite'; import fs from 'node:fs';
import path from 'node:path';
import { defineConfig, transformWithEsbuild, type Plugin } from 'vite';
import uni from '@dcloudio/vite-plugin-uni'; import uni from '@dcloudio/vite-plugin-uni';
/** 此路径根据自己项目路径修改;默认为 uniapp 插件市场导入路径;*/ /** 此路径根据自己项目路径修改;默认为 uniapp 插件市场导入路径;*/
import viteVueUnocss, { unocss, flex, pseudo, border } from './unocss/a-hua-unocss'; import viteVueUnocss, { unocss, flex, pseudo, border } from './unocss/a-hua-unocss';
export default defineConfig({ const isAlipay = () => process.env.UNI_PLATFORM === 'mp-alipay';
/**
* BabelCE1000 / / ?? / ?. / catch{}
* mp-alipay es2015 const/letesbuild constes5
*/
const ALIPAY_ESBUILD_OPTIONS = {
loader: 'js' as const,
target: 'es2015',
sourcemap: false as const,
supported: {
arrow: false,
'template-literal': false,
'optional-chain': false,
'nullish-coalescing': false,
'optional-catch-binding': false,
'object-rest-spread': false,
'class-field': false
}
};
const needsAlipayTransform = (source: string) =>
source.includes('=>') ||
source.includes('`') ||
source.includes('?.') ||
source.includes('??') ||
/catch\s*\{/.test(source);
const walkJsFiles = (dir: string, files: string[] = []) => {
if (!fs.existsSync(dir)) return files;
for (const name of fs.readdirSync(dir)) {
const fullPath = path.join(dir, name);
if (fs.statSync(fullPath).isDirectory()) {
walkJsFiles(fullPath, files);
} else if (name.endsWith('.js')) {
files.push(fullPath);
}
}
return files;
};
const transformAlipayOutputDir = async (root: string) => {
const files = walkJsFiles(root);
let changed = 0;
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
if (!needsAlipayTransform(source)) continue;
try {
const result = await transformWithEsbuild(source, file, ALIPAY_ESBUILD_OPTIONS);
if (result.code && result.code !== source) {
fs.writeFileSync(file, result.code, 'utf8');
changed += 1;
}
} catch (error: any) {
console.warn('[alipay-es-compat] skip', file, error?.message || error);
}
}
console.log(`[alipay-es-compat] ${root} transformed ${changed} file(s)`);
};
function alipayEsCompatPlugin(): Plugin {
return {
name: 'cashier-alipay-es-compat',
apply: 'build',
enforce: 'post',
async renderChunk(code) {
if (!isAlipay() || !needsAlipayTransform(code)) return null;
const result = await transformWithEsbuild(code, 'chunk.js', ALIPAY_ESBUILD_OPTIONS);
return { code: result.code, map: null };
},
async closeBundle() {
if (!isAlipay()) return;
const roots = [
path.resolve(__dirname, 'unpackage/dist/dev/mp-alipay'),
path.resolve(__dirname, 'unpackage/dist/build/mp-alipay')
];
for (const root of roots) {
await transformAlipayOutputDir(root);
}
}
};
}
export default defineConfig({
build: isAlipay()
? {
target: 'es2015',
cssTarget: 'chrome61'
}
: undefined,
plugins: [ plugins: [
uni(), uni(),
alipayEsCompatPlugin(),
viteVueUnocss({ viteVueUnocss({
/** 预设数组;默认[unocss()] */
presets: [ presets: [
/**
*
* text-24uno-text-24xx-text-24...
*/
unocss(), unocss(),
/**
*
* flex-centerflex-col-centerflex-row-center...
*/
flex(), flex(),
/**
*
* after:text-24 after:(text-24 text-white)...
*/
pseudo(), pseudo(),
/**
*
* border-1 => border: 1px solid currentColor
* border-1-red => border: 1px solid red
* border-red => border: 1px solid red
* ...
*
*/
border({ border({
width: 1, width: 1,
style: 'solid', style: 'solid',
color: 'currentColor' color: 'currentColor'
}), })
], ],
/**
* CSS
*
*/
prefix: ['li'], prefix: ['li'],
/**
* CSS
*
*/
exclude: ['node_modules', 'uni_modules'], exclude: ['node_modules', 'uni_modules'],
/** 主题配置 */
theme: { theme: {
/**
* uni.scss
* false
*/
generator: true, generator: true,
/** 自定义颜色预设 */
colors: { colors: {
/** text-very-cool */
veryCool: '#37A5FF', veryCool: '#37A5FF',
brand: { brand: {
/** bg-brand-primary */
primary: '#37A5FF', primary: '#37A5FF',
/** bg-brand */
DEFAULT: '#942192' DEFAULT: '#942192'
}, }
} }
} }
}) })
] ]
}); });