This commit is contained in:
Austin 2026-07-09 20:14:53 +08:00
parent ebb8bc93c0
commit d7a32793ed
13 changed files with 1503 additions and 287 deletions

View File

@ -12,10 +12,76 @@ export interface CashierOrderPayload {
house_id?: string; 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 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 });

View File

@ -10,6 +10,6 @@ export const APP_CONFIG = {
export const DEFAULT_MERCHANT = { export const DEFAULT_MERCHANT = {
id: '', id: '',
name: '上海大观园', name: '上海大观园',
background: '/static/cashier/merchant-bg.png', background: 'https://dev.api.leapy.cn/upload/202606/26/e3ce560c144a106fa6e41af2118bc208.jpg',
accumulationRate: 0.04 accumulationRate: 0
} as const; } as const;

47
mock/pay-checkout.ts Normal file
View File

@ -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;
}
};

View File

@ -8,6 +8,24 @@
"navigationBarTitleText": "卓享汇收银台" "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", "path": "pages/records/index",
"style": { "style": {

File diff suppressed because it is too large Load Diff

317
pages/cashier/result.vue Normal file
View File

@ -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>

321
pages/pay/checkout.vue Normal file
View File

@ -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>

BIN
static/cashier/bg-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

View File

@ -2,3 +2,6 @@

74
utils/merchant-context.ts Normal file
View File

@ -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)
};
};

View File

@ -36,7 +36,11 @@ const buildUrl = (endpoint: string) => {
const isAuthExpired = (statusCode: number, body: any) => { const isAuthExpired = (statusCode: number, body: any) => {
const businessCode = Number(body?.code); 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> => { 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 header: headers
}); });
const body: any = response.data;
const businessCode = Number(body?.code);
const businessMsg = String(body?.msg || '');
if (isAuthExpired(response.statusCode, response.data)) { if (isAuthExpired(response.statusCode, response.data)) {
if (!options.skipAutoLogin && !options.skipAuth && !retried) { if (!options.skipAutoLogin && !options.skipAuth && !retried) {
authStorage.clearToken(); 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); throw new RequestError(`HTTP 请求失败:${response.statusCode}`, response.statusCode, response.data);
} }
if (Number((response.data as any)?.code) === 500) { if (businessCode === 500) {
throw new RequestError((response.data as any)?.msg || '服务器处理失败', response.statusCode, response.data); throw new RequestError(businessMsg || '服务器处理失败', response.statusCode, response.data);
} }
return response.data; return response.data;

View File

@ -6,15 +6,32 @@ interface StorageEnvelope<T> {
expiresAt: number; 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) => { 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 => { const decrypt = <T>(value: string): T | null => {
try { try {
const decrypted = CryptoJS.AES.decrypt(value, APP_CONFIG.STORAGE_ENCRYPTION_KEY) const decrypted = CryptoJS.AES.decrypt(value, AES_KEY, {
.toString(CryptoJS.enc.Utf8); iv: AES_IV,
return decrypted ? JSON.parse(decrypted) as T : null; 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) { } catch (error) {
return null; return null;
} }
@ -52,16 +69,26 @@ export const encryptedStorage = {
} }
}; };
let cachedTokenLogged = false;
export const authStorage = { export const authStorage = {
getToken() { 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) { setToken(token: string, expiresInSeconds = 0) {
encryptedStorage.set(APP_CONFIG.STORAGE_TOKEN_KEY, token, expiresInSeconds); encryptedStorage.set(APP_CONFIG.STORAGE_TOKEN_KEY, token, expiresInSeconds);
cachedTokenLogged = true;
console.log('[cashier] token:', token);
}, },
clearToken() { clearToken() {
encryptedStorage.remove(APP_CONFIG.STORAGE_TOKEN_KEY); encryptedStorage.remove(APP_CONFIG.STORAGE_TOKEN_KEY);
cachedTokenLogged = false;
} }
}; };

27
utils/wxpay.ts Normal file
View File

@ -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');
};