This commit is contained in:
parent
ebb8bc93c0
commit
d7a32793ed
|
|
@ -12,10 +12,76 @@ export interface CashierOrderPayload {
|
|||
house_id?: string;
|
||||
}
|
||||
|
||||
export const merchantConfig = (data: MerchantConfigQuery = {}) => get('v1/cashier/merchant', data);
|
||||
export interface CashierOrderResult {
|
||||
order_id: string;
|
||||
}
|
||||
|
||||
export const createCashierOrder = (data: CashierOrderPayload) => post('v1/cashier/order/create', data);
|
||||
export interface CashierPayResult {
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: string;
|
||||
paySign: string;
|
||||
}
|
||||
|
||||
export const cashierPay = (data: { order_id: string }) => post('v1/cashier/pay', data);
|
||||
export interface BoundMerchantInfo {
|
||||
merchant_id?: string;
|
||||
merchant_name?: string;
|
||||
config?: {
|
||||
score_name?: string;
|
||||
};
|
||||
merchant?: {
|
||||
merchant_id?: string;
|
||||
merchant_name?: string;
|
||||
config?: {
|
||||
score_name?: string;
|
||||
};
|
||||
};
|
||||
supplier?: {
|
||||
give_rate?: string | number;
|
||||
};
|
||||
give_rate?: string | number;
|
||||
}
|
||||
|
||||
export interface SupplierInfo {
|
||||
supplier_id?: string | number;
|
||||
supplier_name?: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export interface ApiResult<T = any> {
|
||||
code: number | string;
|
||||
msg?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface TradeQueryResult {
|
||||
order_id?: number | string;
|
||||
trade_no?: string;
|
||||
pay_status?: number;
|
||||
pay_type?: number;
|
||||
total_money?: string;
|
||||
reduce_money?: string;
|
||||
real_money?: string;
|
||||
}
|
||||
|
||||
export const merchantConfig = (data: MerchantConfigQuery = {}) => get<ApiResult>('v1/cashier/merchant', data);
|
||||
|
||||
export const createCashierOrder = (data: CashierOrderPayload) =>
|
||||
post<ApiResult<CashierOrderResult>>('v1/cashier/order/create', data);
|
||||
|
||||
export const cashierPay = (data: { order_id: string }) => post<ApiResult<CashierPayResult>>('v1/cashier/pay', data);
|
||||
|
||||
export const cashierRecordList = (data: any = {}) => get('v1/cashier/records', data);
|
||||
|
||||
export const bindMerchantByUrl = (url: string) =>
|
||||
get<ApiResult<BoundMerchantInfo>>('user/v1/bing.merchant', { url });
|
||||
|
||||
export const supplierByUrl = (url: string) =>
|
||||
get<ApiResult<SupplierInfo>>('user/v1/supplier', { url });
|
||||
|
||||
export const queryTrade = (tradeNo: string) =>
|
||||
get<ApiResult<TradeQueryResult>>('user/v1/trade/query', { trade_no: tradeNo });
|
||||
|
||||
export const cancelTrade = (tradeNo: string) =>
|
||||
get<ApiResult>('user/v1/trade/cancel', { trade_no: tradeNo });
|
||||
|
|
|
|||
|
|
@ -10,6 +10,6 @@ export const APP_CONFIG = {
|
|||
export const DEFAULT_MERCHANT = {
|
||||
id: '',
|
||||
name: '上海大观园',
|
||||
background: '/static/cashier/merchant-bg.png',
|
||||
accumulationRate: 0.04
|
||||
background: 'https://dev.api.leapy.cn/upload/202606/26/e3ce560c144a106fa6e41af2118bc208.jpg',
|
||||
accumulationRate: 0
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
export interface PayCheckoutOrder {
|
||||
orderId: string;
|
||||
tradeNo: string;
|
||||
amount: string;
|
||||
merchantName: string;
|
||||
orderDesc: string;
|
||||
remark: string;
|
||||
expireAt: number;
|
||||
payChannel: 'wxpay';
|
||||
}
|
||||
|
||||
// Static mock for embedded checkout page; replace with API later.
|
||||
export const MOCK_PAY_CHECKOUT_ORDER: PayCheckoutOrder = {
|
||||
orderId: 'mock-order-20260702001',
|
||||
tradeNo: 'GSP202607021430001234',
|
||||
amount: '1.25',
|
||||
merchantName: '江南米粉',
|
||||
orderDesc: '消费者付款',
|
||||
remark: '',
|
||||
expireAt: Date.now() + 5 * 60 * 1000,
|
||||
payChannel: 'wxpay'
|
||||
};
|
||||
|
||||
export const buildMockOrderFromOptions = (options: Record<string, string> = {}): PayCheckoutOrder => {
|
||||
const amount = options.amount || MOCK_PAY_CHECKOUT_ORDER.amount;
|
||||
const expireSeconds = Number(options.expire_seconds || 300);
|
||||
|
||||
return {
|
||||
orderId: options.order_id || options.orderId || MOCK_PAY_CHECKOUT_ORDER.orderId,
|
||||
tradeNo: options.trade_no || options.tradeNo || MOCK_PAY_CHECKOUT_ORDER.tradeNo,
|
||||
amount,
|
||||
merchantName: decodeQuery(options.merchant_name || options.merchantName) || MOCK_PAY_CHECKOUT_ORDER.merchantName,
|
||||
orderDesc: decodeQuery(options.order_desc || options.orderDesc) || MOCK_PAY_CHECKOUT_ORDER.orderDesc,
|
||||
remark: decodeQuery(options.remark) || '',
|
||||
expireAt: Date.now() + (Number.isFinite(expireSeconds) ? expireSeconds : 300) * 1000,
|
||||
payChannel: 'wxpay'
|
||||
};
|
||||
};
|
||||
|
||||
const decodeQuery = (value?: string) => {
|
||||
if (!value) return '';
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
18
pages.json
18
pages.json
|
|
@ -8,6 +8,24 @@
|
|||
"navigationBarTitleText": "卓享汇收银台"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/cashier/result",
|
||||
"style": {
|
||||
"enablePullDownRefresh": false,
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "支付结果"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/pay/checkout",
|
||||
"style": {
|
||||
"enablePullDownRefresh": false,
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "确认支付",
|
||||
"navigationBarBackgroundColor": "#F3F4F6",
|
||||
"backgroundColor": "#F3F4F6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/records/index",
|
||||
"style": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,317 @@
|
|||
<template>
|
||||
<view class="result-page">
|
||||
<view class="result-card">
|
||||
<view v-if="status === 'pending'" class="status-block">
|
||||
<view class="pending-icon">
|
||||
<text class="ri-loader-4-line pending-spinner"></text>
|
||||
</view>
|
||||
<view class="status-title">支付处理中</view>
|
||||
<view class="status-desc">正在确认支付结果,请稍候...</view>
|
||||
<view class="amount">¥ {{ amount }}</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="status === 'success'" class="status-block">
|
||||
<view class="success-icon ri-checkbox-circle-fill"></view>
|
||||
<view class="status-title success-title">支付成功</view>
|
||||
<view class="amount">¥ {{ amount }}</view>
|
||||
<view v-if="showAccumulation" class="meta-row">
|
||||
<text class="meta-label">获得{{ scoreName }}</text>
|
||||
<text class="meta-value">¥ {{ accumulation }}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">收款商家</text>
|
||||
<text class="meta-value merchant">{{ merchantName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="status-block">
|
||||
<view class="fail-icon ri-close-circle-fill"></view>
|
||||
<view class="status-title fail-title">支付未完成</view>
|
||||
<view class="status-desc">{{ statusMessage }}</view>
|
||||
<view class="amount muted-amount">¥ {{ amount }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="status !== 'pending'" class="action-group">
|
||||
<button
|
||||
v-if="status === 'success'"
|
||||
class="action-btn primary"
|
||||
hover-class="button-hover"
|
||||
@tap="goRecords"
|
||||
>
|
||||
付款记录
|
||||
</button>
|
||||
<button class="action-btn" :class="{ primary: status === 'failed' }" hover-class="button-hover-light" @tap="goCashier">
|
||||
{{ status === 'failed' ? '返回收银台' : '继续付款' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import { queryTrade } from '@/api/cashier';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
const POLL_MAX_ATTEMPTS = 120;
|
||||
|
||||
type ResultStatus = 'pending' | 'success' | 'failed';
|
||||
|
||||
const amount = ref('0.00');
|
||||
const accumulation = ref('0.000');
|
||||
const merchantName = ref('');
|
||||
const merchantUrl = ref('');
|
||||
const scoreName = ref('物业公积金');
|
||||
const tradeNo = ref('');
|
||||
const status = ref<ResultStatus>('pending');
|
||||
const statusMessage = ref('支付结果确认超时,请稍后在付款记录中查看');
|
||||
|
||||
let pollStopped = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const showAccumulation = computed(() => Number(accumulation.value) > 0);
|
||||
|
||||
const decodeOption = (value?: string) => {
|
||||
if (!value) return '';
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => {
|
||||
pollTimer = setTimeout(() => {
|
||||
pollTimer = null;
|
||||
resolve();
|
||||
}, ms);
|
||||
});
|
||||
|
||||
const stopPolling = () => {
|
||||
pollStopped = true;
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const pollTradeStatus = async () => {
|
||||
if (!tradeNo.value) {
|
||||
status.value = 'failed';
|
||||
statusMessage.value = '缺少交易单号';
|
||||
return;
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
|
||||
if (pollStopped) return;
|
||||
|
||||
try {
|
||||
const res = await queryTrade(tradeNo.value);
|
||||
if (Number(res?.code) === 200 && Number(res?.data?.pay_status) === 1) {
|
||||
status.value = 'success';
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('trade query failed', error);
|
||||
}
|
||||
|
||||
if (attempt < POLL_MAX_ATTEMPTS - 1) {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pollStopped) {
|
||||
status.value = 'failed';
|
||||
statusMessage.value = '支付结果确认超时,请稍后在付款记录中查看';
|
||||
}
|
||||
};
|
||||
|
||||
const goRecords = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/records/index'
|
||||
});
|
||||
};
|
||||
|
||||
const goCashier = () => {
|
||||
if (!merchantUrl.value) {
|
||||
uni.showToast({
|
||||
title: '请重新扫码进入收银台',
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.reLaunch({
|
||||
url: `/pages/cashier/index?url=${encodeURIComponent(merchantUrl.value)}`
|
||||
});
|
||||
};
|
||||
|
||||
onLoad((options: any) => {
|
||||
amount.value = options?.amount || '0.00';
|
||||
accumulation.value = options?.accumulation || '0.000';
|
||||
merchantName.value = decodeOption(options?.merchant_name || options?.merchantName);
|
||||
merchantUrl.value = decodeOption(options?.merchant_url || options?.url);
|
||||
scoreName.value = decodeOption(options?.score_name) || '物业公积金';
|
||||
tradeNo.value = decodeOption(options?.trade_no || options?.order_id || options?.orderId);
|
||||
pollTradeStatus();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.result-page {
|
||||
min-height: 100vh;
|
||||
padding: 48rpx 28rpx;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
padding: 72rpx 36rpx 48rpx;
|
||||
border-radius: 14rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8rpx 22rpx rgba(17, 24, 39, 0.04);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pending-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
color: #1f5bd8;
|
||||
font-size: 88rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pending-spinner {
|
||||
display: inline-block;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #1f5bd8;
|
||||
font-size: 108rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fail-icon {
|
||||
color: #ef4444;
|
||||
font-size: 108rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
margin-top: 28rpx;
|
||||
color: #111827;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.fail-title {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
margin-top: 16rpx;
|
||||
padding: 0 12rpx;
|
||||
color: #8b929e;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.amount {
|
||||
margin-top: 24rpx;
|
||||
color: #050505;
|
||||
font-size: 72rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.muted-amount {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 36rpx;
|
||||
padding-top: 28rpx;
|
||||
border-top: 1rpx solid #eef1f5;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #8b929e;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: #1f2937;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta-value.merchant {
|
||||
max-width: 360rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
margin-top: 48rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 88rpx;
|
||||
margin-bottom: 24rpx;
|
||||
padding: 0;
|
||||
border-radius: 9rpx;
|
||||
background: #ffffff;
|
||||
color: #1f5bd8;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.action-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #1f5bd8;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.button-hover {
|
||||
background: #174dc1;
|
||||
}
|
||||
|
||||
.button-hover-light {
|
||||
background: #f0f4ff;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
<template>
|
||||
<view class="checkout-page">
|
||||
<view class="checkout-sheet">
|
||||
<view class="countdown-pill">
|
||||
<view class="countdown-pill-inner">
|
||||
<text class="countdown-label">支付剩余时间</text>
|
||||
<text class="countdown-value">{{ countdownText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="amount-block">
|
||||
<text class="amount-currency">¥</text>
|
||||
<text class="amount-value">{{ order.amount }}</text>
|
||||
</view>
|
||||
<view class="merchant-name">{{ order.merchantName }}</view>
|
||||
|
||||
<view class="info-card">
|
||||
<view class="info-row">
|
||||
<text class="info-label">备注信息</text>
|
||||
<input
|
||||
v-model="remark"
|
||||
class="info-input"
|
||||
placeholder="点击填写订单备注"
|
||||
placeholder-class="info-placeholder"
|
||||
:disabled="paying"
|
||||
/>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">订单信息</text>
|
||||
<text class="info-value">{{ order.orderDesc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pay-method-card">
|
||||
<view class="pay-method-title">支付方式</view>
|
||||
<view class="pay-method-item">
|
||||
<view class="pay-method-left">
|
||||
<view class="wx-icon">
|
||||
<text class="ri-wechat-pay-fill"></text>
|
||||
</view>
|
||||
<text class="pay-method-name">微信支付</text>
|
||||
</view>
|
||||
<text class="ri-checkbox-circle-fill pay-method-check"></text>
|
||||
</view>
|
||||
<view class="pay-method-tip">支付完成后,您需手动切回原应用</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="pay-button"
|
||||
hover-class="pay-button-hover"
|
||||
:loading="paying"
|
||||
:disabled="expired"
|
||||
@tap="handlePay"
|
||||
>
|
||||
确认支付 ¥{{ order.amount }}
|
||||
</button>
|
||||
|
||||
<view class="provider-footer">卓享汇收银台 提供服务</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import { buildMockOrderFromOptions, MOCK_PAY_CHECKOUT_ORDER, type PayCheckoutOrder } from '@/mock/pay-checkout';
|
||||
|
||||
const order = ref<PayCheckoutOrder>({ ...MOCK_PAY_CHECKOUT_ORDER });
|
||||
const remark = ref('');
|
||||
const paying = ref(false);
|
||||
const remainSeconds = ref(0);
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const expired = computed(() => remainSeconds.value <= 0);
|
||||
const countdownText = computed(() => {
|
||||
const minutes = String(Math.floor(remainSeconds.value / 60)).padStart(2, '0');
|
||||
const seconds = String(remainSeconds.value % 60).padStart(2, '0');
|
||||
return `${minutes}:${seconds}`;
|
||||
});
|
||||
|
||||
const clearCountdown = () => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startCountdown = (expireAt: number) => {
|
||||
clearCountdown();
|
||||
const tick = () => {
|
||||
const next = Math.max(0, Math.floor((expireAt - Date.now()) / 1000));
|
||||
remainSeconds.value = next;
|
||||
if (next <= 0) {
|
||||
clearCountdown();
|
||||
}
|
||||
};
|
||||
tick();
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
};
|
||||
|
||||
const handlePay = () => {
|
||||
if (paying.value || expired.value) return;
|
||||
paying.value = true;
|
||||
setTimeout(() => {
|
||||
paying.value = false;
|
||||
uni.showToast({
|
||||
title: '支付接口待对接',
|
||||
icon: 'none'
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
onLoad((options: any) => {
|
||||
order.value = buildMockOrderFromOptions(options || {});
|
||||
remark.value = order.value.remark;
|
||||
startCountdown(order.value.expireAt);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearCountdown();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.checkout-page {
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
padding: 24rpx 0 calc(32rpx + env(safe-area-inset-bottom));
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.checkout-sheet {
|
||||
padding: 16rpx 32rpx 0;
|
||||
}
|
||||
|
||||
.countdown-pill {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.countdown-pill-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 10rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4rpx 16rpx rgba(17, 24, 39, 0.06);
|
||||
}
|
||||
|
||||
.countdown-label,
|
||||
.countdown-value {
|
||||
color: #6b7280;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.countdown-value {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.amount-block {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.amount-currency {
|
||||
margin-right: 8rpx;
|
||||
color: #111827;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
color: #111827;
|
||||
font-size: 72rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.merchant-name {
|
||||
margin-top: 20rpx;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.pay-method-card {
|
||||
margin-top: 40rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: 4rpx 0;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
padding: 28rpx 28rpx;
|
||||
border-bottom: 1rpx solid #f0f2f5;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
flex-shrink: 0;
|
||||
color: #6b7280;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.info-input,
|
||||
.info-value {
|
||||
flex: 1;
|
||||
color: #111827;
|
||||
font-size: 28rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.info-placeholder {
|
||||
color: #c2c7d0;
|
||||
}
|
||||
|
||||
.pay-method-card {
|
||||
padding: 28rpx 28rpx 24rpx;
|
||||
}
|
||||
|
||||
.pay-method-title {
|
||||
margin-bottom: 20rpx;
|
||||
color: #6b7280;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.pay-method-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pay-method-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.wx-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background: #07c160;
|
||||
color: #ffffff;
|
||||
font-size: 36rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pay-method-name {
|
||||
color: #111827;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pay-method-check {
|
||||
color: #07c160;
|
||||
font-size: 36rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pay-method-tip {
|
||||
margin-top: 20rpx;
|
||||
color: #9ca3af;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pay-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 92rpx;
|
||||
margin-top: 48rpx;
|
||||
padding: 0;
|
||||
border-radius: 12rpx;
|
||||
background: #1f5bd8;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pay-button::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.pay-button-hover {
|
||||
background: #174dc1;
|
||||
}
|
||||
|
||||
.pay-button[disabled] {
|
||||
background: #c8d3ee;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.provider-footer {
|
||||
margin-top: 28rpx;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 858 KiB |
|
|
@ -2,3 +2,6 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import CryptoJS from 'crypto-js';
|
||||
import { encryptedStorage } from '@/utils/storage';
|
||||
|
||||
export const ACTIVE_QR_URL_CACHE_KEY = 'cashier:property:active-url';
|
||||
export const ACTIVE_QR_HASH_CACHE_KEY = 'cashier:property:active-url-hash';
|
||||
|
||||
const decodeOption = (value?: string) => {
|
||||
if (!value) return '';
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveMerchantUrlFromOptions = (options: Record<string, string>) => {
|
||||
return decodeOption(options.url || options.qr_url || '');
|
||||
};
|
||||
|
||||
const readQueryParam = (raw: string, key: string) => {
|
||||
const match = raw.match(new RegExp(`[?&]${key}=([^&]+)`));
|
||||
return match ? decodeOption(match[1]) : '';
|
||||
};
|
||||
|
||||
export const resolveMerchantUrlFromScanResult = (raw: string) => {
|
||||
const text = (raw || '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
if (/^https?:\/\//i.test(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const fromUrl = readQueryParam(text, 'url') || readQueryParam(text, 'qr_url');
|
||||
if (fromUrl) return fromUrl;
|
||||
|
||||
const fromQ = readQueryParam(text, 'q');
|
||||
if (fromQ) return resolveMerchantUrlFromScanResult(fromQ);
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getMerchantUrlHash = (merchantUrl: string) => {
|
||||
return CryptoJS.SHA256(merchantUrl).toString();
|
||||
};
|
||||
|
||||
export const getPropertyBindingCacheKeyByHash = (hash: string) => {
|
||||
// Hash key keeps cache key short/stable and avoids raw URL special characters.
|
||||
return `cashier:property:binding:${hash}`;
|
||||
};
|
||||
|
||||
export const getSupplierCacheKeyByHash = (hash: string) => {
|
||||
return `cashier:supplier:detail:${hash}`;
|
||||
};
|
||||
|
||||
export const getPropertyBindingCacheKey = (merchantUrl: string) => {
|
||||
return getPropertyBindingCacheKeyByHash(getMerchantUrlHash(merchantUrl));
|
||||
};
|
||||
|
||||
export const getSupplierCacheKey = (merchantUrl: string) => {
|
||||
return getSupplierCacheKeyByHash(getMerchantUrlHash(merchantUrl));
|
||||
};
|
||||
|
||||
export const syncActiveMerchantContext = (merchantUrl: string) => {
|
||||
const currentHash = getMerchantUrlHash(merchantUrl);
|
||||
const previousHash = encryptedStorage.get<string>(ACTIVE_QR_HASH_CACHE_KEY) || '';
|
||||
encryptedStorage.set(ACTIVE_QR_URL_CACHE_KEY, merchantUrl);
|
||||
encryptedStorage.set(ACTIVE_QR_HASH_CACHE_KEY, currentHash);
|
||||
|
||||
return {
|
||||
currentHash,
|
||||
previousHash,
|
||||
switched: Boolean(previousHash && previousHash !== currentHash)
|
||||
};
|
||||
};
|
||||
|
|
@ -36,7 +36,11 @@ const buildUrl = (endpoint: string) => {
|
|||
|
||||
const isAuthExpired = (statusCode: number, body: any) => {
|
||||
const businessCode = Number(body?.code);
|
||||
return statusCode === 401 || statusCode === 402 || businessCode === 401 || businessCode === 402;
|
||||
const message = String(body?.msg || '').replace(/\s+/g, '');
|
||||
const hasAuthWord = /登录|token|auth|鉴权/i.test(message);
|
||||
const hasExpiredWord = /过期|失效|无效|未登录/.test(message);
|
||||
const isLegacyExpired500 = businessCode === 500 && (hasAuthWord && hasExpiredWord);
|
||||
return statusCode === 401 || statusCode === 402 || businessCode === 401 || businessCode === 402 || isLegacyExpired500;
|
||||
};
|
||||
|
||||
const requestOnce = async <T>(url: string, options: RequestOptions, retried: boolean): Promise<T> => {
|
||||
|
|
@ -57,6 +61,10 @@ const requestOnce = async <T>(url: string, options: RequestOptions, retried: boo
|
|||
header: headers
|
||||
});
|
||||
|
||||
const body: any = response.data;
|
||||
const businessCode = Number(body?.code);
|
||||
const businessMsg = String(body?.msg || '');
|
||||
|
||||
if (isAuthExpired(response.statusCode, response.data)) {
|
||||
if (!options.skipAutoLogin && !options.skipAuth && !retried) {
|
||||
authStorage.clearToken();
|
||||
|
|
@ -71,8 +79,8 @@ const requestOnce = async <T>(url: string, options: RequestOptions, retried: boo
|
|||
throw new RequestError(`HTTP 请求失败:${response.statusCode}`, response.statusCode, response.data);
|
||||
}
|
||||
|
||||
if (Number((response.data as any)?.code) === 500) {
|
||||
throw new RequestError((response.data as any)?.msg || '服务器处理失败', response.statusCode, response.data);
|
||||
if (businessCode === 500) {
|
||||
throw new RequestError(businessMsg || '服务器处理失败', response.statusCode, response.data);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
|
|
|
|||
|
|
@ -6,15 +6,32 @@ interface StorageEnvelope<T> {
|
|||
expiresAt: number;
|
||||
}
|
||||
|
||||
const AES_KEY = CryptoJS.enc.Hex.parse(CryptoJS.SHA256(APP_CONFIG.STORAGE_ENCRYPTION_KEY).toString());
|
||||
const AES_IV = CryptoJS.enc.Hex.parse(CryptoJS.MD5(APP_CONFIG.STORAGE_ENCRYPTION_KEY).toString());
|
||||
|
||||
const encrypt = (value: unknown) => {
|
||||
return CryptoJS.AES.encrypt(JSON.stringify(value), APP_CONFIG.STORAGE_ENCRYPTION_KEY).toString();
|
||||
const payload = CryptoJS.enc.Utf8.parse(JSON.stringify(value));
|
||||
return CryptoJS.AES.encrypt(payload, AES_KEY, {
|
||||
iv: AES_IV,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
}).toString();
|
||||
};
|
||||
|
||||
const decrypt = <T>(value: string): T | null => {
|
||||
try {
|
||||
const decrypted = CryptoJS.AES.decrypt(value, APP_CONFIG.STORAGE_ENCRYPTION_KEY)
|
||||
.toString(CryptoJS.enc.Utf8);
|
||||
return decrypted ? JSON.parse(decrypted) as T : null;
|
||||
const decrypted = CryptoJS.AES.decrypt(value, AES_KEY, {
|
||||
iv: AES_IV,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
}).toString(CryptoJS.enc.Utf8);
|
||||
if (decrypted) {
|
||||
return JSON.parse(decrypted) as T;
|
||||
}
|
||||
|
||||
// Backward compatibility for old passphrase-based ciphertexts.
|
||||
const legacy = CryptoJS.AES.decrypt(value, APP_CONFIG.STORAGE_ENCRYPTION_KEY).toString(CryptoJS.enc.Utf8);
|
||||
return legacy ? JSON.parse(legacy) as T : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -52,16 +69,26 @@ export const encryptedStorage = {
|
|||
}
|
||||
};
|
||||
|
||||
let cachedTokenLogged = false;
|
||||
|
||||
export const authStorage = {
|
||||
getToken() {
|
||||
return encryptedStorage.get<string>(APP_CONFIG.STORAGE_TOKEN_KEY) || '';
|
||||
const token = encryptedStorage.get<string>(APP_CONFIG.STORAGE_TOKEN_KEY) || '';
|
||||
if (token && !cachedTokenLogged) {
|
||||
cachedTokenLogged = true;
|
||||
console.log('[cashier] token:', token);
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
setToken(token: string, expiresInSeconds = 0) {
|
||||
encryptedStorage.set(APP_CONFIG.STORAGE_TOKEN_KEY, token, expiresInSeconds);
|
||||
cachedTokenLogged = true;
|
||||
console.log('[cashier] token:', token);
|
||||
},
|
||||
|
||||
clearToken() {
|
||||
encryptedStorage.remove(APP_CONFIG.STORAGE_TOKEN_KEY);
|
||||
cachedTokenLogged = false;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
export interface WxPayParams {
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: string;
|
||||
paySign: string;
|
||||
}
|
||||
|
||||
export const requestWxPayment = (params: WxPayParams) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: params.timeStamp,
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType,
|
||||
paySign: params.paySign,
|
||||
success: () => resolve(),
|
||||
fail: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const isPaymentCancelled = (error: unknown) => {
|
||||
const message = String((error as any)?.errMsg || '');
|
||||
return message.includes('cancel');
|
||||
};
|
||||
Loading…
Reference in New Issue