cashier/pages/pay/pay.vue

1638 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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" />
<view class="empty-overlay"></view>
<view class="empty-inner">
<view class="empty-brand-block">
<text class="empty-brand">{{ brandName }}</text>
<text class="empty-subtitle">扫描商家收款码快速完成付款</text>
</view>
<view class="empty-action">
<button class="empty-scan-button" hover-class="empty-scan-button-hover" :loading="scanning" @tap="handleScan">
<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>
</block>
<block v-else>
<view class="pay-header" :style="{ backgroundImage: `url(${merchant.background})` }">
<view class="pay-header-mask"></view>
<view class="pay-header-scrim"></view>
<view class="merchant-label">
<view class="merchant-label-inner">
<text class="muted"></text>
<text class="merchant-name" :style="{ fontSize: merchantNameFontSize }">{{ merchant.name }}</text>
<text class="muted">付款</text>
</view>
</view>
</view>
<view class="pay-body">
<view class="pay-main">
<view class="amount-zone">
<view class="amount-display">
<text class="currency">¥</text>
<text v-if="amount" class="amount-text">{{ amount }}</text>
<view v-else class="cursor"></view>
</view>
<text v-if="!amount" class="amount-hint">请输入付款金额</text>
<view v-if="amount && showBenefitTip" class="benefit-block">
<view class="benefit-tip">
{{ benefitTitle }}
<text class="benefit-value">¥ {{ accumulationAmount }}</text>
</view>
</view>
</view>
<view class="extra-zone">
<view class="remark-row">
<input
v-model="remark"
class="remark-input"
placeholder="付款备注(选填)"
placeholder-class="remark-placeholder"
:disabled="paying"
/>
</view>
<!-- #ifdef MP-ALIPAY -->
<button
v-if="showAlipayPhoneAuthBtn"
class="property-row property-row-btn"
hover-class="property-row-hover"
open-type="getAuthorize"
scope="phoneNumber"
:disabled="paying || bindingPhone"
@tap="handleAlipayPhoneAuthTap"
@getAuthorize="handleAlipayPhoneAuthorize"
@error="handleAlipayPhoneAuthError"
>
<text class="property-name li-single-line property-name-action">
{{ propertyRowText }}
</text>
<text v-if="showPropertyArrow" class="ri-arrow-right-s-line property-arrow"></text>
</button>
<view
v-else
class="property-row"
hover-class="property-row-hover"
@tap.stop="handlePropertyRow"
>
<text
class="property-name li-single-line"
:class="{
'property-name-action': isPropertyActionText && !alipayPropertyUnbound,
'property-name-tip': alipayPropertyUnbound
}"
>
{{ propertyRowText }}
</text>
<text v-if="showPropertyArrow" class="ri-arrow-right-s-line property-arrow"></text>
</view>
<!-- #endif -->
<!-- #ifndef MP-ALIPAY -->
<view
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 v-if="showPropertyArrow" class="ri-arrow-right-s-line property-arrow"></text>
</view>
<!-- #endif -->
</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 v-if="!IS_ALIPAY" 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">
<view v-for="key in numberKeys" :key="key" class="key" @tap="appendKey(key)">
{{ key }}
</view>
<view class="key key-zero" @tap="appendKey('0')">0</view>
<view class="key" @tap="appendKey('.')">.</view>
</view>
<view class="action-column">
<view class="key delete-key" @tap="deleteKey">
<text class="ri-delete-back-2-line"></text>
</view>
<view
class="confirm-key"
:class="{ disabled: !canPay, loading: paying }"
@tap="handlePay"
>
<block v-if="paying">支付中</block>
<block v-else-if="canPay">
<text>确认支付</text>
<text class="confirm-amount">¥{{ payableAmount }}</text>
</block>
<block v-else>确认支付</block>
</view>
</view>
</view>
<view class="safe-bottom"></view>
</view>
</block>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { bindMerchantByUrl, cancelTrade, changeMerchant, merchantListByUrl, supplierByUrl } from '@/api/cashier';
import type { MerchantListItem } from '@/api/cashier';
import { bindMobile, fetchOpenInfo, resolveLoginPlatform } from '@/api/auth';
import { postForm } from '@/utils/request';
import { DEFAULT_MERCHANT, APP_CONFIG, PROPERTY_APP } from '@/config';
import {
getPropertyBindingCacheKey,
getPropertyBindingCacheKeyByHash,
getSupplierCacheKey,
getSupplierCacheKeyByHash,
clearPendingMerchantUrl,
clearActiveMerchantContext,
resolveMerchantUrlFromEntry,
resolveMerchantUrlFromScanResult,
syncActiveMerchantContext
} from '@/utils/merchant-context';
import { authStorage, encryptedStorage } from '@/utils/storage';
import { formatPaymentError, isPaymentCancelled, requestPlatformPayment } from '@/utils/wxpay';
interface MerchantInfo {
id: string;
name: string;
background: string;
accumulationRate: number;
}
const defaultMerchant = (uni as any).$globalData?.defaultMerchant || DEFAULT_MERCHANT;
const PROPERTY_CACHE_TTL_SECONDS = 10 * 60;
const SUPPLIER_CACHE_TTL_SECONDS = 10 * 60;
const emptyStateBackground = '/static/cashier/bg-image.png';
const brandName = '卓享汇支付';
const IS_ALIPAY = resolveLoginPlatform() === 'mp-alipay';
const hasMerchantUrl = ref(false);
const merchant = ref<MerchantInfo>({ ...defaultMerchant });
const amount = ref('');
const remark = ref('');
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 bindingPhone = ref(false);
const alipayPropertyUnbound = ref(false);
const scanning = ref(false);
/** 已判定无效的进页码,避免 onShow 用同一 launch qrCode 反复弹窗 */
const rejectedMerchantUrl = ref('');
const numberKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
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(2);
});
const showBenefitTip = computed(() => Number(merchant.value.accumulationRate) > 0);
const benefitTitle = computed(() => `预计获得${scoreName.value || '物业公积金'}`);
/** 商户名越长字号越小,尽量单行展示「向 xxx 付款」 */
const merchantNameFontSize = computed(() => {
const len = String(merchant.value.name || '').trim().length;
if (len <= 6) return '34rpx';
if (len <= 10) return '30rpx';
if (len <= 14) return '26rpx';
if (len <= 18) return '24rpx';
return '22rpx';
});
const propertyRowText = computed(() => {
if (propertyName.value) return propertyName.value;
if (propertyList.value.length) return '选择物业';
if (IS_ALIPAY) {
if (alipayPropertyUnbound.value) {
return `未绑定,小程序搜「${PROPERTY_APP.WECHAT_NAME}」绑房源`;
}
return '绑定手机号查看房源';
}
return '业主去绑定';
});
const isPropertyActionText = computed(() => !propertyName.value);
const showAlipayPhoneAuthBtn = computed(
() => IS_ALIPAY && !propertyName.value && !propertyList.value.length && !alipayPropertyUnbound.value
);
const showPropertyArrow = computed(() => true);
const propertySheetTitle = computed(() => (propertyName.value ? '切换物业' : '选择物业'));
interface PropertyBindingCache {
merchant_id?: string;
merchant_name?: string;
score_name?: string;
fee_rate?: string | number;
}
interface SupplierCache {
supplier_id?: string | number;
supplier_name?: string;
logo?: string;
fee_rate?: string | number;
}
const parseFeeRate = (feeRate: unknown) => {
if (feeRate === undefined || feeRate === null || feeRate === '') return 0;
const rate = Number(feeRate);
if (!Number.isFinite(rate) || rate <= 0) return 0;
return rate / 100;
};
const hasFeeRate = (feeRate: unknown) => feeRate !== undefined && feeRate !== null && feeRate !== '';
const resolveFeeRateFromPayload = (payload: Record<string, any> = {}) => {
const supplierData = payload.supplier || {};
return supplierData.fee_rate != null ? supplierData.fee_rate : payload.fee_rate;
};
const resolvePropertyFromPayload = (payload: Record<string, any> = {}) => {
const merchantData = payload.merchant || payload;
return {
merchant_id: merchantData.merchant_id,
merchant_name: merchantData.merchant_name,
score_name: merchantData.config?.score_name
};
};
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;
}
if (property.score_name) {
scoreName.value = property.score_name;
}
};
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 {
supplier_id:
supplierData.supplier_id != null
? supplierData.supplier_id
: payload.supplier_id != null
? payload.supplier_id
: previous?.supplier_id,
supplier_name:
supplierData.supplier_name != null
? supplierData.supplier_name
: payload.supplier_name != null
? payload.supplier_name
: previous?.supplier_name,
logo: supplierData.logo != null ? supplierData.logo : payload.logo != null ? payload.logo : previous?.logo,
fee_rate: (() => {
const rate = resolveFeeRateFromPayload(payload);
return rate != null ? rate : previous?.fee_rate;
})()
};
};
const applyMerchantInfo = (data: any = {}) => {
const name = data.name || data.merchant_name || data.title;
const background = data.background || data.banner || data.cover || data.bg;
merchant.value = {
id: data.id || data.merchant_id || merchant.value.id,
name: name || merchant.value.name,
background: background || merchant.value.background,
accumulationRate:
data.accumulationRate !== undefined
? Number(data.accumulationRate)
: merchant.value.accumulationRate
};
};
const applyBindingInfo = (binding?: PropertyBindingCache | null) => {
if (!binding) return;
if (hasFeeRate(binding.fee_rate)) {
applyMerchantInfo({
accumulationRate: parseFeeRate(binding.fee_rate)
});
}
applyPropertyInfo(binding);
};
const applySupplierInfo = (supplier?: SupplierCache | null) => {
if (!supplier) return;
const updates: Record<string, unknown> = {
name: supplier.supplier_name,
background: supplier.logo
};
if (hasFeeRate(supplier.fee_rate)) {
updates.accumulationRate = parseFeeRate(supplier.fee_rate);
}
applyMerchantInfo(updates);
};
const syncBindingCacheForUrl = (merchantUrl: string) => {
const context = syncActiveMerchantContext(merchantUrl);
// Strong consistency: when switching merchant QR URL, clear previous merchant cache.
if (context.switched) {
encryptedStorage.remove(getPropertyBindingCacheKeyByHash(context.previousHash));
encryptedStorage.remove(getSupplierCacheKeyByHash(context.previousHash));
}
return getPropertyBindingCacheKey(merchantUrl);
};
const resetToEmptyMerchantState = () => {
hasMerchantUrl.value = false;
activeMerchantUrl.value = '';
merchant.value = { ...defaultMerchant };
amount.value = '';
remark.value = '';
propertyName.value = '';
propertyMerchantId.value = '';
propertyList.value = [];
propertySheetVisible.value = false;
alipayPropertyUnbound.value = false;
scoreName.value = '物业公积金';
clearPendingMerchantUrl();
clearActiveMerchantContext();
};
const promptScan = (message?: string) => {
uni.showModal({
title: '提示',
content: message || '未识别到商家码,请扫描商家收款码进入收银台',
confirmText: '去扫码',
showCancel: false,
success: (res) => {
if (res.confirm) {
handleScan();
}
}
});
};
const failInvalidMerchant = (merchantUrl: string, message?: string) => {
rejectedMerchantUrl.value = merchantUrl;
resetToEmptyMerchantState();
promptScan(message);
};
const fetchSupplierByUrl = async (merchantUrl: string) => {
const response = await supplierByUrl(merchantUrl);
if (Number(response?.code) !== 200 || !response?.data) {
return {
ok: false as const,
msg: response?.msg || '未识别到商家码,请扫描商家收款码进入收银台'
};
}
const payload = response.data || {};
const cacheKey = getSupplierCacheKey(merchantUrl);
const cached = encryptedStorage.get<SupplierCache>(cacheKey);
const latestSupplier = buildSupplierCache(payload, cached);
if (!latestSupplier.supplier_name && !latestSupplier.supplier_id) {
return {
ok: false as const,
msg: response?.msg || '未识别到商家码,请扫描商家收款码进入收银台'
};
}
applyPropertyInfo(resolvePropertyFromPayload(payload));
applySupplierInfo(latestSupplier);
encryptedStorage.set(cacheKey, latestSupplier, SUPPLIER_CACHE_TTL_SECONDS);
return { ok: true as const, supplier: latestSupplier };
};
const initWithMerchantUrl = async (
merchantUrl: string,
options: { force?: boolean } = {}
) => {
if (!options.force && rejectedMerchantUrl.value === merchantUrl) {
resetToEmptyMerchantState();
return;
}
try {
const [supplierResult, bindResponse] = await Promise.all([
fetchSupplierByUrl(merchantUrl),
bindMerchantByUrl(merchantUrl).catch(() => null)
]);
if (!supplierResult.ok) {
failInvalidMerchant(merchantUrl, supplierResult.msg);
return;
}
const bindCode = Number(bindResponse?.code);
const bindMsg = String(bindResponse?.msg || '');
if (bindResponse && bindCode !== 200 && /重新扫码/.test(bindMsg)) {
failInvalidMerchant(merchantUrl, bindMsg);
return;
}
rejectedMerchantUrl.value = '';
hasMerchantUrl.value = true;
activeMerchantUrl.value = merchantUrl;
propertyName.value = '';
propertyMerchantId.value = '';
propertyList.value = [];
propertySheetVisible.value = false;
alipayPropertyUnbound.value = false;
scoreName.value = '物业公积金';
applySupplierInfo(supplierResult.supplier);
await loadPropertyBinding(merchantUrl);
} catch (error: any) {
failInvalidMerchant(
merchantUrl,
error?.message || '未识别到商家码,请扫描商家收款码进入收银台'
);
}
};
const handleScan = () => {
if (scanning.value) return;
scanning.value = true;
uni.scanCode({
onlyFromCamera: false,
scanType: ['qrCode'],
success: async (res) => {
const merchantUrl = resolveMerchantUrlFromScanResult(res.result || '');
if (!merchantUrl) {
uni.showToast({
title: '未识别到商家码',
icon: 'none'
});
promptScan();
return;
}
await initWithMerchantUrl(merchantUrl, { force: true });
},
fail: (err: any) => {
const errMsg = err?.errMsg || '';
if (errMsg.includes('cancel')) return;
uni.showToast({
title: '扫码失败,请重试',
icon: 'none'
});
},
complete: () => {
scanning.value = false;
}
});
};
const handleMissingMerchantUrl = () => {
promptScan();
};
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 = [];
}
};
/**
* 支付宝:无物业名/列表时,用 open.info 的 user_id 判断是否已绑过手机号。
* 有 user_id → 显示「未绑定,小程序搜「乐享云…」绑房源」;否则 → 「绑定手机号查看房源」。
*/
const resolveAlipayEmptyPropertyState = async (options: { assumeBoundIfEmpty?: boolean } = {}) => {
if (!IS_ALIPAY) return;
if (propertyName.value || propertyList.value.length) {
alipayPropertyUnbound.value = false;
return;
}
try {
const response = await fetchOpenInfo();
const userId = response?.data?.user?.user_id ?? response?.data?.open?.user_id;
if (userId != null && String(userId).trim() !== '') {
alipayPropertyUnbound.value = true;
return;
}
} catch (error) {
console.log('open.info empty property state', error);
}
alipayPropertyUnbound.value = !!options.assumeBoundIfEmpty;
};
const loadPropertyBinding = async (merchantUrl: string, options: { skipFallback?: boolean } = {}) => {
const cacheKey = syncBindingCacheForUrl(merchantUrl);
const cached = encryptedStorage.get<PropertyBindingCache>(cacheKey);
if (cached?.merchant_name) {
applyBindingInfo(cached);
}
try {
const response = await bindMerchantByUrl(merchantUrl);
if (Number(response?.code) !== 200 || !hasBindingPayload(response?.data)) {
if (!options.skipFallback) {
await resolvePropertyListFallback(merchantUrl);
}
await resolveAlipayEmptyPropertyState();
return;
}
const latestBinding = buildBindingCache(response.data || {});
applyBindingInfo(latestBinding);
encryptedStorage.set(cacheKey, latestBinding, PROPERTY_CACHE_TTL_SECONDS);
await resolveAlipayEmptyPropertyState();
} catch (error) {
console.log('bind merchant fallback cache', error);
if (!options.skipFallback) {
await resolvePropertyListFallback(merchantUrl);
}
await resolveAlipayEmptyPropertyState();
}
};
const loadSupplier = async (merchantUrl: string) => {
const cacheKey = getSupplierCacheKey(merchantUrl);
const cached = encryptedStorage.get<SupplierCache>(cacheKey);
if (cached) {
applySupplierInfo(cached);
}
try {
const result = await fetchSupplierByUrl(merchantUrl);
if (!result.ok) return false;
return true;
} catch (error) {
console.log('supplier fallback cache', error);
return false;
}
};
const appendKey = (key: string) => {
if (paying.value) return;
if (key === '.') {
if (!amount.value) {
amount.value = '0.';
return;
}
if (amount.value.includes('.')) return;
amount.value += '.';
return;
}
const nextValue = amount.value === '0' ? key : `${amount.value}${key}`;
const decimalPart = nextValue.split('.')[1];
if (decimalPart && decimalPart.length > 2) return;
if (Number(nextValue) > 999999.99) return;
amount.value = nextValue;
};
const deleteKey = () => {
if (paying.value) return;
amount.value = amount.value.slice(0, -1);
};
const showAlipayUnboundTip = () => {
uni.showModal({
title: '暂未绑定房源',
content: `请搜索并进入「${PROPERTY_APP.WECHAT_NAME}」小程序,完成房源绑定后消费即可获得物业基金。`,
showCancel: false,
confirmText: '知道了'
});
};
const refreshPropertyAfterPhoneBind = async () => {
if (!activeMerchantUrl.value) return;
alipayPropertyUnbound.value = false;
clearPropertyBinding();
propertyList.value = [];
encryptedStorage.remove(getPropertyBindingCacheKey(activeMerchantUrl.value));
await loadPropertyBinding(activeMerchantUrl.value);
// 刚绑完手机号仍无房源时兜底按已绑定处理open.info 偶发未带上 user_id
if (!propertyName.value && !propertyList.value.length && !alipayPropertyUnbound.value) {
await resolveAlipayEmptyPropertyState({ assumeBoundIfEmpty: true });
}
if (propertyName.value || propertyList.value.length) {
uni.showToast({
title: '已匹配到物业信息',
icon: 'none'
});
}
};
const tryParseJson = (value: any) => {
if (typeof value !== 'string') return value;
const text = value.trim();
if (!text || (text[0] !== '{' && text[0] !== '[')) return value;
try {
return JSON.parse(text);
} catch {
return value;
}
};
/** 调试日志用:把嵌套 JSON 字符串展开成可展开对象 */
const toLogObject = (value: any, depth = 0): any => {
if (value == null || depth > 4) return value;
const parsed = tryParseJson(value);
if (Array.isArray(parsed)) {
return parsed.map((item) => toLogObject(item, depth + 1));
}
if (parsed && typeof parsed === 'object') {
const result: Record<string, any> = {};
Object.keys(parsed).forEach((key) => {
result[key] = toLogObject(parsed[key], depth + 1);
});
return result;
}
return parsed;
};
const logPhoneAuth = (label: string, payload?: any) => {
console.log(`[收银台][手机号授权] ${label}`, payload === undefined ? undefined : toLogObject(payload));
};
/** 取 getPhoneNumber 密文response.response */
const extractAlipayEncryptedData = (phoneRes: any) => {
const parsed = tryParseJson(phoneRes?.response);
if (parsed && typeof parsed === 'object') {
const encrypted = parsed.response;
return typeof encrypted === 'string' ? encrypted.trim() : '';
}
return typeof phoneRes?.response === 'string' ? phoneRes.response.trim() : '';
};
const requestAlipayPhoneNumber = () =>
new Promise<any>((resolve, reject) => {
// #ifdef MP-ALIPAY
if (typeof my !== 'undefined' && typeof my.getPhoneNumber === 'function') {
my.getPhoneNumber({
success: resolve,
fail: reject
});
return;
}
// #endif
uni.getPhoneNumber({
success: resolve,
fail: reject
});
});
const handleAlipayPhoneAuthTap = () => {
logPhoneAuth('按钮点击');
};
const handleAlipayPhoneAuthError = (event?: any) => {
logPhoneAuth('error 回调', event);
};
const handleAlipayPhoneAuthorize = async (event?: any) => {
logPhoneAuth('getAuthorize 回调', event);
if (paying.value || bindingPhone.value) return;
bindingPhone.value = true;
try {
const phoneRes = await requestAlipayPhoneNumber();
logPhoneAuth('getPhoneNumber 成功', phoneRes);
const encryptedData = extractAlipayEncryptedData(phoneRes);
logPhoneAuth('解析 encryptedData', {
encryptedData,
response: tryParseJson(phoneRes?.response)
});
if (!encryptedData) {
throw new Error('手机号授权失败');
}
const response = await bindMobile(encryptedData);
logPhoneAuth('bind.mobile 响应', response);
if (Number(response?.code) !== 200) {
throw new Error(response?.msg || '手机号绑定失败');
}
const nextToken = String(response?.data?.token || '').trim();
if (!nextToken) {
throw new Error('绑定成功但未返回 token');
}
authStorage.setToken(nextToken, Number(response?.data?.expires_in || 0));
logPhoneAuth('已替换登录 token', { token: nextToken });
await refreshPropertyAfterPhoneBind();
} catch (error: any) {
logPhoneAuth('授权失败', error);
const errMsg = error?.errMsg || error?.message || '';
if (errMsg.includes('cancel') || errMsg.includes('拒绝')) return;
uni.showToast({
title: errMsg || '手机号授权失败',
icon: 'none'
});
} finally {
bindingPhone.value = false;
}
};
const goBindProperty = () => {
if (IS_ALIPAY) {
showAlipayUnboundTip();
return;
}
if (paying.value) return;
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) {
if (IS_ALIPAY) {
if (alipayPropertyUnbound.value) {
showAlipayUnboundTip();
}
return;
}
goBindProperty();
return;
}
propertyList.value = list;
propertySheetVisible.value = true;
};
const handlePropertyRow = async () => {
if (paying.value || switchingProperty.value || bindingPhone.value) return;
if (!activeMerchantUrl.value) return;
if (IS_ALIPAY && alipayPropertyUnbound.value) {
showAlipayUnboundTip();
return;
}
if (IS_ALIPAY && showAlipayPhoneAuthBtn.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 != null ? 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) => {
const query = [
`amount=${payableAmount.value}`,
`accumulation=${accumulationAmount.value}`,
`merchant_name=${encodeURIComponent(merchant.value.name)}`,
`merchant_url=${encodeURIComponent(activeMerchantUrl.value)}`,
`trade_no=${encodeURIComponent(tradeNo)}`,
`score_name=${encodeURIComponent(scoreName.value)}`
].join('&');
return `/pages/cashier/result?${query}`;
};
const handlePay = async () => {
if (!canPay.value) return;
paying.value = true;
try {
if (!activeMerchantUrl.value) {
throw new Error('缺少支付参数:商户码 url');
}
// 扫商户码支付:调用后端 jspay按当前端传入 platform
const payload = {
amount: payableAmount.value,
platform: resolveLoginPlatform(),
remark: remark.value.trim(),
url: activeMerchantUrl.value
};
const payRes = await postForm<any>('user/v1/jspay', payload);
if (Number(payRes?.code) !== 200) {
throw new Error(payRes?.msg || '获取支付参数失败');
}
const payData = payRes?.data || {};
const tradeNo = payData.trade_no || payData.tradeNo || '';
if (!tradeNo) {
throw new Error('缺少交易单号');
}
try {
await requestPlatformPayment(payData);
} catch (error) {
if (isPaymentCancelled(error)) {
await cancelTrade(tradeNo).catch(() => {});
throw new Error('已取消支付');
}
throw error;
}
uni.redirectTo({
url: buildResultUrl(tradeNo)
});
} catch (error: any) {
uni.showToast({
title: formatPaymentError(error),
icon: 'none'
});
} finally {
paying.value = false;
}
};
onLoad((options: any) => {
const merchantUrl = resolveMerchantUrlFromEntry(options || {});
if (!merchantUrl) {
handleMissingMerchantUrl();
return;
}
clearPendingMerchantUrl();
initWithMerchantUrl(merchantUrl);
});
onShow(() => {
if (!hasMerchantUrl.value || !activeMerchantUrl.value) {
const merchantUrl = resolveMerchantUrlFromEntry({});
if (merchantUrl && merchantUrl !== rejectedMerchantUrl.value) {
clearPendingMerchantUrl();
initWithMerchantUrl(merchantUrl);
return;
}
}
if (!hasMerchantUrl.value || !activeMerchantUrl.value) return;
loadPropertyBinding(activeMerchantUrl.value);
loadSupplier(activeMerchantUrl.value);
});
</script>
<style lang="scss" scoped>
.cashier-page {
display: flex;
flex-direction: column;
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 {
position: relative;
min-height: 100vh;
overflow: hidden;
}
.empty-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.empty-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(15, 35, 95, 0.18) 0%,
rgba(15, 35, 95, 0.42) 42%,
rgba(255, 255, 255, 0.92) 78%,
#ffffff 100%
);
}
.empty-inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100vh;
padding: 160rpx 48rpx calc(72rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.empty-brand-block {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.empty-brand {
font-family: SourceHanSansCN, -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
color: #ffffff;
font-size: 60rpx;
font-weight: 600;
line-height: 1.25;
letter-spacing: 2rpx;
text-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.2);
}
.empty-subtitle {
margin-top: 20rpx;
color: rgba(255, 255, 255, 0.92);
font-size: 28rpx;
line-height: 1.5;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.18);
}
.empty-action {
padding-top: 48rpx;
}
.empty-scan-button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 104rpx;
padding: 0;
border-radius: 16rpx;
background: #1f5bd8;
color: #ffffff;
font-size: 32rpx;
font-weight: 800;
line-height: 1;
box-shadow: 0 12rpx 32rpx rgba(31, 91, 216, 0.28);
}
.empty-scan-button::after {
border: none;
}
.empty-scan-button-icon {
margin-right: 12rpx;
font-size: 40rpx;
line-height: 1;
}
.empty-scan-button-hover {
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;
}
.pay-header {
position: relative;
flex-shrink: 0;
height: 360rpx;
background-size: cover;
background-position: center center;
overflow: hidden;
}
.pay-header-mask {
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.14) 0%, rgba(0, 0, 0, 0) 42%);
}
.pay-header-scrim {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
height: 200rpx;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.42) 48%,
rgba(0, 0, 0, 0.62) 78%,
rgba(255, 255, 255, 1) 100%
);
}
.pay-body {
flex: 1;
margin-top: -24rpx;
padding-bottom: 488rpx;
background: #FFFFFF;
}
.pay-main {
padding: 0 48rpx;
}
.merchant-label {
position: absolute;
left: 0;
right: 0;
bottom: 52rpx;
z-index: 2;
display: flex;
justify-content: center;
}
.merchant-label-inner {
display: flex;
align-items: baseline;
flex-wrap: nowrap;
max-width: 88%;
padding: 14rpx 32rpx;
border-radius: 999rpx;
background: rgba(0, 0, 0, 0.42);
}
.muted {
flex-shrink: 0;
color: rgba(255, 255, 255, 0.9);
font-size: 28rpx;
}
.merchant-name {
min-width: 0;
margin: 0 8rpx;
color: #FFFFFF;
font-size: 34rpx;
font-weight: 800;
line-height: 1.3;
text-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.35);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.amount-zone {
margin-top: 56rpx;
}
.amount-display {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 100rpx;
}
.currency {
flex-shrink: 0;
margin-right: 16rpx;
color: #050505;
font-size: 60rpx;
font-weight: 700;
line-height: 1;
}
.amount-text {
color: #050505;
font-size: 88rpx;
font-weight: 800;
line-height: 1;
letter-spacing: -1rpx;
}
.cursor {
width: 6rpx;
height: 80rpx;
border-radius: 8rpx;
background: #1F5BD8;
animation: blink 1.1s step-end infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
.amount-hint {
display: block;
margin-top: 16rpx;
padding-left: 4rpx;
color: #C2C7D0;
font-size: 26rpx;
}
.benefit-block {
margin-top: 20rpx;
padding-left: 4rpx;
}
.benefit-tip {
color: #8B929E;
font-size: 26rpx;
line-height: 1.5;
}
.benefit-value {
margin-left: 8rpx;
color: #4B6CB7;
font-weight: 600;
}
.extra-zone {
margin-top: 36rpx;
padding-top: 28rpx;
border-top: 1rpx solid #F0F2F5;
}
.remark-row {
padding: 0 4rpx;
}
.remark-input {
height: 44rpx;
color: #1F2937;
font-size: 28rpx;
text-align: left;
}
.remark-placeholder {
color: #C2C7D0;
}
.property-row {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
margin-top: 28rpx;
padding: 12rpx 4rpx;
}
.property-row--disabled {
opacity: 0.92;
}
.property-row-btn {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
margin: 0;
padding: 12rpx 4rpx;
border: none;
background: transparent;
line-height: 1;
text-align: left;
}
.property-row-btn::after {
border: none;
}
.property-row-hover {
opacity: 0.88;
}
.property-name {
flex: 0 1 auto;
max-width: calc(100% - 36rpx);
color: #374151;
font-size: 28rpx;
font-weight: 600;
}
.property-name-action {
color: #1F5BD8;
}
.property-name-tip {
color: #8B929E;
font-weight: 500;
text-decoration: underline;
text-underline-offset: 4rpx;
}
.property-arrow {
flex-shrink: 0;
margin-left: 4rpx;
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;
right: 0;
bottom: 0;
z-index: 10;
padding: 12rpx 12rpx 0;
background: #F7F7F8;
border-top: 1rpx solid #ECEEF2;
}
.keyboard.disabled {
opacity: 0.72;
}
.keypad {
display: flex;
gap: 12rpx;
}
.number-grid {
display: grid;
flex: 1;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
}
.action-column {
display: grid;
width: 168rpx;
grid-template-rows: 96rpx 1fr;
gap: 12rpx;
}
.key,
.confirm-key {
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
border-radius: 8rpx;
background: #FFFFFF;
color: #050505;
font-size: 44rpx;
font-weight: 600;
line-height: 1;
}
.key:active,
.confirm-key:active {
opacity: 0.78;
}
.key-zero {
grid-column: span 2;
}
.delete-key {
color: #747B86;
font-size: 40rpx;
}
.confirm-key {
flex-direction: column;
gap: 6rpx;
height: auto;
min-height: 204rpx;
padding: 0 12rpx;
background: #1F5BD8;
color: #FFFFFF;
font-size: 26rpx;
font-weight: 700;
text-align: center;
}
.confirm-amount {
font-size: 22rpx;
font-weight: 600;
opacity: 0.92;
}
.confirm-key.disabled,
.confirm-key.loading {
background: #C8D3EE;
}
.safe-bottom {
height: calc(16rpx + env(safe-area-inset-bottom));
}
</style>