Files
QSY-lvjuxcx/pkgService/components/CurrentApplicationBillPanel.vue
liangjian 445bd850f4 feat: 新增工单中心、房间巡查、失物招领等业务模块
- 新增工单中心(ticket_center)与工单工作台(ticket_workbench)页面
- 新增房间巡查(room_inspection)与失物招领发布(lost_found_publish)页面
- 预约看样流程新增房型/楼栋/楼层选择器组件
- 新增工具模块: booking-entry、privacy、reservation-flow
- api/index.js 补充楼层列表、看样提交等接口
- locales/messages.js 补充多语言文案
- manifest.json 开启微信小程序隐私检查
- 完善登录、注册、申请详情、账单等相关页面交互
2026-07-24 09:35:46 +08:00

2198 lines
74 KiB
Vue

<template>
<view class="bill-panel">
<view class="hero-panel tn-margin-bottom-lg">
<view class="hero-title tn-margin-top">{{ t('heroTitle') }}</view>
<view class="hero-desc tn-margin-top-sm">{{ heroDesc }}</view>
<view v-if="currentReferenceTypeNo" class="source-tag tn-margin-top-sm">{{ t('applicationNoPrefix') }}{{ currentReferenceTypeNo }}</view>
<view class="hero-summary tn-margin-top-lg">
<view class="summary-card">
<view class="summary-label">{{ t('pendingCountLabel') }}</view>
<view class="summary-value tn-margin-top-sm">{{ billList.length }}</view>
</view>
<view class="summary-card">
<view class="summary-label">{{ t('pendingAmountLabel') }}</view>
<view class="summary-value tn-margin-top-sm">¥{{ formatMoney(totalRemainingAmount) }}</view>
</view>
</view>
</view>
<view v-if="loading || resolvingReference" class="empty-panel tn-padding-lg">
<view class="empty-title">{{ t('loadingTitle') }}</view>
<view class="empty-desc tn-margin-top-sm">{{ t('loadingDesc') }}</view>
</view>
<view v-else-if="billList.length">
<view
v-for="(item, index) in billList"
:key="getBillKey(item)"
class="bill-card tn-margin-bottom-lg"
>
<view class="card-head tn-flex tn-flex-row-between tn-flex-col-center">
<view class="card-title-wrap">
<view class="card-title">{{ getReferenceTypeText(item) }}</view>
<view class="card-code tn-margin-top-xs">{{ getBillNo(item) }}</view>
</view>
<view :class="['status-badge', 'status-' + resolvePaymentStatusType(getBillPaymentStatus(item))]">
{{ getBillPaymentStatusText(item) }}
</view>
</view>
<view class="card-body tn-margin-top">
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('relatedBusinessNoLabel') }}</text>
<text class="info-value">{{ getBillReferenceTypeNo(item) || '--' }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('totalChargeLabel') }}</text>
<text class="info-value">¥{{ formatMoney(getBillTotalCharge(item)) }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('paidAmountLabel') }}</text>
<text class="info-value">¥{{ formatMoney(getBillPaidAmount(item)) }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('remainingAmountLabel') }}</text>
<text class="info-value info-value--highlight">¥{{ formatMoney(getBillRemainingAmount(item)) }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('refundStatusLabel') }}</text>
<text class="info-value">{{ getRefundStatusText(getBillRefundStatus(item)) }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('createTimeLabel') }}</text>
<text class="info-value">{{ formatDateTime(getBillCreateTime(item)) }}</text>
</view>
</view>
<view v-if="getBillRemark(item) || getBillResult(item)" class="remark-panel tn-margin-top">
<view v-if="getBillRemark(item)" class="remark-line">
<text class="remark-label">{{ t('remarkLabel') }}</text>
<text class="remark-text">{{ getBillRemark(item) }}</text>
</view>
<view v-if="getBillResult(item)" class="remark-line tn-margin-top-sm">
<text class="remark-label">{{ t('resultLabel') }}</text>
<text class="remark-text">{{ getBillResult(item) }}</text>
</view>
</view>
<view v-if="getPendingPaymentOrder(item)" class="pending-payment-panel tn-margin-top">
<view class="pending-payment-title">{{ t('pendingPaymentTitle') }}</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('pendingPaymentOrderNoLabel') }}</text>
<text class="info-value">{{ getPendingPaymentOrderNo(item) }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('pendingPaymentAmountLabel') }}</text>
<text class="info-value info-value--highlight">¥{{ formatMoney(getPendingPaymentAmount(item)) }}</text>
</view>
<view v-if="getPendingPaymentExpireText(item)" class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('pendingPaymentExpireLabel') }}</text>
<text class="info-value" :class="{ 'info-value--expired': isPendingPaymentExpired(item) }">
{{ getPendingPaymentExpireText(item) }}
</text>
</view>
<view v-if="getPendingPaymentDescription(item)" class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('pendingPaymentDescLabel') }}</text>
<text class="info-value">{{ getPendingPaymentDescription(item) }}</text>
</view>
<view v-if="getPendingPaymentChannel(item)" class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('pendingPaymentChannelLabel') }}</text>
<text class="info-value">{{ getPendingPaymentChannel(item) }}</text>
</view>
</view>
<view class="card-footer tn-margin-top-lg">
<view
v-if="canCancelOrder(item)"
class="cancel-btn"
:data-bill-index="index"
:class="{ 'cancel-btn--loading': cancelingBillNo === getBillNo(item) }"
@tap.stop="handleCancelOrder"
>
<text class="tn-text-bold">{{ getCancelButtonText(item) }}</text>
</view>
<view
class="pay-btn"
:data-bill-index="index"
:class="{ 'pay-btn--loading': payingBillNo === getBillNo(item) }"
@tap.stop="handleWechatPay"
>
<text class="tn-text-bold">{{ getPayButtonText(item) }}</text>
</view>
</view>
</view>
</view>
<view v-else class="empty-panel tn-padding-lg">
<view class="empty-title">{{ currentReferenceTypeNo ? t('emptyPendingTitle') : t('emptyApplicationTitle') }}</view>
<view class="empty-desc tn-margin-top-sm">{{ currentReferenceTypeNo ? t('emptyPendingDesc') : t('emptyApplicationDesc') }}</view>
<view v-if="!currentReferenceTypeNo" class="empty-action tn-margin-top-lg" @tap="goToMyApplication">
<text class="tn-text-bold">{{ t('goToMyApplication') }}</text>
</view>
</view>
<tn-popup v-model="showPaymentNotice" mode="bottom" length="72%" :borderRadius="32" :closeBtn="true" @close="handlePaymentNoticeClose">
<view class="payment-notice-popup">
<view class="payment-notice-popup__header">
<view class="payment-notice-popup__title">{{ paymentNoticeContext.title }}</view>
</view>
<scroll-view scroll-y class="payment-notice-popup__body">
<view class="payment-notice-popup__summary">
<view class="payment-notice-popup__summary-row">
<text class="payment-notice-popup__summary-label">{{ paymentNoticeContext.billLabel }}</text>
<text class="payment-notice-popup__summary-value">{{ paymentNoticeContext.billNo }}</text>
</view>
<view class="payment-notice-popup__summary-row">
<text class="payment-notice-popup__summary-label">{{ paymentNoticeContext.itemLabel }}</text>
<text class="payment-notice-popup__summary-value">{{ paymentNoticeContext.itemTitle }}</text>
</view>
<view class="payment-notice-popup__summary-row">
<text class="payment-notice-popup__summary-label">{{ paymentNoticeContext.amountLabel }}</text>
<text class="payment-notice-popup__summary-value payment-notice-popup__summary-value--price">{{ paymentNoticeContext.amountText }}</text>
</view>
</view>
<view class="payment-notice-popup__content">
<view
v-for="(line, index) in paymentNoticeContext.lines"
:key="`payment-notice-line-${index}`"
class="payment-notice-popup__line"
>
{{ index + 1 }}. {{ line }}
</view>
</view>
</scroll-view>
<view class="payment-notice-popup__footer">
<view class="payment-notice-popup__action payment-notice-popup__action--ghost" @tap="closePaymentNotice(false)">
<text class="tn-text-bold">{{ paymentNoticeContext.cancelText }}</text>
</view>
<view class="payment-notice-popup__action payment-notice-popup__action--primary" @tap="closePaymentNotice(true)">
<text class="tn-text-bold">{{ paymentNoticeContext.confirmText }}</text>
</view>
</view>
</view>
</tn-popup>
<view v-if="showPaymentFeedbackPanel" class="payment-waiting-mask">
<view :class="['payment-waiting-panel', 'payment-waiting-panel--' + paymentStageVisualType]">
<view class="payment-waiting-glow payment-waiting-glow--one"></view>
<view class="payment-waiting-glow payment-waiting-glow--two"></view>
<view :class="['payment-waiting-badge', 'payment-waiting-badge--' + paymentStageVisualType]">
<text :class="[paymentBadgeIconClass, 'payment-waiting-badge__icon']"></text>
<text>{{ paymentStageLabel }}</text>
</view>
<view class="payment-waiting-icon-wrap">
<view v-if="isPaymentWaitingStage" class="payment-waiting-icon-ring"></view>
<view :class="['payment-waiting-icon-core', 'payment-waiting-icon-core--' + paymentStageVisualType]">
<text :class="[paymentFeedbackIconClass, 'payment-waiting-icon', { 'payment-waiting-icon--spin': isPaymentWaitingStage }]"></text>
</view>
</view>
<view class="payment-waiting-title">{{ paymentWaitingTitle }}</view>
<view class="payment-waiting-desc">{{ paymentWaitingDesc }}</view>
<view v-if="isPaymentWaitingStage" class="payment-progress-card">
<view class="payment-progress-head tn-flex tn-flex-row-between tn-flex-col-center">
<text class="payment-progress-label">{{ t('paymentProgressLabel') }}</text>
<text class="payment-progress-value">{{ paymentProgressText }}</text>
</view>
<view class="payment-progress-track">
<view class="payment-progress-bar" :style="{ width: paymentProgressPercent + '%' }"></view>
</view>
<view class="payment-progress-note">{{ paymentStatusHintText }}</view>
</view>
<view class="payment-summary-grid">
<view class="payment-summary-item">
<view class="payment-summary-label">{{ t('billNoLabel') }}</view>
<view class="payment-summary-value payment-summary-value--mono">{{ paymentWaitingBillNo || '--' }}</view>
</view>
<view class="payment-summary-item">
<view class="payment-summary-label">{{ t('currentPaymentLabel') }}</view>
<view class="payment-summary-value payment-summary-value--price">¥{{ paymentWaitingAmountText }}</view>
</view>
</view>
<view class="payment-tips-card">
<view class="payment-tips-title">{{ paymentTipsTitle }}</view>
<view v-for="(tip, index) in paymentTips" :key="`${paymentStage}-${index}`" class="payment-tip-line">{{ tip }}</view>
</view>
<view v-if="showPaymentActionButton" class="payment-action-row">
<view class="payment-action-btn" @tap="handlePaymentResultAction">
<text class="tn-text-bold">{{ paymentActionText }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { accountQueryBills, applicationFormMePage, paymentBillPay, paymentPayingBills, paymentStatusByBillNo, userDetailCancel } from '@/api/index.js'
import {
ACCOUNT_BILL_PAYMENT_STATUS,
ACCOUNT_BILL_STATUS,
getAccountBillPaymentStatusLabel,
getAccountBillRefundStatusLabel,
getAccountReferenceTypeLabel
} from '@/utils/enum-store.js'
import { getActiveApplicationCode, getMyApplicationList, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
const VIEWING_APPLICATION_PATTERN = /预约看房申请|看房时间:|意向房型:|意向区域:|viewing request|viewing time|preferred room type|preferred area/i
function isViewingApplicationRecord(item = {}) {
if (!item || typeof item !== 'object') {
return false
}
const typeText = [
item.businessType,
item.applicationType,
item.applicationTypeDesc,
item.formType,
item.bizType,
item.type,
item.typeDesc
]
.filter(value => value !== null && typeof value !== 'undefined' && String(value).trim())
.join(' ')
if (/viewing|看房|带看/i.test(typeText)) {
return true
}
if ([item.appointmentViewingTime, item.viewingTime, item.bookViewingTime].some(value => String(value || '').trim())) {
return true
}
const remark = item && item.remark ? String(item.remark) : ''
return VIEWING_APPLICATION_PATTERN.test(remark)
}
function normalizeBillList(response) {
const payload = response && response.data ? response.data : response
if (Array.isArray(payload)) {
return payload.filter(item => item && typeof item === 'object')
}
if (payload && Array.isArray(payload.data)) {
return payload.data.filter(item => item && typeof item === 'object')
}
return []
}
function normalizeApplicationList(list = []) {
if (!Array.isArray(list)) {
return []
}
return list
.filter(item => item && typeof item === 'object')
.map((item) => {
return {
...item,
isViewingApplication: isViewingApplicationRecord(item)
}
})
}
function parseExpireTimeToTimestamp(value) {
if (value === null || typeof value === 'undefined' || value === '') {
return 0
}
if (typeof value === 'number' && Number.isFinite(value)) {
return value > 1e12 ? value : value * 1000
}
const text = String(value).trim()
if (!text) {
return 0
}
if (/^\d+$/.test(text)) {
const numericValue = Number(text)
if (!Number.isFinite(numericValue)) {
return 0
}
return numericValue > 1e12 ? numericValue : numericValue * 1000
}
const normalizedText = text.includes('T') ? text : text.replace(/-/g, '/')
const timestamp = Date.parse(normalizedText)
return Number.isFinite(timestamp) ? timestamp : 0
}
function padCountdownUnit(value) {
return String(Math.max(0, Number(value) || 0)).padStart(2, '0')
}
const PAYMENT_QUERY_STATUS = {
PENDING: 'pending',
SUCCESS: 'success',
FAILED: 'failed'
}
const PAYMENT_ORDER_STATUS = {
SUCCESS: 10,
FAILED: 99
}
const PAYMENT_QUERY_MAX_ATTEMPTS = 8
const PAYMENT_QUERY_INTERVAL = 1800
export default {
name: 'CurrentApplicationBillPanel',
props: {
referenceTypeNo: {
type: String,
default: ''
},
autoResolveReference: {
type: Boolean,
default: true
}
},
data() {
return {
loading: false,
billList: [],
pendingPaymentOrderMap: {},
payingBillNo: '',
cancelingBillNo: '',
currentReferenceTypeNo: '',
countdownNow: Date.now(),
resolvingReference: false,
paymentPollingBillNo: '',
paymentPollingAttempt: 0,
paymentStage: '',
paymentStatusHint: '',
paymentSession: null,
showPaymentNotice: false,
paymentNoticeResolver: null,
paymentNoticeContext: {
title: '',
billLabel: '',
billNo: '--',
itemLabel: '',
itemTitle: '',
amountLabel: '',
amountText: '',
lines: [],
confirmText: '',
cancelText: ''
},
i18n: {
zh: {
heroTitle: '待支付账单',
applicationNoPrefix: '申请单号:',
pendingCountLabel: '待支付数量',
pendingAmountLabel: '待支付总额',
loadingTitle: '账单加载中',
loadingDesc: '正在同步您的待支付账单,请稍候。',
relatedBusinessNoLabel: '关联业务编号',
totalChargeLabel: '应收合计',
paidAmountLabel: '已付金额',
remainingAmountLabel: '待付金额',
refundStatusLabel: '退款状态',
createTimeLabel: '创建时间',
remarkLabel: '备注',
resultLabel: '处理结果',
pendingPaymentTitle: '未完成支付订单',
pendingPaymentOrderNoLabel: '支付单号',
pendingPaymentAmountLabel: '订单金额',
pendingPaymentDescLabel: '支付说明',
pendingPaymentChannelLabel: '支付渠道',
pendingPaymentExpireLabel: '剩余支付时间',
pendingPaymentExpired: '已失效',
emptyPendingTitle: '暂无待支付账单',
emptyApplicationTitle: '暂无可查询申请单',
emptyPendingDesc: '当前申请单下没有查询到待支付账单,后续有新账单时会在这里展示。',
emptyApplicationDesc: '当前未找到可关联的申请单,请先前往“我的申请”选择具体申请后查看账单。',
goToMyApplication: '前往我的申请',
paymentProgressLabel: '结果确认进度',
billNoLabel: '账单号',
currentPaymentLabel: '本次支付',
heroDescResolved: '当前页面已按申请单编号查询其关联的待支付账单记录。',
heroDescAutoResolve: '当前将优先读取最近申请单的编号来查询关联账单。',
paymentStageLaunching: '正在唤起微信收银台',
paymentStagePolling: '正在同步支付结果',
paymentStageSuccess: '支付结果已确认',
paymentStageFailed: '支付未完成',
paymentStageDefault: '支付处理中',
paymentTitleLaunching: '请完成微信支付',
paymentTitlePolling: '正在确认是否支付成功',
paymentTitleSuccess: '支付成功',
paymentTitleFailed: '支付结果确认失败',
paymentTitleDefault: '支付处理中',
paymentDescLaunching: '系统正在调起微信支付,请在微信收银台中完成付款后返回。',
paymentDescPolling: '支付完成后,系统会自动向后台查询支付状态,无需手动刷新。',
paymentDescSuccess: '本次支付已同步完成,账单状态已更新。',
paymentDescFailed: '系统暂时未能确认支付成功,您可以查看提示后稍后重试。',
paymentProgressWaiting: '等待支付完成',
paymentHintLaunching: '已为您准备好支付信息,等待微信支付完成。',
paymentHintPolling: '正在与后台核对支付状态,请稍候。',
paymentTipsTitleFailed: '处理建议',
paymentTipsTitleSuccess: '完成说明',
paymentTipsTitleDefault: '温馨提示',
paymentTipsFailed1: '若您已完成付款,请稍后刷新账单,避免重复支付。',
paymentTipsFailed2: '若微信侧已扣款但页面未更新,请联系管家或客服协助核实。',
paymentTipsFailed3: '若未扣款,可返回账单列表后重新发起支付。',
paymentTipsSuccess1: '若当前申请单还有其他待付账单,可继续在当前页完成支付。',
paymentTipsSuccess2: '若当前账单已全部支付完成,系统会自动返回首页。',
paymentTipsSuccess3: '支付记录同步可能略有延迟,刷新后即可看到最新状态。',
paymentTipsDefault1: '支付成功后,系统可能需要几秒钟同步账单状态。',
paymentTipsDefault2: '请不要重复点击支付,也不要提前关闭当前页面。',
paymentTipsDefault3: '如果超时未完成,系统会自动再校验一次账单状态。',
paymentActionAcknowledge: '我知道了',
buttonConfirming: '确认结果中...',
buttonPolling: '查询结果中({current}/{total})',
buttonLaunching: '拉起支付中...',
wechatPayButton: '微信支付',
continuePayButton: '继续支付',
paymentStatusPending: '处理中',
billRecordFallback: '账单记录',
paymentParamIncomplete: '微信支付参数不完整',
wechatCodeFailed: '获取微信登录凭证失败',
billPaymentDesc: '账单支付',
paymentFailedHint: '暂未查询到支付成功结果,请稍后刷新账单确认。',
paymentResultSuccess: '后台确认支付成功',
paymentResultFailed: '后台返回支付失败',
paymentResultPending: '后台仍在处理中',
paymentHintPaidWaiting: '已支付成功,正在等待后台确认账单状态。',
paymentFailedError: '支付失败',
paymentTimeoutError: '支付结果确认超时,请稍后刷新账单确认',
paymentSuccessPendingBills: '支付结果已确认,当前申请单仍有其他待支付账单。',
paymentSuccessAllDone: '支付结果已确认,当前申请单账单已全部完成。',
billInfoIncomplete: '账单信息不完整',
paymentNoticeTitle: '用户须知',
paymentNoticeBillLabel: '账单号',
paymentNoticeAmountLabel: '支付金额',
paymentNoticeItemLabel: '费用项目',
paymentNoticeLine1: '请确认本次支付账单和金额无误后再继续付款。',
paymentNoticeLine2: '支付完成后,账单状态可能需要几秒同步,请勿重复支付。',
paymentNoticeLine3: '若支付中断或页面关闭,可稍后返回账单页查看最新结果。',
paymentNoticeConfirm: '继续支付',
paymentNoticeCancel: '稍后再付',
paymentHintLaunchingToast: '正在唤起微信支付,请在微信内完成付款。',
paymentCanceled: '已取消支付',
paymentResultFailedRetry: '支付结果确认失败,请稍后刷新账单确认',
billLoadFailed: '账单加载失败',
cancelOrderButton: '取消订单',
cancelOrderLoading: '取消中...',
cancelOrderTitle: '取消订单',
cancelOrderContent: '取消后将关闭当前未支付订单,是否继续?',
cancelOrderSuccess: '订单已取消',
cancelOrderFailed: '取消订单失败'
},
en: {
heroTitle: 'Pending Bills',
applicationNoPrefix: 'Application No: ',
pendingCountLabel: 'Pending Count',
pendingAmountLabel: 'Pending Amount',
loadingTitle: 'Loading Bills',
loadingDesc: 'Syncing your pending bills, please wait.',
relatedBusinessNoLabel: 'Related Business No.',
totalChargeLabel: 'Total Charge',
paidAmountLabel: 'Paid Amount',
remainingAmountLabel: 'Remaining Amount',
refundStatusLabel: 'Refund Status',
createTimeLabel: 'Created At',
remarkLabel: 'Remark',
resultLabel: 'Result',
pendingPaymentTitle: 'Unfinished Payment',
pendingPaymentOrderNoLabel: 'Payment Order No.',
pendingPaymentAmountLabel: 'Order Amount',
pendingPaymentDescLabel: 'Description',
pendingPaymentChannelLabel: 'Channel',
pendingPaymentExpireLabel: 'Time Left',
pendingPaymentExpired: 'Expired',
emptyPendingTitle: 'No Pending Bills',
emptyApplicationTitle: 'No Application Available',
emptyPendingDesc: 'No pending bills are found under the current application. New bills will appear here later.',
emptyApplicationDesc: 'No related application is found. Please go to "My Applications" and select an application first.',
goToMyApplication: 'Go to My Applications',
paymentProgressLabel: 'Confirmation Progress',
billNoLabel: 'Bill No.',
currentPaymentLabel: 'This Payment',
heroDescResolved: 'This page is querying pending bills by the current application number.',
heroDescAutoResolve: 'The latest application number will be used first to query related bills.',
paymentStageLaunching: 'Launching Wechat Pay',
paymentStagePolling: 'Syncing Payment Result',
paymentStageSuccess: 'Payment Confirmed',
paymentStageFailed: 'Payment Incomplete',
paymentStageDefault: 'Processing Payment',
paymentTitleLaunching: 'Complete Wechat Pay',
paymentTitlePolling: 'Confirming Payment Result',
paymentTitleSuccess: 'Payment Successful',
paymentTitleFailed: 'Payment Confirmation Failed',
paymentTitleDefault: 'Processing Payment',
paymentDescLaunching: 'Wechat Pay is being launched. Complete the payment in Wechat and return here.',
paymentDescPolling: 'After payment, the system checks the payment status automatically. No manual refresh is needed.',
paymentDescSuccess: 'This payment has been synchronized and the bill status is updated.',
paymentDescFailed: 'The system cannot confirm the payment for now. Please review the tips and try again later.',
paymentProgressWaiting: 'Waiting for Payment',
paymentHintLaunching: 'Payment information is ready. Waiting for Wechat payment to complete.',
paymentHintPolling: 'Checking payment status with the backend, please wait.',
paymentTipsTitleFailed: 'Suggestions',
paymentTipsTitleSuccess: 'Completion Notes',
paymentTipsTitleDefault: 'Tips',
paymentTipsFailed1: 'If you have completed the payment, refresh the bill later to avoid duplicate payment.',
paymentTipsFailed2: 'If Wechat has deducted the amount but the page is not updated, contact your manager or support for verification.',
paymentTipsFailed3: 'If no deduction occurred, return to the bill list and try again.',
paymentTipsSuccess1: 'If there are other pending bills under this application, you can continue paying on this page.',
paymentTipsSuccess2: 'If all bills are paid, the system returns to the home page automatically.',
paymentTipsSuccess3: 'Payment record synchronization may be slightly delayed. Refresh to see the latest status.',
paymentTipsDefault1: 'After successful payment, the system may need a few seconds to sync the bill status.',
paymentTipsDefault2: 'Do not tap the pay button repeatedly or close this page too early.',
paymentTipsDefault3: 'If the process times out, the system checks the bill status again automatically.',
paymentActionAcknowledge: 'Got It',
buttonConfirming: 'Confirming...',
buttonPolling: 'Checking ({current}/{total})',
buttonLaunching: 'Launching...',
wechatPayButton: 'Wechat Pay',
continuePayButton: 'Continue Pay',
paymentStatusPending: 'Processing',
billRecordFallback: 'Bill Record',
paymentParamIncomplete: 'Incomplete Wechat payment parameters',
wechatCodeFailed: 'Failed to get Wechat login code',
billPaymentDesc: 'Bill Payment',
paymentFailedHint: 'Payment success is not confirmed yet. Please refresh the bill later.',
paymentResultSuccess: 'Backend confirmed payment success',
paymentResultFailed: 'Backend returned payment failure',
paymentResultPending: 'Backend is still processing',
paymentHintPaidWaiting: 'Payment succeeded. Waiting for backend confirmation of the bill status.',
paymentFailedError: 'Payment failed',
paymentTimeoutError: 'Payment confirmation timed out. Please refresh the bill later.',
paymentSuccessPendingBills: 'Payment is confirmed. There are still other unpaid bills under this application.',
paymentSuccessAllDone: 'Payment is confirmed. All bills under this application are completed.',
billInfoIncomplete: 'Incomplete bill information',
paymentNoticeTitle: 'Payment Notice',
paymentNoticeBillLabel: 'Bill No.',
paymentNoticeAmountLabel: 'Amount',
paymentNoticeItemLabel: 'Charge Item',
paymentNoticeLine1: 'Please confirm the bill and payment amount before continuing.',
paymentNoticeLine2: 'After payment, the bill status may take a few seconds to sync. Please do not pay repeatedly.',
paymentNoticeLine3: 'If the payment is interrupted or the page is closed, return to the bill page later to check the latest result.',
paymentNoticeConfirm: 'Continue',
paymentNoticeCancel: 'Later',
paymentHintLaunchingToast: 'Launching Wechat Pay. Please complete the payment in Wechat.',
paymentCanceled: 'Payment canceled',
paymentResultFailedRetry: 'Payment confirmation failed. Please refresh the bill later.',
billLoadFailed: 'Failed to load bills',
cancelOrderButton: 'Cancel Order',
cancelOrderLoading: 'Canceling...',
cancelOrderTitle: 'Cancel Order',
cancelOrderContent: 'This will close the current unpaid order. Continue?',
cancelOrderSuccess: 'Order canceled',
cancelOrderFailed: 'Failed to cancel order'
}
}
}
},
computed: {
language() {
return this.$getLanguage()
},
totalRemainingAmount() {
return this.billList.reduce((sum, item) => {
return sum + Number(item && item.remainingAmount ? item.remainingAmount : 0)
}, 0)
},
heroDesc() {
if (this.currentReferenceTypeNo) {
return this.t('heroDescResolved')
}
return this.t('heroDescAutoResolve')
},
showPaymentFeedbackPanel() {
return Boolean(this.paymentStage)
},
isPaymentWaitingStage() {
return this.paymentStage === 'launching' || this.paymentStage === 'polling'
},
isPaymentSuccessStage() {
return this.paymentStage === 'success'
},
isPaymentFailedStage() {
return this.paymentStage === 'failed'
},
paymentStageVisualType() {
if (this.isPaymentSuccessStage) {
return 'success'
}
if (this.isPaymentFailedStage) {
return 'failed'
}
return 'waiting'
},
paymentStageLabel() {
if (this.paymentStage === 'launching') {
return this.t('paymentStageLaunching')
}
if (this.paymentStage === 'polling') {
return this.t('paymentStagePolling')
}
if (this.paymentStage === 'success') {
return this.t('paymentStageSuccess')
}
if (this.paymentStage === 'failed') {
return this.t('paymentStageFailed')
}
return this.t('paymentStageDefault')
},
paymentBadgeIconClass() {
if (this.isPaymentSuccessStage) {
return 'tn-icon-success-circle-fill'
}
if (this.isPaymentFailedStage) {
return 'tn-icon-warning-fill'
}
return 'tn-icon-secure'
},
paymentFeedbackIconClass() {
if (this.isPaymentSuccessStage) {
return 'tn-icon-success'
}
if (this.isPaymentFailedStage) {
return 'tn-icon-close'
}
return 'tn-icon-loading'
},
paymentWaitingTitle() {
if (this.paymentStage === 'launching') {
return this.t('paymentTitleLaunching')
}
if (this.paymentStage === 'polling') {
return this.t('paymentTitlePolling')
}
if (this.paymentStage === 'success') {
return this.t('paymentTitleSuccess')
}
if (this.paymentStage === 'failed') {
return this.t('paymentTitleFailed')
}
return this.t('paymentTitleDefault')
},
paymentWaitingDesc() {
if (this.paymentStage === 'launching') {
return this.t('paymentDescLaunching')
}
if (this.paymentStage === 'polling') {
return this.t('paymentDescPolling')
}
if (this.paymentStage === 'success') {
return this.t('paymentDescSuccess')
}
if (this.paymentStage === 'failed') {
return this.t('paymentDescFailed')
}
return ''
},
paymentWaitingBillNo() {
return this.paymentPollingBillNo || this.payingBillNo || (this.paymentSession && this.paymentSession.billNo) || ''
},
paymentWaitingAmountText() {
const amount = this.paymentSession && Number(this.paymentSession.amount)
return Number.isFinite(amount) ? amount.toFixed(2) : '0.00'
},
paymentProgressPercent() {
if (this.paymentStage !== 'polling') {
return 16
}
const current = this.paymentPollingAttempt > 0 ? this.paymentPollingAttempt : 1
return Math.min(100, Math.max(18, Math.round((current / PAYMENT_QUERY_MAX_ATTEMPTS) * 100)))
},
paymentProgressText() {
if (this.paymentStage !== 'polling') {
return this.t('paymentProgressWaiting')
}
const current = this.paymentPollingAttempt > 0 ? this.paymentPollingAttempt : 1
return `${current}/${PAYMENT_QUERY_MAX_ATTEMPTS}`
},
paymentStatusHintText() {
if (this.paymentStatusHint) {
return this.paymentStatusHint
}
if (this.paymentStage === 'launching') {
return this.t('paymentHintLaunching')
}
return this.t('paymentHintPolling')
},
paymentTipsTitle() {
if (this.isPaymentFailedStage) {
return this.t('paymentTipsTitleFailed')
}
if (this.isPaymentSuccessStage) {
return this.t('paymentTipsTitleSuccess')
}
return this.t('paymentTipsTitleDefault')
},
paymentTips() {
if (this.isPaymentFailedStage) {
return [
this.t('paymentTipsFailed1'),
this.t('paymentTipsFailed2'),
this.t('paymentTipsFailed3')
]
}
if (this.isPaymentSuccessStage) {
return [
this.t('paymentTipsSuccess1'),
this.t('paymentTipsSuccess2'),
this.t('paymentTipsSuccess3')
]
}
return [
this.t('paymentTipsDefault1'),
this.t('paymentTipsDefault2'),
this.t('paymentTipsDefault3')
]
},
showPaymentActionButton() {
return this.isPaymentFailedStage
},
paymentActionText() {
return this.isPaymentFailedStage ? this.t('paymentActionAcknowledge') : ''
}
},
watch: {
referenceTypeNo: {
immediate: true,
handler(value) {
const nextReferenceTypeNo = value ? String(value).trim() : ''
if (nextReferenceTypeNo === this.currentReferenceTypeNo) {
return
}
this.currentReferenceTypeNo = nextReferenceTypeNo
if (this.currentReferenceTypeNo) {
saveActiveApplicationCode(this.currentReferenceTypeNo)
}
if (this._panelReady) {
this.refreshBillList()
}
}
}
},
mounted() {
this._panelReady = true
this.startCountdownTimer()
this.refreshBillList()
},
beforeDestroy() {
this.clearCountdownTimer()
},
methods: {
t(key, params) {
const translatedText = this.$tm('pkgService.components.CurrentApplicationBillPanel', key, params)
if (translatedText && translatedText !== key) {
return translatedText
}
const languagePack = this.i18n && this.i18n[this.language] ? this.i18n[this.language] : this.i18n.zh
const fallbackText = languagePack && languagePack[key] ? languagePack[key] : key
if (!params || typeof fallbackText !== 'string') {
return fallbackText
}
return fallbackText.replace(/\{(\w+)\}/g, (match, token) => {
return Object.prototype.hasOwnProperty.call(params, token) ? params[token] : match
})
},
getBillNo(item = {}) {
return item.no || item.billNo || ''
},
getBillKey(item = {}) {
return this.getBillNo(item) || this.getBillReferenceTypeNo(item) || this.getBillCreateTime(item) || 'bill-item'
},
getBillReferenceTypeNo(item = {}) {
return item.referenceTypeNo || ''
},
getBillPaymentStatus(item = {}) {
return item.paymentStatus
},
getBillPaymentStatusText(item = {}) {
const translatedStatus = this.getPaymentStatusText(this.getBillPaymentStatus(item))
return translatedStatus || item.paymentStatusDesc || this.t('paymentStatusPending')
},
getBillRefundStatus(item = {}) {
return item.refundStatus
},
getBillCreateTime(item = {}) {
return item.createTime || ''
},
getBillTotalCharge(item = {}) {
return item.totalCharge
},
getBillPaidAmount(item = {}) {
return item.paidAmount
},
getBillRemainingAmount(item = {}) {
return item.remainingAmount
},
getBillRemark(item = {}) {
return item.remark || ''
},
getBillResult(item = {}) {
return item.result || ''
},
getPendingPaymentOrder(item = {}) {
const billNo = this.getBillNo(item)
return billNo ? (this.pendingPaymentOrderMap[billNo] || null) : null
},
getPendingPaymentOrderNo(item = {}) {
const order = this.getPendingPaymentOrder(item)
return order ? (order.payRequestNo || order.payNo || order.paymentNo || '--') : '--'
},
getPendingPaymentAmount(item = {}) {
const order = this.getPendingPaymentOrder(item)
if (order && (order.amount || order.amount === 0)) {
return order.amount
}
return this.getBillRemainingAmount(item)
},
getPendingPaymentDescription(item = {}) {
const order = this.getPendingPaymentOrder(item)
return order ? (order.description || '') : ''
},
getPendingPaymentChannel(item = {}) {
const order = this.getPendingPaymentOrder(item)
return order && order.cashier ? (order.cashier.payChannel || '') : ''
},
startCountdownTimer() {
if (this._countdownTimer) {
return
}
this._countdownTimer = setInterval(() => {
this.countdownNow = Date.now()
}, 1000)
},
clearCountdownTimer() {
if (this._countdownTimer) {
clearInterval(this._countdownTimer)
this._countdownTimer = null
}
},
getPendingPaymentExpireTimestamp(order = {}) {
return parseExpireTimeToTimestamp(order.expireTime || (order.cashier && order.cashier.expireTime) || '')
},
getPendingPaymentRemainingSeconds(item = {}) {
const order = this.getPendingPaymentOrder(item)
if (!order) {
return 0
}
const expireTimestamp = this.getPendingPaymentExpireTimestamp(order)
if (!expireTimestamp) {
return 0
}
return Math.max(0, Math.ceil((expireTimestamp - this.countdownNow) / 1000))
},
formatPendingPaymentCountdown(seconds = 0) {
const totalSeconds = Math.max(0, Number(seconds) || 0)
const day = Math.floor(totalSeconds / 86400)
const hour = Math.floor((totalSeconds % 86400) / 3600)
const minute = Math.floor((totalSeconds % 3600) / 60)
const second = totalSeconds % 60
const timeText = `${padCountdownUnit(hour)}:${padCountdownUnit(minute)}:${padCountdownUnit(second)}`
return day > 0
? (this.language === 'en' ? `${day}d ${timeText}` : `${day}天 ${timeText}`)
: timeText
},
isPendingPaymentExpired(item = {}) {
const order = this.getPendingPaymentOrder(item)
if (!order) {
return false
}
const expireTimestamp = this.getPendingPaymentExpireTimestamp(order)
if (!expireTimestamp) {
return false
}
return expireTimestamp <= this.countdownNow
},
getPendingPaymentExpireText(item = {}) {
const order = this.getPendingPaymentOrder(item)
if (!order) {
return ''
}
const expireTimestamp = this.getPendingPaymentExpireTimestamp(order)
if (!expireTimestamp) {
return ''
}
if (expireTimestamp <= this.countdownNow) {
return this.t('pendingPaymentExpired')
}
return this.formatPendingPaymentCountdown(this.getPendingPaymentRemainingSeconds(item))
},
getPayButtonText(item = {}) {
const billNo = this.getBillNo(item)
if (this.paymentPollingBillNo && this.paymentPollingBillNo === billNo) {
if (!this.paymentPollingAttempt) {
return this.t('buttonConfirming')
}
return this.t('buttonPolling', {
current: this.paymentPollingAttempt,
total: PAYMENT_QUERY_MAX_ATTEMPTS
})
}
if (this.payingBillNo === billNo) {
return this.paymentStage === 'launching' ? this.t('buttonLaunching') : this.t('buttonConfirming')
}
if (this.getPendingPaymentOrder(item) && !this.isPendingPaymentExpired(item)) {
return this.t('continuePayButton')
}
return this.t('wechatPayButton')
},
canCancelOrder(item = {}) {
if (!this.currentReferenceTypeNo) {
return false
}
const remainingAmount = Number(this.getBillRemainingAmount(item) || 0)
const paymentStatus = Number(this.getBillPaymentStatus(item))
return remainingAmount > 0 || paymentStatus !== ACCOUNT_BILL_PAYMENT_STATUS.PAID
},
getCancelButtonText(item = {}) {
return this.cancelingBillNo === this.getBillNo(item)
? this.t('cancelOrderLoading')
: this.t('cancelOrderButton')
},
resolveReferenceTypeNo(item = {}) {
return item.code || item.detailCode || ''
},
resolvePreferredReferenceTypeNo(list = []) {
const sortedList = normalizeApplicationList(list)
.filter(item => !item.isViewingApplication && this.resolveReferenceTypeNo(item))
.sort((a, b) => new Date(b.createTime || 0).getTime() - new Date(a.createTime || 0).getTime())
if (!sortedList.length) {
return ''
}
const preferredItem = sortedList.find(item => Number(item.status) !== 99) || sortedList[0]
return this.resolveReferenceTypeNo(preferredItem)
},
ensureReferenceTypeNo() {
if (this.currentReferenceTypeNo) {
return Promise.resolve(this.currentReferenceTypeNo)
}
if (!this.autoResolveReference) {
return Promise.resolve('')
}
const cachedActiveCode = getActiveApplicationCode()
if (cachedActiveCode) {
this.currentReferenceTypeNo = cachedActiveCode
return Promise.resolve(this.currentReferenceTypeNo)
}
const cachedApplicationList = normalizeApplicationList(getMyApplicationList())
const cachedCode = this.resolvePreferredReferenceTypeNo(cachedApplicationList)
if (cachedCode) {
this.currentReferenceTypeNo = cachedCode
saveActiveApplicationCode(cachedCode)
return Promise.resolve(this.currentReferenceTypeNo)
}
this.resolvingReference = true
return applicationFormMePage({
PageIndex: 1,
PageSize: 20
})
.then((response) => {
const payload = response && response.data ? response.data : response
const list = normalizeApplicationList(payload && payload.data)
saveMyApplicationList(list)
const nextCode = this.resolvePreferredReferenceTypeNo(list)
this.currentReferenceTypeNo = nextCode
if (nextCode) {
saveActiveApplicationCode(nextCode)
}
return nextCode
})
.catch(() => '')
.finally(() => {
this.resolvingReference = false
})
},
formatMoney(value) {
const amount = Number(value || 0)
return amount.toFixed(2)
},
formatDateTime(value) {
if (!value) {
return '--'
}
const text = String(value).replace('T', ' ')
return text.length > 16 ? text.slice(0, 16) : text
},
getPaymentStatusText(status) {
return getAccountBillPaymentStatusLabel(status, this.t('paymentStatusPending'))
},
resolvePaymentStatusType(status) {
if (Number(status) === ACCOUNT_BILL_PAYMENT_STATUS.PAID) {
return 'paid'
}
if (Number(status) === ACCOUNT_BILL_PAYMENT_STATUS.PARTIAL) {
return 'partial'
}
return 'pending'
},
getRefundStatusText(status) {
return getAccountBillRefundStatusLabel(status, '--')
},
getReferenceTypeText(item = {}) {
const translatedType = getAccountReferenceTypeLabel(item.referenceType, '')
return translatedType || item.referenceTypeDesc || this.t('billRecordFallback')
},
parsePaymentInfo(paymentInfo) {
if (!paymentInfo) {
return {}
}
if (typeof paymentInfo === 'object') {
return paymentInfo
}
const text = String(paymentInfo).trim()
if (!text) {
return {}
}
try {
return JSON.parse(text)
} catch (error) {
return {}
}
},
normalizeWechatPayOptions(paymentInfo) {
const source = this.parsePaymentInfo(paymentInfo)
return {
provider: 'wxpay',
timeStamp: String(source.timeStamp || source.timestamp || source.time_stamp || ''),
nonceStr: source.nonceStr || source.noncestr || source.nonce_str || '',
package: source.package || source.packageValue || source.package_value || '',
signType: source.signType || source.sign_type || 'MD5',
paySign: source.paySign || source.paysign || source.pay_sign || ''
}
},
requestWechatPayment(paymentInfo) {
const payOptions = this.normalizeWechatPayOptions(paymentInfo)
if (!payOptions.timeStamp || !payOptions.nonceStr || !payOptions.package || !payOptions.paySign) {
return Promise.reject(new Error(this.t('paymentParamIncomplete')))
}
return new Promise((resolve, reject) => {
uni.requestPayment({
...payOptions,
success: (res) => resolve(res),
fail: (error) => reject(error)
})
})
},
normalizePendingPaymentOrders(response) {
const payload = response && response.data ? response.data : response
if (Array.isArray(payload)) {
return payload.filter(item => item && typeof item === 'object')
}
if (payload && Array.isArray(payload.data)) {
return payload.data.filter(item => item && typeof item === 'object')
}
return []
},
syncPendingPaymentOrders(list = []) {
const billNos = [...new Set(
(Array.isArray(list) ? list : [])
.map(item => this.getBillNo(item))
.filter(Boolean)
)]
if (!billNos.length) {
this.pendingPaymentOrderMap = {}
return Promise.resolve({})
}
const currentReferenceTypeNo = this.currentReferenceTypeNo
return Promise.all(
billNos.map((billNo) => {
return paymentPayingBills({ BillNo: billNo })
.then((response) => {
const orderList = this.normalizePendingPaymentOrders(response)
return {
billNo,
order: orderList.length ? orderList[0] : null
}
})
.catch(() => ({
billNo,
order: null
}))
})
).then((resultList) => {
if (currentReferenceTypeNo !== this.currentReferenceTypeNo) {
return this.pendingPaymentOrderMap
}
const nextMap = resultList.reduce((result, item) => {
if (item && item.billNo && item.order) {
result[item.billNo] = item.order
}
return result
}, {})
this.pendingPaymentOrderMap = nextMap
return nextMap
})
},
getWechatLoginCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => {
const code = res && res.code ? res.code : ''
if (!code) {
reject(new Error(this.t('wechatCodeFailed')))
return
}
resolve(code)
},
fail: (error) => {
reject(error || new Error(this.t('wechatCodeFailed')))
}
})
})
},
buildBillPayPayload(item = {}, wechatCode = '') {
const payload = {
referenceTypeNo: this.currentReferenceTypeNo,
billNo: this.getBillNo(item),
payMethod: 'Wechat',
amount: Number(this.getBillRemainingAmount(item) || 0),
description: this.getReferenceTypeText(item) || this.t('billPaymentDesc')
}
if (wechatCode) {
payload.ExtraInfo = wechatCode
}
return payload
},
resolveNoticeText(key, zhText, enText) {
const translatedText = this.t(key)
if (translatedText && translatedText !== key) {
return translatedText
}
return this.language === 'en' ? enText : zhText
},
resetPaymentNoticeContext() {
this.paymentNoticeContext = {
title: '',
billLabel: '',
billNo: '--',
itemLabel: '',
itemTitle: '',
amountLabel: '',
amountText: '',
lines: [],
confirmText: '',
cancelText: ''
}
},
closePaymentNotice(confirmed = false) {
const resolver = this.paymentNoticeResolver
this.paymentNoticeResolver = null
this.showPaymentNotice = false
this.resetPaymentNoticeContext()
if (typeof resolver === 'function') {
resolver(!!confirmed)
}
},
handlePaymentNoticeClose() {
if (this.paymentNoticeResolver) {
this.closePaymentNotice(false)
} else {
this.showPaymentNotice = false
this.resetPaymentNoticeContext()
}
},
confirmPaymentNotice(item = {}) {
const billNo = this.getBillNo(item) || '--'
const amountText = `¥${this.formatMoney(this.getBillRemainingAmount(item))}`
const modalTitle = this.resolveNoticeText('paymentNoticeTitle', '支付须知', 'Notice')
const itemLabel = this.resolveNoticeText('paymentNoticeItemLabel', '费用项目', 'Item')
const billLabel = this.resolveNoticeText('paymentNoticeBillLabel', '账单号', 'Bill')
const amountLabel = this.resolveNoticeText('paymentNoticeAmountLabel', '支付金额', 'Amount')
const line1 = this.resolveNoticeText('paymentNoticeLine1', '请确认本次支付账单和金额无误后再继续付款。', 'Please confirm the bill and amount before payment.')
const line2 = this.resolveNoticeText('paymentNoticeLine2', '支付完成后,账单状态可能需要几秒同步,请勿重复支付。', 'Bill status may sync a few seconds later. Do not pay twice.')
const line3 = this.resolveNoticeText('paymentNoticeLine3', '若支付中断或页面关闭,可稍后返回账单页查看最新结果。', 'If interrupted, return later to check the latest result.')
const itemTitle = this.getReferenceTypeText(item) || this.resolveNoticeText('billPaymentDesc', '账单支付', 'Bill Payment')
return new Promise((resolve) => {
this.paymentNoticeResolver = resolve
this.paymentNoticeContext = {
title: modalTitle,
billLabel,
billNo,
itemLabel,
itemTitle,
amountLabel,
amountText,
lines: [line1, line2, line3],
confirmText: this.resolveNoticeText('paymentNoticeConfirm', '继续支付', 'Continue'),
cancelText: this.resolveNoticeText('paymentNoticeCancel', '稍后再付', 'Later')
}
this.showPaymentNotice = true
})
},
confirmCancelOrder() {
return new Promise((resolve) => {
uni.showModal({
title: this.t('cancelOrderTitle'),
content: this.t('cancelOrderContent'),
confirmColor: '#ef4444',
success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false)
})
})
},
buildCancelOrderPayload(item = {}) {
const pendingOrder = this.getPendingPaymentOrder(item) || {}
const billNo = this.getBillNo(item)
const paymentNo = pendingOrder.paymentNo || pendingOrder.payNo || pendingOrder.payRequestNo || ''
return {
detailCode: this.currentReferenceTypeNo,
code: this.currentReferenceTypeNo,
referenceTypeNo: this.currentReferenceTypeNo,
billNo,
BillNo: billNo,
paymentNo,
payNo: paymentNo,
payRequestNo: pendingOrder.payRequestNo || ''
}
},
createPaymentSession(item = {}) {
this.paymentSession = {
billNo: this.getBillNo(item),
amount: Number(this.getBillRemainingAmount(item) || 0),
title: this.getReferenceTypeText(item) || this.t('billPaymentDesc')
}
},
resetPaymentWaitingState() {
this.payingBillNo = ''
this.paymentPollingBillNo = ''
this.paymentPollingAttempt = 0
this.paymentStage = ''
this.paymentStatusHint = ''
this.paymentSession = null
},
openPaymentFailedState(message = '') {
this.paymentStage = 'failed'
this.paymentStatusHint = message || this.t('paymentFailedHint')
this.payingBillNo = ''
this.paymentPollingBillNo = this.paymentWaitingBillNo
},
handlePaymentResultAction() {
if (this.isPaymentFailedStage) {
this.resetPaymentWaitingState()
}
},
wait(ms = 0) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
},
buildPaymentQueryContext(item = {}, cashier = {}) {
const paymentInfo = this.parsePaymentInfo(cashier.paymentInfo)
return {
referenceTypeNo: this.currentReferenceTypeNo,
billNo: this.getBillNo(item),
paymentNo: cashier.paymentNo || cashier.cashierNo || cashier.orderNo || '',
tradeNo: cashier.tradeNo || cashier.outTradeNo || paymentInfo.outTradeNo || ''
}
},
queryPaymentResult(queryContext = {}) {
return paymentStatusByBillNo({
BillNo: queryContext.billNo
})
.then((response) => {
const payload = response && response.data ? response.data : response
const payStatus = Number(payload && payload.payStatus)
const statusDesc = this.language !== 'en' && payload && payload.statusDesc ? payload.statusDesc : ''
if (payStatus === PAYMENT_ORDER_STATUS.SUCCESS) {
return {
status: PAYMENT_QUERY_STATUS.SUCCESS,
message: statusDesc || this.t('paymentResultSuccess'),
data: payload
}
}
if (payStatus === PAYMENT_ORDER_STATUS.FAILED) {
return {
status: PAYMENT_QUERY_STATUS.FAILED,
message: statusDesc || this.t('paymentResultFailed'),
data: payload
}
}
return {
status: PAYMENT_QUERY_STATUS.PENDING,
message: statusDesc || this.t('paymentResultPending'),
data: payload
}
})
},
pollPaymentUntilSettled(item = {}, cashier = {}) {
const queryContext = this.buildPaymentQueryContext(item, cashier)
let lastResult = null
this.paymentStage = 'polling'
this.paymentPollingBillNo = queryContext.billNo
this.paymentPollingAttempt = 0
this.paymentStatusHint = this.t('paymentHintPaidWaiting')
const executePolling = async () => {
for (let attempt = 1; attempt <= PAYMENT_QUERY_MAX_ATTEMPTS; attempt += 1) {
if (attempt > 1) {
await this.wait(PAYMENT_QUERY_INTERVAL)
}
this.paymentPollingAttempt = attempt
lastResult = await this.queryPaymentResult(queryContext)
this.paymentStatusHint = lastResult && lastResult.message ? lastResult.message : this.t('paymentHintPolling')
if (lastResult && lastResult.status === PAYMENT_QUERY_STATUS.SUCCESS) {
return lastResult
}
if (lastResult && lastResult.status === PAYMENT_QUERY_STATUS.FAILED) {
throw new Error(lastResult.message || this.t('paymentFailedError'))
}
}
throw new Error((lastResult && lastResult.message) || this.t('paymentTimeoutError'))
}
return executePolling()
},
emitPaymentSuccess() {
const hasPendingBills = this.billList.length > 0
this.paymentStage = 'success'
this.paymentStatusHint = hasPendingBills
? this.t('paymentSuccessPendingBills')
: this.t('paymentSuccessAllDone')
this.payingBillNo = ''
this.paymentPollingBillNo = this.paymentWaitingBillNo
return this.wait(1200)
.then(() => {
this.$emit('payment-success', {
referenceTypeNo: this.currentReferenceTypeNo,
hasPendingBills,
billList: this.billList
})
if (!hasPendingBills) {
this.$emit('all-paid', {
referenceTypeNo: this.currentReferenceTypeNo
})
return
}
this.resetPaymentWaitingState()
})
},
reloadListAfterPayment() {
return this.reloadList()
.catch(() => [])
.then(() => {
this.emitPaymentSuccess()
})
},
reconcilePaymentResult(billNo = '') {
return this.reloadList()
.catch(() => [])
.then(() => {
const billStillPending = this.billList.some(item => this.getBillNo(item) === billNo)
if (!billStillPending) {
this.emitPaymentSuccess()
return true
}
return false
})
},
handleWechatPay(event) {
const dataset = event && event.currentTarget ? event.currentTarget.dataset || {} : {}
const billIndex = Number(dataset.billIndex)
const item = Number.isInteger(billIndex) && billIndex >= 0 ? (this.billList[billIndex] || {}) : {}
const billNo = this.getBillNo(item)
const pendingPaymentOrder = this.getPendingPaymentOrder(item)
const reusablePendingPaymentOrder = pendingPaymentOrder && !this.isPendingPaymentExpired(item)
? pendingPaymentOrder
: null
if (!billNo || !this.currentReferenceTypeNo) {
uni.showToast({
title: this.t('billInfoIncomplete'),
icon: 'none'
})
return
}
if (this.payingBillNo) {
return
}
let cashier = {}
Promise.resolve()
.then(() => this.confirmPaymentNotice(item))
.then((confirmed) => {
if (!confirmed) {
throw new Error('PAYMENT_NOTICE_CANCELED')
}
this.payingBillNo = billNo
this.paymentStage = 'launching'
this.paymentStatusHint = this.t('paymentHintLaunchingToast')
this.createPaymentSession(item)
})
.then(() => {
if (reusablePendingPaymentOrder && reusablePendingPaymentOrder.cashier && reusablePendingPaymentOrder.cashier.paymentInfo) {
cashier = reusablePendingPaymentOrder.cashier
return cashier
}
return this.getWechatLoginCode()
.then((wechatCode) => paymentBillPay(this.buildBillPayPayload(item, wechatCode)))
.then((response) => {
const payload = response && response.data ? response.data : response
cashier = payload && payload.cashier ? payload.cashier : {}
return cashier
})
})
.then(() => {
return this.requestWechatPayment(cashier.paymentInfo)
})
.then(() => {
return this.pollPaymentUntilSettled(item, cashier)
})
.then(() => {
return this.reloadListAfterPayment()
})
.catch((error) => {
const errMsg = error && (error.errMsg || error.message || error.msg) ? (error.errMsg || error.message || error.msg) : ''
if (errMsg === 'PAYMENT_NOTICE_CANCELED') {
this.resetPaymentWaitingState()
return
}
if (/cancel/i.test(errMsg)) {
this.resetPaymentWaitingState()
uni.showToast({
title: this.t('paymentCanceled'),
icon: 'none'
})
return
}
return this.reconcilePaymentResult(billNo)
.then((hasConfirmedSuccess) => {
if (hasConfirmedSuccess) {
return
}
this.openPaymentFailedState(errMsg || this.t('paymentResultFailedRetry'))
})
})
.finally(() => {
if (this.isPaymentWaitingStage) {
this.resetPaymentWaitingState()
}
})
},
handleCancelOrder(event) {
const dataset = event && event.currentTarget ? event.currentTarget.dataset || {} : {}
const billIndex = Number(dataset.billIndex)
const item = Number.isInteger(billIndex) && billIndex >= 0 ? (this.billList[billIndex] || {}) : {}
const billNo = this.getBillNo(item)
if (!billNo || !this.canCancelOrder(item) || this.cancelingBillNo || this.payingBillNo) {
return
}
this.confirmCancelOrder()
.then((confirmed) => {
if (!confirmed) {
return
}
this.cancelingBillNo = billNo
return userDetailCancel(this.buildCancelOrderPayload(item), this.language)
.then(() => {
uni.showToast({
title: this.t('cancelOrderSuccess'),
icon: 'success'
})
return this.refreshBillList()
})
})
.catch((error) => {
uni.showToast({
title: (error && (error.message || error.msg)) || this.t('cancelOrderFailed'),
icon: 'none'
})
})
.finally(() => {
this.cancelingBillNo = ''
})
},
refreshBillList() {
return this.ensureReferenceTypeNo()
.then((referenceTypeNo) => {
if (!referenceTypeNo) {
this.billList = []
this.pendingPaymentOrderMap = {}
return []
}
this.$emit('reference-resolved', referenceTypeNo)
return this.reloadList()
})
},
reloadList() {
if (!this.currentReferenceTypeNo) {
this.billList = []
this.pendingPaymentOrderMap = {}
return Promise.resolve([])
}
const requestKey = this.currentReferenceTypeNo
if (this._reloadListPromise && this._reloadListKey === requestKey) {
return this._reloadListPromise
}
this.loading = true
this._reloadListKey = requestKey
this._reloadListPromise = accountQueryBills({
referenceTypeNo: this.currentReferenceTypeNo,
status: ACCOUNT_BILL_STATUS.ACTIVE,
paymentStatus: ACCOUNT_BILL_PAYMENT_STATUS.UNPAID
})
.then((response) => {
this.billList = normalizeBillList(response)
return this.syncPendingPaymentOrders(this.billList)
.then(() => this.billList)
})
.catch((error) => {
this.billList = []
this.pendingPaymentOrderMap = {}
uni.showToast({
title: (error && (error.message || error.msg)) || this.t('billLoadFailed'),
icon: 'none'
})
return []
})
.finally(() => {
this.loading = false
this._reloadListPromise = null
})
return this._reloadListPromise
},
goToMyApplication() {
uni.navigateTo({
url: '/pkgService/my_application/my_application'
})
}
}
}
</script>
<style scoped lang="scss">
.hero-panel,
.bill-card,
.empty-panel {
background: #ffffff;
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
border: 1rpx solid rgba($brand-primary, 0.05);
}
.hero-title,
.empty-title,
.card-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.hero-desc,
.empty-desc,
.card-code,
.info-label,
.remark-label {
color: #6A7A99;
}
.hero-summary {
display: flex;
gap: 20rpx;
}
.source-tag {
display: inline-flex;
align-items: center;
padding: 8rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
color: $brand-primary;
background: rgba($brand-primary, 0.08);
}
.summary-card {
flex: 1;
padding: 24rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, rgba($brand-primary, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%);
border: 1rpx solid rgba($brand-primary, 0.1);
}
.summary-label {
font-size: 22rpx;
color: #6A7A99;
}
.summary-value {
font-size: 40rpx;
font-weight: 700;
color: #1A2233;
}
.card-code,
.info-label,
.info-value,
.remark-text {
font-size: 24rpx;
}
.status-badge {
padding: 8rpx 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
}
.status-pending {
color: #b45309;
background: rgba(245, 158, 11, 0.12);
}
.status-partial {
color: $brand-primary;
background: rgba($brand-primary, 0.12);
}
.status-paid {
color: #166534;
background: rgba(34, 197, 94, 0.12);
}
.card-body {
display: flex;
flex-direction: column;
gap: 0;
}
.info-row {
padding: 16rpx 0;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
gap: 24rpx;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
flex: 0 0 170rpx;
color: #6A7A99;
}
.info-value {
flex: 1;
color: #1A2233;
text-align: right;
word-break: break-all;
font-weight: 500;
}
.info-value--highlight {
color: #c2410c;
font-weight: 700;
}
.info-value--expired {
color: #dc2626;
font-weight: 700;
}
.remark-panel {
padding: 24rpx;
border-radius: 20rpx;
background: rgba($brand-primary, 0.02);
border: 1rpx solid rgba($brand-primary, 0.1);
}
.pending-payment-panel {
padding: 24rpx;
border-radius: 20rpx;
background: linear-gradient(180deg, rgba(255, 247, 237, 0.9) 0%, rgba(255, 251, 245, 0.95) 100%);
border: 1rpx solid rgba(249, 115, 22, 0.16);
}
.pending-payment-title {
margin-bottom: 12rpx;
font-size: 26rpx;
font-weight: 700;
color: #c2410c;
}
.remark-line {
display: flex;
align-items: flex-start;
gap: 16rpx;
}
.remark-label {
flex: 0 0 96rpx;
font-size: 22rpx;
color: #6A7A99;
}
.remark-text {
flex: 1;
color: #1A2233;
line-height: 1.7;
}
.card-footer {
display: flex;
gap: 20rpx;
justify-content: flex-end;
}
.cancel-btn,
.pay-btn {
min-width: 220rpx;
height: 84rpx;
padding: 0 36rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
color: #ffffff;
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.25);
font-size: 26rpx;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.3s;
}
.cancel-btn {
background: #ffffff;
color: #ef4444;
border: 1rpx solid rgba(239, 68, 68, 0.3);
box-shadow: 0 8rpx 20rpx rgba(239, 68, 68, 0.08);
}
.pay-btn:active {
transform: scale(0.98);
}
.cancel-btn:active {
transform: scale(0.98);
}
.cancel-btn--loading,
.pay-btn--loading {
opacity: 0.72;
}
.empty-action {
width: 100%;
height: 96rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
color: #ffffff;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.25);
transition: all 0.3s;
}
.empty-action:active {
transform: scale(0.98);
}
.payment-notice-popup {
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
display: flex;
flex-direction: column;
height: 72vh;
}
.payment-notice-popup__header {
padding: 32rpx;
text-align: center;
border-bottom: 1px solid #eef2f7;
}
.payment-notice-popup__title {
font-size: 34rpx;
font-weight: 700;
color: #172033;
}
.payment-notice-popup__body {
flex: 1;
padding: 28rpx 32rpx 32rpx;
box-sizing: border-box;
}
.payment-notice-popup__summary {
padding: 28rpx;
border-radius: 24rpx;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.98) 0%, rgba(238, 246, 255, 0.98) 100%);
border: 1rpx solid rgba($brand-primary, 0.08);
}
.payment-notice-popup__summary-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
}
.payment-notice-popup__summary-row + .payment-notice-popup__summary-row {
margin-top: 18rpx;
}
.payment-notice-popup__summary-label {
flex: 0 0 140rpx;
font-size: 24rpx;
color: #6a7a99;
}
.payment-notice-popup__summary-value {
flex: 1;
text-align: right;
font-size: 24rpx;
font-weight: 600;
color: #172033;
word-break: break-all;
}
.payment-notice-popup__summary-value--price {
color: #d9480f;
}
.payment-notice-popup__content {
margin-top: 24rpx;
padding: 28rpx;
border-radius: 24rpx;
background: #f8fafc;
}
.payment-notice-popup__line {
font-size: 25rpx;
line-height: 1.75;
color: #475569;
}
.payment-notice-popup__line + .payment-notice-popup__line {
margin-top: 18rpx;
}
.payment-notice-popup__footer {
display: flex;
gap: 20rpx;
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
border-top: 1px solid #eef2f7;
background: #ffffff;
}
.payment-notice-popup__action {
flex: 1;
height: 92rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.payment-notice-popup__action:active {
transform: scale(0.98);
}
.payment-notice-popup__action--ghost {
color: #475569;
background: #f1f5f9;
}
.payment-notice-popup__action--primary {
color: #ffffff;
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
box-shadow: 0 16rpx 34rpx rgba($brand-primary, 0.24);
}
.payment-waiting-mask {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(8, 15, 35, 0.46);
backdrop-filter: blur(12rpx);
-webkit-backdrop-filter: blur(12rpx);
}
.payment-waiting-panel {
position: relative;
width: 100%;
max-width: 640rpx;
padding: 40rpx 32rpx 34rpx;
border-radius: 36rpx;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(244, 249, 255, 0.98) 100%);
box-shadow: 0 30rpx 80rpx rgba(15, 23, 42, 0.18);
}
.payment-waiting-panel--success {
background: linear-gradient(180deg, rgba(248, 255, 251, 0.98) 0%, rgba(240, 250, 245, 0.98) 100%);
}
.payment-waiting-panel--failed {
background: linear-gradient(180deg, rgba(255, 250, 250, 0.98) 0%, rgba(255, 244, 244, 0.98) 100%);
}
.payment-waiting-glow {
position: absolute;
border-radius: 50%;
filter: blur(12rpx);
opacity: 0.8;
pointer-events: none;
}
.payment-waiting-glow--one {
top: -56rpx;
right: -30rpx;
width: 220rpx;
height: 220rpx;
background: radial-gradient(circle, rgba(64, 158, 255, 0.24) 0%, rgba(64, 158, 255, 0) 72%);
}
.payment-waiting-glow--two {
left: -60rpx;
bottom: -80rpx;
width: 260rpx;
height: 260rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.18) 0%, rgba($brand-primary, 0) 74%);
}
.payment-waiting-badge {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
padding: 10rpx 20rpx;
border-radius: 999rpx;
color: $brand-primary;
background: rgba($brand-primary, 0.08);
font-size: 22rpx;
font-weight: 700;
}
.payment-waiting-badge--success {
color: #15803d;
background: rgba(34, 197, 94, 0.12);
}
.payment-waiting-badge--failed {
color: #c2410c;
background: rgba(249, 115, 22, 0.12);
}
.payment-waiting-badge__icon {
margin-right: 8rpx;
font-size: 24rpx;
}
.payment-waiting-icon-wrap {
position: relative;
z-index: 1;
width: 180rpx;
height: 180rpx;
margin: 28rpx auto 18rpx;
display: flex;
align-items: center;
justify-content: center;
}
.payment-waiting-icon-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2rpx solid rgba($brand-primary, 0.12);
border-top-color: rgba($brand-primary, 0.4);
border-bottom-color: rgba(76, 190, 255, 0.4);
animation: paymentRotate 1.8s linear infinite;
}
.payment-waiting-icon-core {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
background: linear-gradient(135deg, $brand-primary 0%, #68B6FF 100%);
box-shadow: 0 18rpx 40rpx rgba($brand-primary, 0.28);
}
.payment-waiting-icon-core--success {
background: linear-gradient(135deg, #22c55e 0%, #34d399 100%);
box-shadow: 0 18rpx 40rpx rgba(34, 197, 94, 0.24);
}
.payment-waiting-icon-core--failed {
background: linear-gradient(135deg, #f97316 0%, #fb7185 100%);
box-shadow: 0 18rpx 40rpx rgba(249, 115, 22, 0.24);
}
.payment-waiting-icon {
font-size: 52rpx;
}
.payment-waiting-icon--spin {
animation: paymentRotate 1.1s linear infinite;
}
.payment-waiting-title {
position: relative;
z-index: 1;
text-align: center;
font-size: 38rpx;
font-weight: 700;
color: #172033;
}
.payment-waiting-desc {
position: relative;
z-index: 1;
margin-top: 14rpx;
text-align: center;
font-size: 25rpx;
line-height: 1.7;
color: #66758f;
}
.payment-progress-card,
.payment-tips-card {
position: relative;
z-index: 1;
margin-top: 28rpx;
padding: 24rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.76);
border: 1rpx solid rgba($brand-primary, 0.08);
}
.payment-progress-head {
margin-bottom: 18rpx;
}
.payment-progress-label,
.payment-summary-label,
.payment-tips-title {
font-size: 22rpx;
font-weight: 700;
color: #5d6c87;
}
.payment-progress-value {
font-size: 22rpx;
font-weight: 700;
color: $brand-primary;
}
.payment-progress-track {
width: 100%;
height: 14rpx;
border-radius: 999rpx;
overflow: hidden;
background: rgba($brand-primary, 0.08);
}
.payment-progress-bar {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, $brand-primary 0%, #78BEFF 100%);
box-shadow: 0 6rpx 18rpx rgba($brand-primary, 0.22);
transition: width 0.35s ease;
}
.payment-progress-note {
margin-top: 16rpx;
font-size: 24rpx;
color: #23314d;
line-height: 1.6;
}
.payment-summary-grid {
position: relative;
z-index: 1;
display: flex;
gap: 18rpx;
margin-top: 20rpx;
}
.payment-summary-item {
flex: 1;
min-width: 0;
padding: 24rpx;
border-radius: 24rpx;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.96) 0%, rgba(238, 246, 255, 0.96) 100%);
border: 1rpx solid rgba($brand-primary, 0.08);
}
.payment-summary-value {
margin-top: 12rpx;
font-size: 26rpx;
font-weight: 700;
color: #1A2233;
word-break: break-all;
}
.payment-summary-value--mono {
font-size: 24rpx;
}
.payment-summary-value--price {
color: #d9480f;
}
.payment-tip-line {
margin-top: 14rpx;
font-size: 23rpx;
line-height: 1.7;
color: #5f6e88;
}
.payment-action-row {
position: relative;
z-index: 1;
margin-top: 28rpx;
}
.payment-action-btn {
width: 100%;
height: 92rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
background: linear-gradient(135deg, #f97316 0%, #fb7185 100%);
box-shadow: 0 16rpx 34rpx rgba(249, 115, 22, 0.24);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.payment-action-btn:active {
transform: scale(0.98);
}
@keyframes paymentRotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>