cashier/pages/cashier/index.vue

825 lines
18 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">
<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>
</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">{{ 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-tip">
{{ benefitTitle }}
<text class="benefit-value">¥ {{ accumulationAmount }}</text>
</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>
<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>
</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 } from '@dcloudio/uni-app';
import { bindMerchantByUrl, cancelTrade, supplierByUrl } from '@/api/cashier';
import { postForm } from '@/utils/request';
import { DEFAULT_MERCHANT } from '@/config';
import {
getPropertyBindingCacheKey,
getPropertyBindingCacheKeyByHash,
getSupplierCacheKey,
getSupplierCacheKeyByHash,
resolveMerchantUrlFromOptions,
resolveMerchantUrlFromScanResult,
syncActiveMerchantContext
} from '@/utils/merchant-context';
import { encryptedStorage } from '@/utils/storage';
import { isPaymentCancelled, requestWxPayment } 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 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 scanning = ref(false);
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(3);
});
const showBenefitTip = computed(() => Number(merchant.value.accumulationRate) > 0);
const benefitTitle = computed(() => `预计获得${scoreName.value || '物业公积金'}`);
const parseGiveRate = (giveRate: unknown) => {
if (giveRate === undefined || giveRate === null || giveRate === '') return 0;
const rate = Number(giveRate);
if (!Number.isFinite(rate) || rate <= 0) return 0;
return rate / 100;
};
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
};
};
interface PropertyBindingCache {
merchant_id?: string;
merchant_name?: string;
score_name?: string;
give_rate?: string | number;
}
interface SupplierCache {
supplier_id?: string | number;
supplier_name?: string;
logo?: string;
}
const applyBindingInfo = (binding?: PropertyBindingCache | null) => {
if (!binding) return;
applyMerchantInfo({
merchant_id: binding.merchant_id,
merchant_name: binding.merchant_name,
accumulationRate: parseGiveRate(binding.give_rate)
});
if (binding.score_name) {
scoreName.value = binding.score_name;
}
};
const applySupplierInfo = (supplier?: SupplierCache | null) => {
if (!supplier) return;
applyMerchantInfo({
name: supplier.supplier_name,
background: supplier.logo
});
};
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 initWithMerchantUrl = async (merchantUrl: string) => {
hasMerchantUrl.value = true;
activeMerchantUrl.value = merchantUrl;
await loadPropertyBinding(merchantUrl);
await loadSupplier(merchantUrl);
};
const promptScan = () => {
uni.showModal({
title: '提示',
content: '未识别到商家码,请扫描商家收款码进入收银台',
confirmText: '去扫码',
showCancel: false,
success: (res) => {
if (res.confirm) {
handleScan();
}
}
});
};
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);
},
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 loadPropertyBinding = async (merchantUrl: string) => {
const cacheKey = syncBindingCacheForUrl(merchantUrl);
const cached = encryptedStorage.get<PropertyBindingCache>(cacheKey);
if (cached) {
applyBindingInfo(cached);
}
try {
const response = await bindMerchantByUrl(merchantUrl);
if (Number(response?.code) !== 200 || !response?.data) return;
const payload = response.data || {};
const merchantData = payload.merchant || payload;
const supplierData = payload.supplier || {};
const latestBinding: PropertyBindingCache = {
merchant_id: merchantData.merchant_id,
merchant_name: merchantData.merchant_name,
score_name: merchantData.config?.score_name,
give_rate: supplierData.give_rate ?? payload.give_rate
};
applyBindingInfo(latestBinding);
encryptedStorage.set(cacheKey, latestBinding, PROPERTY_CACHE_TTL_SECONDS);
} catch (error) {
console.log('bind merchant fallback cache', error);
}
};
const loadSupplier = async (merchantUrl: string) => {
const cacheKey = getSupplierCacheKey(merchantUrl);
const cached = encryptedStorage.get<SupplierCache>(cacheKey);
if (cached) {
applySupplierInfo(cached);
}
try {
const response = await supplierByUrl(merchantUrl);
if (Number(response?.code) !== 200 || !response?.data) return;
const latestSupplier: SupplierCache = {
supplier_id: response.data.supplier_id,
supplier_name: response.data.supplier_name,
logo: response.data.logo
};
applySupplierInfo(latestSupplier);
encryptedStorage.set(cacheKey, latestSupplier, SUPPLIER_CACHE_TTL_SECONDS);
} catch (error) {
console.log('supplier fallback cache', error);
}
};
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 handleBindHouse = () => {
if (paying.value) return;
uni.navigateTo({
url: '/pages/house/bind'
});
};
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拿到微信 requestPayment 所需签名参数
const payload = {
amount: payableAmount.value,
platform: 'mp-weixin',
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 wechat = payRes?.data || {};
const params: any = {
timeStamp: wechat.timeStamp || wechat.time_stamp || wechat.timestamp || '',
nonceStr: wechat.nonceStr || wechat.nonce_str || '',
package: wechat.package || wechat.pack || '',
signType: wechat.signType || wechat.sign_type || '',
paySign: wechat.paySign || wechat.pay_sign || ''
};
if (!params.paySign) {
throw new Error('支付参数不完整');
}
const tradeNo = wechat.trade_no || wechat.tradeNo || '';
if (!tradeNo) {
throw new Error('缺少交易单号');
}
try {
await requestWxPayment(params);
} 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: error?.message || '支付失败',
icon: 'none'
});
} finally {
paying.value = false;
}
};
onLoad((options: any) => {
const merchantUrl = resolveMerchantUrlFromOptions(options || {});
if (!merchantUrl) {
handleMissingMerchantUrl();
return;
}
initWithMerchantUrl(merchantUrl);
});
</script>
<style lang="scss" scoped>
.cashier-page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #FFFFFF;
color: #111827;
}
.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;
}
.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;
max-width: 88%;
padding: 14rpx 32rpx;
border-radius: 999rpx;
background: rgba(0, 0, 0, 0.42);
}
.muted {
color: rgba(255, 255, 255, 0.9);
}
.merchant-name {
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);
}
.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-tip {
margin-top: 20rpx;
padding-left: 4rpx;
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;
}
.bind-row {
display: flex;
align-items: center;
justify-content: flex-start;
margin-top: 28rpx;
padding: 8rpx 0;
}
.bind-text {
color: #8B929E;
font-size: 26rpx;
}
.bind-hint {
margin-left: 10rpx;
color: #C2C7D0;
font-size: 24rpx;
}
.bind-arrow {
margin-left: 4rpx;
color: #D1D5DB;
font-size: 30rpx;
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>