This commit is contained in:
Austin 2026-07-13 23:06:02 +08:00
parent a9132acd98
commit 0ac3fd5645
13 changed files with 1586 additions and 91 deletions

View File

@ -1,4 +1,4 @@
import { get, post } from '../utils/request';
import { get, post, postForm } from '../utils/request';
export interface MerchantConfigQuery {
merchant_id?: string;
@ -56,6 +56,11 @@ export interface SupplierInfo {
};
}
export interface MerchantListItem {
merchant_id: string | number;
merchant_name: string;
}
export interface ApiResult<T = any> {
code: number | string;
msg?: string;
@ -72,6 +77,79 @@ export interface TradeQueryResult {
real_money?: string;
}
export interface TradeListItem {
trade_no: string;
pay_status: number;
real_money: string;
merchant_id?: number | string;
create_time?: string;
wuye_money?: string;
pay_time?: string | null;
refund_time?: string | null;
pay_type?: number;
supplier_name?: string;
}
export interface TradeListQuery {
trade_no?: string;
page?: number | string;
limit?: number | string;
}
export interface PlatformScoreResult {
score?: string | number;
}
export interface ScoreRecordItem {
id?: string | number;
score?: string | number;
change_score?: string | number;
type?: string | number;
remark?: string;
create_time?: string;
[key: string]: unknown;
}
export interface ScoreRecordQuery {
page?: number | string;
limit?: number | string;
}
export interface CashTradeInfo {
trade_no?: string;
pay_status?: number;
pay_type?: number;
total_money?: string;
reduce_money?: string;
real_money?: string;
merchant_id?: number | string;
supplier_id?: number | string;
timeout?: number;
create_time?: string;
}
export interface CashTradeInfoResult {
trade?: CashTradeInfo;
body?: string;
subject?: string;
}
export interface CashTradePayPayload {
trade_no: string;
platform?: string;
remark?: string;
}
export interface CashTradePayResult {
trade_no?: string;
app_id?: string;
timestamp?: string;
nonce_str?: string;
package?: string;
sign_type?: string;
pay_sign?: string;
}
export const merchantConfig = (data: MerchantConfigQuery = {}) => get<ApiResult>('v1/cashier/merchant', data);
export const createCashierOrder = (data: CashierOrderPayload) =>
@ -81,14 +159,45 @@ export const cashierPay = (data: { order_id: string }) => post<ApiResult<Cashier
export const cashierRecordList = (data: any = {}) => get('v1/cashier/records', data);
export const tradeList = (data: TradeListQuery = {}) =>
get<ApiResult<TradeListItem[]>>('user/v1/trade.list', {
trade_no: data.trade_no ?? '',
page: data.page ?? 1,
limit: data.limit ?? 10
});
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 merchantListByUrl = (url: string) =>
get<ApiResult<MerchantListItem[]>>('user/v1/merchant.list', { url });
export const changeMerchant = (data: { merchant_id: string | number; url: string }) =>
postForm<ApiResult>('user/v1/merchant.change', data);
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 });
export const platformScore = () => get<ApiResult<PlatformScoreResult>>('user/v1/platform.score');
export const platformScoreRecord = (data: ScoreRecordQuery = {}) =>
get<ApiResult<ScoreRecordItem[]>>('user/v1/platform.score.record', {
page: data.page ?? 1,
limit: data.limit ?? 10
});
export const cashTradeInfo = (tradeNo: string) =>
get<ApiResult<CashTradeInfoResult>>('user/v1/cash.trade.info', { trade_no: tradeNo });
export const cashTradePay = (data: CashTradePayPayload) =>
post<ApiResult<CashTradePayResult>>('user/v1/cash.trade', {
trade_no: data.trade_no,
platform: data.platform ?? 'mp-weixin',
remark: data.remark ?? ''
});

View File

@ -2,11 +2,19 @@ export const APP_CONFIG = {
API_BASE_URL: 'https://dev.api.leapy.cn',
APP_ID: '123018',
RESOURCE_URL: 'https://resource.leapy.cn/staff/',
PROPERTY_IMG_URL: 'https://resource.leapy.cn/',
STORAGE_TOKEN_KEY: 'cashier:auth:token',
// 小程序代码可被反编译,密钥仅用于避免 token 明文落盘,不替代服务端的有效期与失效策略。
STORAGE_ENCRYPTION_KEY: 'cashier-storage-key-123018-v1-32'
} as const;
export const PROPERTY_APP = {
APP_ID: 'wx42076f086669c49e',
BIND_HOUSE_PATH: 'pagesC/certification/index?from=cashier',
MINE_PATH: 'pages/mine/mine',
WECHAT_APP_ID: 'wx02de0e1de9f4bda3'
} as const;
export const DEFAULT_MERCHANT = {
id: '',
name: '上海大观园',

View File

@ -17,7 +17,10 @@
"postcss" : true,
"minified" : true
},
"usingComponents" : true
"usingComponents" : true,
"navigateToMiniProgramAppIdList" : [
"wx42076f086669c49e"
]
},
"mp-alipay" : {
"usingComponents" : true

View File

@ -34,6 +34,22 @@
"navigationBarTitleText": "付款记录"
}
},
{
"path": "pages/mine/index",
"style": {
"enablePullDownRefresh": true,
"navigationBarTextStyle": "black",
"navigationBarTitleText": "个人中心"
}
},
{
"path": "pages/mine/score-records",
"style": {
"enablePullDownRefresh": true,
"navigationBarTextStyle": "black",
"navigationBarTitleText": "积分流水"
}
},
{
"path": "pages/house/bind",
"style": {

View File

@ -1,5 +1,10 @@
<template>
<view class="cashier-page">
<view class="mine-entry" @tap="goMine">
<text class="ri-user-3-line mine-entry-icon"></text>
<text>我的</text>
</view>
<block v-if="!hasMerchantUrl">
<view class="empty-page">
<image class="empty-bg" :src="emptyStateBackground" mode="aspectFill" />
@ -14,6 +19,10 @@
<text class="ri-qr-scan-2-line empty-scan-button-icon"></text>
<text>扫一扫商家收款码</text>
</button>
<view class="empty-mine-link" hover-class="empty-mine-link-hover" @tap="goMine">
<text class="ri-user-3-line empty-mine-link-icon"></text>
<text>个人中心</text>
</view>
</view>
</view>
</view>
@ -46,7 +55,6 @@
{{ benefitTitle }}
<text class="benefit-value">¥ {{ accumulationAmount }}</text>
</view>
<text v-if="propertyName" class="benefit-property li-two-line">绑定物业{{ propertyName }}</text>
</view>
</view>
@ -60,15 +68,51 @@
:disabled="paying"
/>
</view>
<view class="bind-row" @tap.stop="handleBindHouse">
<text class="bind-text">绑定房源</text>
<text class="bind-hint">后期绑定</text>
<text class="ri-arrow-right-s-line bind-arrow"></text>
<view
class="property-row"
hover-class="property-row-hover"
@tap.stop="handlePropertyRow"
>
<text
class="property-name li-single-line"
:class="{ 'property-name-action': isPropertyActionText }"
>
{{ propertyRowText }}
</text>
<text class="ri-arrow-right-s-line property-arrow"></text>
</view>
</view>
</view>
</view>
<view v-if="propertySheetVisible" class="property-sheet-mask" @tap="closePropertySheet">
<view class="property-sheet" @tap.stop>
<view class="property-sheet-header">
<text class="property-sheet-title">{{ propertySheetTitle }}</text>
<text class="ri-close-line property-sheet-close" @tap="closePropertySheet"></text>
</view>
<scroll-view scroll-y class="property-sheet-list">
<view
v-for="item in propertyList"
:key="String(item.merchant_id)"
class="property-sheet-item"
:class="{ active: isSameMerchantId(item.merchant_id, propertyMerchantId) }"
@tap="handleSwitchProperty(item)"
>
<text class="property-sheet-item-name li-two-line">{{ item.merchant_name }}</text>
<text
v-if="isSameMerchantId(item.merchant_id, propertyMerchantId)"
class="ri-check-line property-sheet-item-check"
></text>
</view>
</scroll-view>
<view class="property-sheet-footer" @tap="goBindProperty">
<text class="ri-add-line property-sheet-footer-icon"></text>
<text>绑定新房源</text>
</view>
</view>
</view>
<view class="keyboard" :class="{ disabled: paying }">
<view class="keypad">
<view class="number-grid">
@ -104,10 +148,11 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { bindMerchantByUrl, cancelTrade, supplierByUrl } from '@/api/cashier';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { bindMerchantByUrl, cancelTrade, changeMerchant, merchantListByUrl, supplierByUrl } from '@/api/cashier';
import type { MerchantListItem } from '@/api/cashier';
import { postForm } from '@/utils/request';
import { DEFAULT_MERCHANT } from '@/config';
import { DEFAULT_MERCHANT, APP_CONFIG, PROPERTY_APP } from '@/config';
import {
getPropertyBindingCacheKey,
getPropertyBindingCacheKeyByHash,
@ -141,6 +186,10 @@ const paying = ref(false);
const activeMerchantUrl = ref('');
const scoreName = ref('物业公积金');
const propertyName = ref('');
const propertyMerchantId = ref('');
const propertyList = ref<MerchantListItem[]>([]);
const propertySheetVisible = ref(false);
const switchingProperty = ref(false);
const scanning = ref(false);
const numberKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
@ -148,10 +197,17 @@ const canPay = computed(() => Number(amount.value) > 0 && !paying.value);
const payableAmount = computed(() => Number(amount.value || 0).toFixed(2));
const accumulationAmount = computed(() => {
const value = Number(amount.value || 0) * Number(merchant.value.accumulationRate || 0);
return value.toFixed(3);
return value.toFixed(2);
});
const showBenefitTip = computed(() => Number(merchant.value.accumulationRate) > 0);
const benefitTitle = computed(() => `预计获得${scoreName.value || '物业公积金'}`);
const propertyRowText = computed(() => {
if (propertyName.value) return propertyName.value;
if (propertyList.value.length) return '选择物业';
return '业主去绑定';
});
const isPropertyActionText = computed(() => !propertyName.value);
const propertySheetTitle = computed(() => (propertyName.value ? '切换物业' : '选择物业'));
interface PropertyBindingCache {
merchant_id?: string;
@ -190,8 +246,46 @@ const resolvePropertyFromPayload = (payload: Record<string, any> = {}) => {
};
};
const applyPropertyInfo = (property?: { merchant_name?: string; score_name?: string }) => {
const hasBindingPayload = (payload?: Record<string, any> | null) => {
if (!payload || typeof payload !== 'object') return false;
const merchantData = payload.merchant || payload;
const merchantId = merchantData.merchant_id;
const merchantName = merchantData.merchant_name;
return merchantId !== undefined && merchantId !== null && merchantId !== '' && !!merchantName;
};
const buildBindingCache = (payload: Record<string, any> = {}): PropertyBindingCache => {
const merchantData = payload.merchant || payload;
return {
merchant_id: merchantData.merchant_id,
merchant_name: merchantData.merchant_name,
score_name: merchantData.config?.score_name,
fee_rate: resolveFeeRateFromPayload(payload)
};
};
const clearPropertyBinding = () => {
propertyName.value = '';
propertyMerchantId.value = '';
};
const fetchMerchantList = async (merchantUrl: string) => {
const response = await merchantListByUrl(merchantUrl);
if (Number(response?.code) !== 200) {
throw new Error(response?.msg || '获取物业列表失败');
}
return Array.isArray(response?.data) ? response.data : [];
};
const applyPropertyInfo = (property?: {
merchant_id?: string | number;
merchant_name?: string;
score_name?: string;
}) => {
if (!property) return;
if (property.merchant_id !== undefined && property.merchant_id !== null && property.merchant_id !== '') {
propertyMerchantId.value = String(property.merchant_id);
}
if (property.merchant_name) {
propertyName.value = property.merchant_name;
}
@ -200,6 +294,10 @@ const applyPropertyInfo = (property?: { merchant_name?: string; score_name?: str
}
};
const isSameMerchantId = (left: string | number, right: string | number) => {
return String(left) === String(right);
};
const buildSupplierCache = (payload: Record<string, any> = {}, previous?: SupplierCache | null): SupplierCache => {
const supplierData = payload.supplier || {};
return {
@ -226,14 +324,11 @@ const applyMerchantInfo = (data: any = {}) => {
const applyBindingInfo = (binding?: PropertyBindingCache | null) => {
if (!binding) return;
const updates: Record<string, unknown> = {
merchant_id: binding.merchant_id,
merchant_name: binding.merchant_name
};
if (hasFeeRate(binding.fee_rate)) {
updates.accumulationRate = parseFeeRate(binding.fee_rate);
applyMerchantInfo({
accumulationRate: parseFeeRate(binding.fee_rate)
});
}
applyMerchantInfo(updates);
applyPropertyInfo(binding);
};
@ -263,6 +358,9 @@ const initWithMerchantUrl = async (merchantUrl: string) => {
hasMerchantUrl.value = true;
activeMerchantUrl.value = merchantUrl;
propertyName.value = '';
propertyMerchantId.value = '';
propertyList.value = [];
propertySheetVisible.value = false;
scoreName.value = '物业公积金';
await loadPropertyBinding(merchantUrl);
await loadSupplier(merchantUrl);
@ -319,29 +417,46 @@ const handleMissingMerchantUrl = () => {
promptScan();
};
const loadPropertyBinding = async (merchantUrl: string) => {
const resolvePropertyListFallback = async (merchantUrl: string) => {
try {
const list = await fetchMerchantList(merchantUrl);
propertyList.value = list;
if (!list.length) {
clearPropertyBinding();
encryptedStorage.remove(syncBindingCacheForUrl(merchantUrl));
}
} catch (error) {
console.log('property list fallback', error);
clearPropertyBinding();
propertyList.value = [];
}
};
const loadPropertyBinding = async (merchantUrl: string, options: { skipFallback?: boolean } = {}) => {
const cacheKey = syncBindingCacheForUrl(merchantUrl);
const cached = encryptedStorage.get<PropertyBindingCache>(cacheKey);
if (cached) {
if (cached?.merchant_name) {
applyBindingInfo(cached);
}
try {
const response = await bindMerchantByUrl(merchantUrl);
if (Number(response?.code) !== 200 || !response?.data) return;
if (Number(response?.code) !== 200 || !hasBindingPayload(response?.data)) {
if (!options.skipFallback) {
await resolvePropertyListFallback(merchantUrl);
}
return;
}
const payload = response.data || {};
const merchantData = payload.merchant || payload;
const latestBinding: PropertyBindingCache = {
merchant_id: merchantData.merchant_id,
merchant_name: merchantData.merchant_name,
score_name: merchantData.config?.score_name,
fee_rate: resolveFeeRateFromPayload(payload)
};
const latestBinding = buildBindingCache(response.data || {});
applyBindingInfo(latestBinding);
encryptedStorage.set(cacheKey, latestBinding, PROPERTY_CACHE_TTL_SECONDS);
} catch (error) {
console.log('bind merchant fallback cache', error);
if (!options.skipFallback) {
await resolvePropertyListFallback(merchantUrl);
}
}
};
@ -391,11 +506,129 @@ const deleteKey = () => {
amount.value = amount.value.slice(0, -1);
};
const handleBindHouse = () => {
const goBindProperty = () => {
if (paying.value) return;
uni.navigateTo({
url: '/pages/house/bind'
propertySheetVisible.value = false;
if (activeMerchantUrl.value) {
encryptedStorage.remove(getPropertyBindingCacheKey(activeMerchantUrl.value));
}
uni.navigateToMiniProgram({
appId: PROPERTY_APP.APP_ID,
path: PROPERTY_APP.BIND_HOUSE_PATH,
extraData: { from: 'cashier' },
envVersion: APP_CONFIG.API_BASE_URL.includes('dev.') ? 'trial' : 'release',
fail: () => {
uni.showToast({
title: '跳转失败,请稍后重试',
icon: 'none'
});
}
});
};
const goMine = () => {
uni.navigateTo({
url: '/pages/mine/index'
});
};
const closePropertySheet = () => {
propertySheetVisible.value = false;
};
const openPropertySheet = async () => {
if (!activeMerchantUrl.value) return;
const list = await fetchMerchantList(activeMerchantUrl.value);
if (!list.length) {
goBindProperty();
return;
}
propertyList.value = list;
propertySheetVisible.value = true;
};
const handlePropertyRow = async () => {
if (paying.value || switchingProperty.value) return;
if (!activeMerchantUrl.value) return;
if (!propertyName.value) {
try {
if (propertyList.value.length) {
propertySheetVisible.value = true;
return;
}
await openPropertySheet();
} catch (error: any) {
uni.showToast({
title: error?.message || '获取物业列表失败',
icon: 'none'
});
}
return;
}
try {
await openPropertySheet();
} catch (error: any) {
uni.showToast({
title: error?.message || '获取物业列表失败',
icon: 'none'
});
}
};
const switchToProperty = async (
item: MerchantListItem,
merchantUrl: string,
options: { closeSheet?: boolean; skipBindingFallback?: boolean } = {}
) => {
if (paying.value || switchingProperty.value) return false;
if (isSameMerchantId(item.merchant_id, propertyMerchantId.value)) {
if (options.closeSheet) closePropertySheet();
return true;
}
switchingProperty.value = true;
try {
const response = await changeMerchant({
merchant_id: item.merchant_id,
url: merchantUrl
});
if (Number(response?.code) !== 200) {
throw new Error(response?.msg || '切换物业失败');
}
if (options.closeSheet) closePropertySheet();
applyPropertyInfo(item);
encryptedStorage.set(
getPropertyBindingCacheKey(merchantUrl),
{
merchant_id: item.merchant_id,
merchant_name: item.merchant_name
},
PROPERTY_CACHE_TTL_SECONDS
);
await loadPropertyBinding(merchantUrl, { skipFallback: options.skipBindingFallback ?? true });
await loadSupplier(merchantUrl);
return true;
} catch (error: any) {
uni.showToast({
title: error?.message || '切换物业失败',
icon: 'none'
});
return false;
} finally {
switchingProperty.value = false;
}
};
const handleSwitchProperty = async (item: MerchantListItem) => {
if (!activeMerchantUrl.value) return;
await switchToProperty(item, activeMerchantUrl.value, { closeSheet: true, skipBindingFallback: true });
};
const buildResultUrl = (tradeNo: string) => {
@ -481,6 +714,12 @@ onLoad((options: any) => {
}
initWithMerchantUrl(merchantUrl);
});
onShow(() => {
if (!hasMerchantUrl.value || !activeMerchantUrl.value) return;
loadPropertyBinding(activeMerchantUrl.value);
loadSupplier(activeMerchantUrl.value);
});
</script>
<style lang="scss" scoped>
@ -490,6 +729,29 @@ onLoad((options: any) => {
min-height: 100vh;
background: #FFFFFF;
color: #111827;
position: relative;
}
.mine-entry {
position: fixed;
top: calc(env(safe-area-inset-top) + 16rpx);
left: 24rpx;
z-index: 20;
display: flex;
align-items: center;
gap: 6rpx;
padding: 12rpx 20rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.92);
color: #1f5bd8;
font-size: 24rpx;
font-weight: 700;
box-shadow: 0 8rpx 20rpx rgba(17, 24, 39, 0.08);
}
.mine-entry-icon {
font-size: 28rpx;
line-height: 1;
}
.empty-page {
@ -587,6 +849,26 @@ onLoad((options: any) => {
background: #174dc1;
}
.empty-mine-link {
display: flex;
align-items: center;
justify-content: center;
margin-top: 24rpx;
color: rgba(255, 255, 255, 0.96);
font-size: 28rpx;
font-weight: 600;
}
.empty-mine-link-hover {
opacity: 0.86;
}
.empty-mine-link-icon {
margin-right: 8rpx;
font-size: 32rpx;
line-height: 1;
}
.brand-only {
margin: 0;
}
@ -727,14 +1009,6 @@ onLoad((options: any) => {
line-height: 1.5;
}
.benefit-property {
display: block;
margin-top: 8rpx;
color: #AEB4BE;
font-size: 24rpx;
line-height: 1.5;
}
.benefit-value {
margin-left: 8rpx;
color: #4B6CB7;
@ -762,32 +1036,129 @@ onLoad((options: any) => {
color: #C2C7D0;
}
.bind-row {
.property-row {
display: flex;
align-items: center;
justify-content: flex-start;
margin-top: 28rpx;
padding: 8rpx 0;
padding: 12rpx 4rpx;
}
.bind-text {
color: #8B929E;
font-size: 26rpx;
.property-row-hover {
opacity: 0.88;
}
.bind-hint {
margin-left: 10rpx;
color: #C2C7D0;
font-size: 24rpx;
.property-name {
flex: 0 1 auto;
max-width: calc(100% - 36rpx);
color: #374151;
font-size: 28rpx;
font-weight: 600;
}
.bind-arrow {
.property-name-action {
color: #1F5BD8;
}
.property-arrow {
flex-shrink: 0;
margin-left: 4rpx;
color: #D1D5DB;
color: #C2C7D0;
font-size: 30rpx;
line-height: 1;
}
.property-sheet-mask {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: flex-end;
background: rgba(17, 24, 39, 0.42);
}
.property-sheet {
width: 100%;
max-height: 68vh;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
border-radius: 24rpx 24rpx 0 0;
background: #ffffff;
}
.property-sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 20rpx;
}
.property-sheet-title {
color: #111827;
font-size: 32rpx;
font-weight: 800;
}
.property-sheet-close {
color: #9CA3AF;
font-size: 40rpx;
line-height: 1;
}
.property-sheet-list {
max-height: 48vh;
}
.property-sheet-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 28rpx 32rpx;
border-top: 1rpx solid #F3F4F6;
}
.property-sheet-item.active {
background: #F8FAFF;
}
.property-sheet-item-name {
flex: 1;
min-width: 0;
color: #1F2937;
font-size: 28rpx;
line-height: 1.5;
}
.property-sheet-item.active .property-sheet-item-name {
color: #1F5BD8;
font-weight: 700;
}
.property-sheet-item-check {
flex-shrink: 0;
color: #1F5BD8;
font-size: 36rpx;
line-height: 1;
}
.property-sheet-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin: 8rpx 32rpx 0;
padding: 24rpx 0;
border-top: 1rpx solid #F3F4F6;
color: #1F5BD8;
font-size: 28rpx;
font-weight: 700;
}
.property-sheet-footer-icon {
font-size: 32rpx;
line-height: 1;
}
.keyboard {
position: fixed;
left: 0;

View File

@ -37,9 +37,9 @@
v-if="status === 'success'"
class="action-btn primary"
hover-class="button-hover"
@tap="goRecords"
@tap="goMine"
>
付款记录
个人中心
</button>
<button class="action-btn" :class="{ primary: status === 'failed' }" hover-class="button-hover-light" @tap="goCashier">
{{ status === 'failed' ? '返回收银台' : '继续付款' }}
@ -59,7 +59,7 @@ const POLL_MAX_ATTEMPTS = 120;
type ResultStatus = 'pending' | 'success' | 'failed';
const amount = ref('0.00');
const accumulation = ref('0.000');
const accumulation = ref('0.00');
const merchantName = ref('');
const merchantUrl = ref('');
const scoreName = ref('物业公积金');
@ -127,9 +127,9 @@ const pollTradeStatus = async () => {
}
};
const goRecords = () => {
const goMine = () => {
uni.navigateTo({
url: '/pages/records/index'
url: '/pages/mine/index'
});
};
@ -148,7 +148,7 @@ const goCashier = () => {
onLoad((options: any) => {
amount.value = options?.amount || '0.00';
accumulation.value = options?.accumulation || '0.000';
accumulation.value = options?.accumulation || '0.00';
merchantName.value = decodeOption(options?.merchant_name || options?.merchantName);
merchantUrl.value = decodeOption(options?.merchant_url || options?.url);
scoreName.value = decodeOption(options?.score_name) || '物业公积金';

231
pages/mine/index.vue Normal file
View File

@ -0,0 +1,231 @@
<template>
<view class="mine-page">
<view class="score-card">
<text class="score-label">平台积分</text>
<text class="score-value">¥ {{ platformScoreText }}</text>
</view>
<view class="fund-card" hover-class="fund-card-hover" @tap="goPropertyFund">
<view class="fund-card-left">
<image class="fund-icon-image" :src="fundIconUrl" mode="aspectFit" />
<view class="fund-text">
<text class="fund-title">物业基金</text>
<text class="fund-desc">查看我的物业基金余额</text>
</view>
</view>
<view class="fund-action">
<text class="fund-action-text">去查看</text>
<text class="ri-arrow-right-s-line fund-action-arrow"></text>
</view>
</view>
<view class="menu-card">
<view class="menu-item" hover-class="menu-item-hover" @tap="goRecords">
<text class="menu-label">付款记录</text>
<text class="ri-arrow-right-s-line menu-arrow"></text>
</view>
<view class="menu-item no-border" hover-class="menu-item-hover" @tap="goScoreRecords">
<text class="menu-label">积分流水</text>
<text class="ri-arrow-right-s-line menu-arrow"></text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app';
import { platformScore } from '@/api/cashier';
import { APP_CONFIG, PROPERTY_APP } from '@/config';
const platformScoreText = ref('0.00');
const loading = ref(false);
const fundIconUrl = `${APP_CONFIG.PROPERTY_IMG_URL}property/new/cashier/fangwu.png`;
const loadPlatformScore = async () => {
const response = await platformScore();
if (Number(response?.code) !== 200) {
throw new Error(response?.msg || '获取平台积分失败');
}
const score = Number(response?.data?.score || 0);
platformScoreText.value = Number.isFinite(score) ? score.toFixed(2) : '0.00';
};
const refreshPage = async () => {
loading.value = true;
try {
await loadPlatformScore();
} catch (error: any) {
uni.showToast({
title: error?.message || '加载失败',
icon: 'none'
});
} finally {
loading.value = false;
uni.stopPullDownRefresh();
}
};
const goPropertyFund = () => {
uni.navigateToMiniProgram({
appId: PROPERTY_APP.APP_ID,
path: PROPERTY_APP.MINE_PATH,
extraData: { from: 'cashier' },
envVersion: APP_CONFIG.API_BASE_URL.includes('dev.') ? 'trial' : 'release',
fail: () => {
uni.showToast({ title: '跳转失败,请稍后重试', icon: 'none' });
}
});
};
const goRecords = () => {
uni.navigateTo({ url: '/pages/records/index' });
};
const goScoreRecords = () => {
uni.navigateTo({ url: '/pages/mine/score-records' });
};
onShow(() => {
refreshPage();
});
onPullDownRefresh(() => {
refreshPage();
});
</script>
<style lang="scss" scoped>
.mine-page {
min-height: 100vh;
padding: 24rpx;
background: #f4f6f8;
}
.score-card {
padding: 40rpx 32rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #1f5bd8 0%, #3d7cf5 100%);
box-shadow: 0 12rpx 32rpx rgba(31, 91, 216, 0.24);
}
.score-label {
color: rgba(255, 255, 255, 0.88);
font-size: 28rpx;
}
.score-value {
display: block;
margin-top: 16rpx;
color: #ffffff;
font-size: 64rpx;
font-weight: 800;
line-height: 1.1;
}
.fund-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin-top: 24rpx;
padding: 28rpx 28rpx 28rpx 24rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #f8faff 0%, #eef4ff 100%);
border: 1rpx solid rgba(31, 91, 216, 0.12);
box-shadow: 0 8rpx 22rpx rgba(31, 91, 216, 0.08);
}
.fund-card-hover {
opacity: 0.92;
}
.fund-card-left {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
gap: 20rpx;
}
.fund-icon-image {
flex-shrink: 0;
width: 72rpx;
height: 72rpx;
}
.fund-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
gap: 8rpx;
}
.fund-title {
color: #111827;
font-size: 32rpx;
font-weight: 800;
line-height: 1.2;
}
.fund-desc {
color: #6b7280;
font-size: 24rpx;
line-height: 1.4;
}
.fund-action {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2rpx;
}
.fund-action-text {
color: #1f5bd8;
font-size: 26rpx;
font-weight: 700;
}
.fund-action-arrow {
color: #1f5bd8;
font-size: 28rpx;
line-height: 1;
}
.menu-card {
margin-top: 24rpx;
padding: 8rpx 0;
border-radius: 20rpx;
background: #ffffff;
box-shadow: 0 8rpx 22rpx rgba(17, 24, 39, 0.04);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid #f3f4f6;
}
.menu-item.no-border {
border-bottom: none;
}
.menu-item-hover {
background: #f9fafb;
}
.menu-label {
color: #1f2937;
font-size: 30rpx;
font-weight: 600;
}
.menu-arrow {
color: #c2c7d0;
font-size: 30rpx;
}
</style>

View File

@ -0,0 +1,199 @@
<template>
<view class="score-records-page">
<view v-if="records.length" class="records-list">
<view v-for="(item, index) in records" :key="recordKey(item, index)" class="record-card">
<view class="record-header">
<text class="record-title">{{ item.remark || formatRecordType(item) }}</text>
<text class="record-score" :class="scoreClass(item)">{{ formatScoreChange(item) }}</text>
</view>
<view class="record-meta-row">
<text class="record-meta-label">时间</text>
<text class="record-meta-value">{{ item.create_time || '-' }}</text>
</view>
<view v-if="item.score !== undefined && item.score !== null && item.score !== ''" class="record-meta-row">
<text class="record-meta-label">余额</text>
<text class="record-meta-value">¥ {{ formatMoney(item.score) }}</text>
</view>
</view>
</view>
<view v-else-if="!loading" class="records-empty">
<wd-status-tip :image="emptyImage" tip="暂无积分流水" />
</view>
<view v-if="records.length && (loading || finished)" class="records-footer">
<wd-loadmore :state="loadMoreState" />
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app';
import { platformScoreRecord } from '@/api/cashier';
import type { ScoreRecordItem } from '@/api/cashier';
import { APP_CONFIG } from '@/config';
const PAGE_SIZE = 10;
const records = ref<ScoreRecordItem[]>([]);
const page = ref(1);
const loading = ref(false);
const finished = ref(false);
const emptyImage = `${APP_CONFIG.RESOURCE_URL}tip/message.png`;
const loadMoreState = computed(() => (loading.value ? 'loading' : 'finished'));
const formatMoney = (value?: string | number) => {
const amount = Number(value || 0);
if (!Number.isFinite(amount)) return '0.00';
return amount.toFixed(2);
};
const formatRecordType = (item: ScoreRecordItem) => {
const type = String(item.type ?? '');
const map: Record<string, string> = {
'1': '积分收入',
'2': '积分支出',
'3': '积分返还'
};
return map[type] || '积分变动';
};
const formatScoreChange = (item: ScoreRecordItem) => {
const change = item.change_score ?? item.score;
const amount = Number(change || 0);
if (!Number.isFinite(amount)) return '0.00';
const prefix = amount > 0 ? '+' : '';
return `${prefix}${amount.toFixed(2)}`;
};
const scoreClass = (item: ScoreRecordItem) => {
const change = Number(item.change_score ?? item.score ?? 0);
return change >= 0 ? 'score-plus' : 'score-minus';
};
const recordKey = (item: ScoreRecordItem, index: number) => String(item.id ?? `${item.create_time || 'record'}-${index}`);
const fetchRecords = async (reset = false) => {
if (loading.value) return;
if (!reset && finished.value) return;
loading.value = true;
const nextPage = reset ? 1 : page.value;
try {
const response = await platformScoreRecord({
page: nextPage,
limit: PAGE_SIZE
});
if (Number(response?.code) !== 200) {
throw new Error(response?.msg || '获取积分流水失败');
}
const list = Array.isArray(response?.data) ? response.data : [];
records.value = reset ? list : records.value.concat(list);
page.value = nextPage + 1;
finished.value = list.length < PAGE_SIZE;
} catch (error: any) {
uni.showToast({
title: error?.message || '获取积分流水失败',
icon: 'none'
});
} finally {
loading.value = false;
if (reset) {
uni.stopPullDownRefresh();
}
}
};
onLoad(() => {
fetchRecords(true);
});
onPullDownRefresh(() => {
finished.value = false;
fetchRecords(true);
});
onReachBottom(() => {
fetchRecords(false);
});
</script>
<style lang="scss" scoped>
.score-records-page {
min-height: 100vh;
padding: 24rpx;
background: #f4f6f8;
}
.records-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.record-card {
padding: 28rpx;
border-radius: 16rpx;
background: #ffffff;
box-shadow: 0 8rpx 22rpx rgba(17, 24, 39, 0.04);
}
.record-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.record-title {
flex: 1;
min-width: 0;
color: #111827;
font-size: 30rpx;
font-weight: 700;
}
.record-score {
flex-shrink: 0;
font-size: 34rpx;
font-weight: 800;
}
.score-plus {
color: #1f5bd8;
}
.score-minus {
color: #ef4444;
}
.record-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18rpx;
}
.record-meta-label {
color: #8b929e;
font-size: 26rpx;
}
.record-meta-value {
color: #374151;
font-size: 26rpx;
}
.records-empty {
padding-top: 300rpx;
}
.records-footer {
padding: 12rpx 0 24rpx;
}
</style>

View File

@ -1,6 +1,20 @@
<template>
<view class="checkout-page">
<view class="checkout-sheet">
<view v-if="loading" class="checkout-loading">
<text class="ri-loader-4-line loading-spinner"></text>
<text class="loading-text">正在加载订单...</text>
</view>
<view v-else-if="loadError" class="checkout-error">
<text class="error-text">{{ loadError }}</text>
</view>
<view v-else class="checkout-sheet">
<view v-if="returning" class="returning-mask">
<text class="ri-loader-4-line loading-spinner"></text>
<text class="returning-text">支付成功正在返回...</text>
</view>
<view class="countdown-pill">
<view class="countdown-pill-inner">
<text class="countdown-label">支付剩余时间</text>
@ -10,9 +24,9 @@
<view class="amount-block">
<text class="amount-currency">¥</text>
<text class="amount-value">{{ order.amount }}</text>
<text class="amount-value">{{ amountText }}</text>
</view>
<view class="merchant-name">{{ order.merchantName }}</view>
<view class="merchant-name">{{ subjectText }}</view>
<view class="info-card">
<view class="info-row">
@ -22,12 +36,12 @@
class="info-input"
placeholder="点击填写订单备注"
placeholder-class="info-placeholder"
:disabled="paying"
:disabled="paying || expired || returning"
/>
</view>
<view class="info-row">
<text class="info-label">订单信息</text>
<text class="info-value">{{ order.orderDesc }}</text>
<text class="info-value">{{ bodyText }}</text>
</view>
</view>
@ -42,17 +56,16 @@
</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"
:disabled="expired || returning || !tradeNo"
@tap="handlePay"
>
确认支付 ¥{{ order.amount }}
确认支付 ¥{{ amountText }}
</button>
<view class="provider-footer">卓享汇收银台 提供服务</view>
@ -61,15 +74,30 @@
</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';
import { computed, ref } from 'vue';
import { onLoad, onUnload } from '@dcloudio/uni-app';
import { cancelTrade, cashTradeInfo, cashTradePay, queryTrade } from '@/api/cashier';
import { isCheckoutClosing, returnToHostMiniProgram, resetCheckoutClosing } from '@/utils/checkout-return';
import { isPaymentCancelled, requestWxPayment } from '@/utils/wxpay';
const order = ref<PayCheckoutOrder>({ ...MOCK_PAY_CHECKOUT_ORDER });
const POLL_INTERVAL_MS = 1000;
const POLL_MAX_ATTEMPTS = 120;
const tradeNo = ref('');
const subjectText = ref('');
const bodyText = ref('');
const amountText = ref('0.00');
const remark = ref('');
const loading = ref(true);
const loadError = ref('');
const paying = ref(false);
const returning = ref(false);
const remainSeconds = ref(0);
let countdownTimer: ReturnType<typeof setInterval> | null = null;
let hasPayAttempted = false;
let paymentSucceeded = false;
let timeoutHandled = false;
const expired = computed(() => remainSeconds.value <= 0);
const countdownText = computed(() => {
@ -85,6 +113,23 @@ const clearCountdown = () => {
}
};
const parseDateTime = (value?: string) => {
if (!value) return NaN;
return new Date(value.replace(/-/g, '/')).getTime();
};
const resolveExpireAt = (trade: Record<string, any> = {}) => {
const timeoutSeconds = Number(trade.timeout || 0);
const createAt = parseDateTime(trade.create_time);
if (Number.isFinite(createAt) && timeoutSeconds > 0) {
return createAt + timeoutSeconds * 1000;
}
if (timeoutSeconds > 0) {
return Date.now() + timeoutSeconds * 1000;
}
return Date.now() + 5 * 60 * 1000;
};
const startCountdown = (expireAt: number) => {
clearCountdown();
const tick = () => {
@ -92,32 +137,212 @@ const startCountdown = (expireAt: number) => {
remainSeconds.value = next;
if (next <= 0) {
clearCountdown();
handleTimeout();
}
};
tick();
countdownTimer = setInterval(tick, 1000);
};
const handlePay = () => {
if (paying.value || expired.value) return;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const isTradeNotPayable = (message = '') => {
const text = String(message);
return text.includes('订单状态') || text.includes('不允许付款') || text.includes('已取消') || text.includes('已关闭');
};
const finishCancelledAndReturn = async () => {
if (isCheckoutClosing() || !tradeNo.value) return;
try {
await cancelTrade(tradeNo.value);
} catch (error) {
console.log('cancel trade failed', error);
}
await backToHost('cancelled', 0);
};
const pollTradePaid = async () => {
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
try {
const response = await queryTrade(tradeNo.value);
if (Number(response?.code) === 200 && Number(response?.data?.pay_status) === 1) {
return true;
}
} catch (error) {
console.log('trade query failed', error);
}
if (attempt < POLL_MAX_ATTEMPTS - 1) {
await sleep(POLL_INTERVAL_MS);
}
}
return false;
};
const backToHost = async (status: 'success' | 'cancelled' | 'timeout', payStatus?: number) => {
if (isCheckoutClosing() || !tradeNo.value) return false;
const returned = await returnToHostMiniProgram({
status,
trade_no: tradeNo.value,
pay_status: payStatus
});
if (!returned) {
resetCheckoutClosing();
}
return returned;
};
const handleTimeout = async () => {
if (timeoutHandled || paymentSucceeded || isCheckoutClosing()) return;
timeoutHandled = true;
clearCountdown();
try {
await cancelTrade(tradeNo.value);
} catch (error) {
console.log('timeout cancel failed', error);
}
await backToHost('timeout', 0);
};
const loadOrderInfo = async () => {
loading.value = true;
loadError.value = '';
try {
const response = await cashTradeInfo(tradeNo.value);
if (Number(response?.code) !== 200 || !response?.data) {
throw new Error(response?.msg || '获取订单信息失败');
}
const payload = response.data || {};
const trade = payload.trade || {};
if (Number(trade.pay_status) === 1) {
paymentSucceeded = true;
await backToHost('success', 1);
return;
}
subjectText.value = payload.subject || '';
bodyText.value = payload.body || '';
amountText.value = Number(trade.real_money || 0).toFixed(2);
startCountdown(resolveExpireAt(trade));
} catch (error: any) {
loadError.value = error?.message || '获取订单信息失败';
} finally {
loading.value = false;
}
};
const handlePay = async () => {
if (paying.value || expired.value || returning.value || !tradeNo.value) return;
paying.value = true;
setTimeout(() => {
paying.value = false;
hasPayAttempted = true;
try {
const response = await cashTradePay({
trade_no: tradeNo.value,
platform: 'mp-weixin',
remark: remark.value.trim()
});
if (Number(response?.code) !== 200 || !response?.data) {
const msg = response?.msg || '获取支付参数失败';
if (isTradeNotPayable(msg)) {
await finishCancelledAndReturn();
return;
}
throw new Error(msg);
}
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 {
await requestWxPayment(params);
} catch (error) {
if (isPaymentCancelled(error)) {
await finishCancelledAndReturn();
return;
}
throw error;
}
returning.value = true;
const paid = await pollTradePaid();
if (paid) {
paymentSucceeded = true;
await backToHost('success', 1);
return;
}
await backToHost('success', 0);
} catch (error: any) {
const message = error?.message || '支付失败';
if (isTradeNotPayable(message)) {
await finishCancelledAndReturn();
return;
}
uni.showToast({
title: '支付接口待对接',
title: message,
icon: 'none'
});
}, 500);
} finally {
paying.value = false;
returning.value = false;
}
};
const decodeQuery = (value?: string) => {
if (!value) return '';
try {
return decodeURIComponent(value);
} catch (error) {
return value;
}
};
onLoad((options: any) => {
order.value = buildMockOrderFromOptions(options || {});
remark.value = order.value.remark;
startCountdown(order.value.expireAt);
const nextTradeNo = decodeQuery(options?.trade_no || options?.tradeNo).trim();
if (!nextTradeNo) {
loadError.value = '缺少交易单号';
loading.value = false;
return;
}
tradeNo.value = nextTradeNo;
loadOrderInfo();
});
onUnmounted(() => {
onUnload(() => {
clearCountdown();
if (isCheckoutClosing() || paymentSucceeded || !tradeNo.value) return;
const currentTradeNo = tradeNo.value;
cancelTrade(currentTradeNo).catch(() => {});
returnToHostMiniProgram({
status: 'cancelled',
trade_no: currentTradeNo,
pay_status: 0
});
});
</script>
@ -129,10 +354,59 @@ onUnmounted(() => {
background: #f3f4f6;
}
.checkout-loading,
.checkout-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 16rpx;
}
.loading-spinner {
color: #1f5bd8;
font-size: 56rpx;
animation: spin 1s linear infinite;
}
.loading-text,
.error-text,
.returning-text {
color: #6b7280;
font-size: 28rpx;
}
.error-text {
color: #ef4444;
}
.checkout-sheet {
position: relative;
padding: 16rpx 32rpx 0;
}
.returning-mask {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
background: rgba(255, 255, 255, 0.92);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.countdown-pill {
display: flex;
justify-content: center;
@ -205,7 +479,7 @@ onUnmounted(() => {
align-items: center;
justify-content: space-between;
gap: 24rpx;
padding: 28rpx 28rpx;
padding: 28rpx;
border-bottom: 1rpx solid #f0f2f5;
}
@ -232,7 +506,7 @@ onUnmounted(() => {
}
.pay-method-card {
padding: 28rpx 28rpx 24rpx;
padding: 28rpx;
}
.pay-method-title {
@ -278,13 +552,6 @@ onUnmounted(() => {
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;

View File

@ -1,18 +1,265 @@
<template>
<view class="records-page">
<view class="records-empty">
<wd-status-tip :image="uni.$globalData?.RESOURCE_URL + 'tip/message.png'" tip="暂无付款记录" />
<view v-if="records.length" class="records-list">
<view v-for="item in records" :key="item.trade_no" class="record-card">
<view class="record-header">
<text class="record-merchant li-single-line">{{ item.supplier_name || '收款商家' }}</text>
<text class="record-status" :class="statusClass(item.pay_status)">{{ formatPayStatus(item.pay_status) }}</text>
</view>
<view class="record-amount-row">
<text class="record-amount-label">实付金额</text>
<text class="record-amount">¥ {{ formatMoney(item.real_money) }}</text>
</view>
<view v-if="showWuyeMoney(item)" class="record-meta-row">
<text class="record-meta-label">物业返还</text>
<text class="record-meta-value">¥ {{ formatMoney(item.wuye_money) }}</text>
</view>
<view class="record-meta-row">
<text class="record-meta-label">支付方式</text>
<text class="record-meta-value">{{ formatPayType(item.pay_type) }}</text>
</view>
<view class="record-meta-row">
<text class="record-meta-label">支付时间</text>
<text class="record-meta-value">{{ item.pay_time || item.create_time || '-' }}</text>
</view>
<view class="record-footer">
<text class="record-trade-no">单号 {{ item.trade_no }}</text>
</view>
</view>
</view>
<view v-else-if="!loading" class="records-empty">
<wd-status-tip :image="emptyImage" tip="暂无付款记录" />
</view>
<view v-if="records.length && (loading || finished)" class="records-footer">
<wd-loadmore :state="loadMoreState" />
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app';
import { tradeList } from '@/api/cashier';
import type { TradeListItem } from '@/api/cashier';
import { APP_CONFIG } from '@/config';
const PAGE_SIZE = 10;
const records = ref<TradeListItem[]>([]);
const page = ref(1);
const loading = ref(false);
const finished = ref(false);
const emptyImage = `${APP_CONFIG.RESOURCE_URL}tip/message.png`;
const loadMoreState = computed(() => (loading.value ? 'loading' : 'finished'));
const formatMoney = (value?: string | number) => {
const amount = Number(value || 0);
if (!Number.isFinite(amount)) return '0.00';
return amount.toFixed(2);
};
const formatPayStatus = (status?: number) => {
const map: Record<number, string> = {
0: '未支付',
1: '已支付',
2: '部分退款',
3: '全额退款',
4: '已取消'
};
return map[Number(status)] || '未知状态';
};
const statusClass = (status?: number) => {
const value = Number(status);
if (value === 1) return 'status-success';
if (value === 0) return 'status-pending';
if (value === 4) return 'status-cancel';
return 'status-refund';
};
const formatPayType = (payType?: number) => {
const map: Record<number, string> = {
1: '微信',
2: '支付宝',
3: '云闪付',
4: '现金',
5: '余额',
6: '线下'
};
return map[Number(payType)] || '-';
};
const showWuyeMoney = (item: TradeListItem) => Number(item.wuye_money || 0) > 0;
const fetchRecords = async (reset = false) => {
if (loading.value) return;
if (!reset && finished.value) return;
loading.value = true;
const nextPage = reset ? 1 : page.value;
try {
const response = await tradeList({
trade_no: '',
page: nextPage,
limit: PAGE_SIZE
});
if (Number(response?.code) !== 200) {
throw new Error(response?.msg || '获取付款记录失败');
}
const list = Array.isArray(response?.data) ? response.data : [];
records.value = reset ? list : records.value.concat(list);
page.value = nextPage + 1;
finished.value = list.length < PAGE_SIZE;
} catch (error: any) {
uni.showToast({
title: error?.message || '获取付款记录失败',
icon: 'none'
});
} finally {
loading.value = false;
if (reset) {
uni.stopPullDownRefresh();
}
}
};
onLoad(() => {
fetchRecords(true);
});
onPullDownRefresh(() => {
finished.value = false;
fetchRecords(true);
});
onReachBottom(() => {
fetchRecords(false);
});
</script>
<style lang="scss" scoped>
.records-page {
min-height: 100vh;
background: #F4F6F8;
padding: 24rpx;
background: #f4f6f8;
}
.records-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.record-card {
padding: 28rpx 28rpx 24rpx;
border-radius: 16rpx;
background: #ffffff;
box-shadow: 0 8rpx 22rpx rgba(17, 24, 39, 0.04);
}
.record-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.record-merchant {
flex: 1;
min-width: 0;
color: #111827;
font-size: 30rpx;
font-weight: 700;
}
.record-status {
flex-shrink: 0;
padding: 6rpx 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
}
.status-success {
background: #e8f2ff;
color: #1f5bd8;
}
.status-pending {
background: #fff7e8;
color: #d48806;
}
.status-cancel {
background: #f3f4f6;
color: #6b7280;
}
.status-refund {
background: #fff1f0;
color: #cf1322;
}
.record-amount-row {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-top: 24rpx;
}
.record-amount-label {
color: #8b929e;
font-size: 26rpx;
}
.record-amount {
color: #111827;
font-size: 40rpx;
font-weight: 800;
}
.record-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18rpx;
}
.record-meta-label {
color: #8b929e;
font-size: 26rpx;
}
.record-meta-value {
max-width: 420rpx;
color: #374151;
font-size: 26rpx;
text-align: right;
}
.record-footer {
margin-top: 22rpx;
padding-top: 20rpx;
border-top: 1rpx solid #eef1f5;
}
.record-trade-no {
color: #9ca3af;
font-size: 22rpx;
}
.records-empty {
padding-top: 300rpx;
}
.records-footer {
padding: 12rpx 0 24rpx;
}
</style>

View File

@ -4,3 +4,5 @@

40
utils/checkout-return.ts Normal file
View File

@ -0,0 +1,40 @@
export type CheckoutReturnStatus = 'success' | 'cancelled' | 'timeout';
export interface CheckoutReturnExtraData {
status: CheckoutReturnStatus;
trade_no: string;
pay_status?: number;
}
let closing = false;
export const isCheckoutClosing = () => closing;
export const returnToHostMiniProgram = (extraData: CheckoutReturnExtraData) => {
if (closing) {
return Promise.resolve(false);
}
closing = true;
return new Promise<boolean>((resolve) => {
// #ifdef MP-WEIXIN
uni.navigateBackMiniProgram({
extraData,
success: () => resolve(true),
fail: () => {
closing = false;
resolve(false);
}
});
// #endif
// #ifndef MP-WEIXIN
closing = false;
resolve(false);
// #endif
});
};
export const resetCheckoutClosing = () => {
closing = false;
};

View File

@ -72,3 +72,5 @@ export const syncActiveMerchantContext = (merchantUrl: string) => {
switched: Boolean(previousHash && previousHash !== currentHash)
};
};
export const getActiveMerchantUrl = () => encryptedStorage.get<string>(ACTIVE_QR_URL_CACHE_KEY) || '';