feat(cashier): 收银台接入真实支付并调整码牌 path 为 /h/:code

移动/PC 收银台对接订单查询、支付项与发起支付;码牌路由 CODE_PAY 与后端 getCodeLink 对齐为 /h/:code。
This commit is contained in:
bootx
2026-07-15 16:48:11 +08:00
parent bd84bc23b9
commit 326065bf98
14 changed files with 626 additions and 255 deletions

View File

@@ -1,46 +1,35 @@
<script lang="ts" setup>
import { showToast } from 'vant'
import type { CashierItemPublic, GatewayOrderInfo } from '@/shared/api/gateway'
import { showNotify, showSuccessToast } from 'vant'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { cashierPay, getGatewayOrder, listCashierItems } from '@/shared/api/gateway'
defineOptions({ name: 'CashierPage' })
const { t } = useI18n()
const route = useRoute()
const orderNo = route.params.orderNo as string
// 演示订单数据mock不连后端
const order = computed(() => ({
title: t('cashier.demoOrderTitle'),
orderNo: route.params.orderNo as string,
amount: 0.01,
// 15 分钟后过期
expiredTime: Date.now() + 15 * 60 * 1000,
}))
const loading = ref(true)
const paying = ref(false)
const loadError = ref('')
const order = ref<GatewayOrderInfo>({})
const payMethods = ref<CashierItemPublic[]>([])
const selectId = ref<string>('')
// 演示支付方式mock— name 由 i18n 按 icon 渲染
interface PayMethod {
id: string
icon: 'wechat' | 'alipay' | 'union_pay'
recommend?: boolean
}
const payMethods: PayMethod[] = [
{ id: '1', icon: 'wechat', recommend: true },
{ id: '2', icon: 'alipay' },
{ id: '3', icon: 'union_pay' },
]
// 支付方式名称(跟随语言)
function methodName(item: PayMethod) {
return t(`cashier.method.${item.icon}`)
}
// 选中的支付方式(默认推荐项)
const selectId = ref<string>(payMethods[0]!.id)
// 倒计时(原生 setInterval项目无 @vueuse/core
const remainSeconds = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
const amountYuan = computed(() => {
if (!order.value.amount) {
return '0.00'
}
return (order.value.amount / 100).toFixed(2)
})
const countdown = computed(() => {
const s = remainSeconds.value
const h = String(Math.floor(s / 3600)).padStart(2, '0')
@@ -49,103 +38,264 @@ const countdown = computed(() => {
return { h, m, s: sec }
})
function startCountdown() {
remainSeconds.value = Math.max(0, Math.floor((order.value.expiredTime - Date.now()) / 1000))
timer = setInterval(() => {
if (remainSeconds.value > 0) {
remainSeconds.value--
}
else if (timer) {
clearInterval(timer)
}
}, 1000)
const paid = computed(() => order.value.status === 'paid')
/**
* 根据 UA 识别客户端环境(与后端 ClientEnvEnum 对齐)
*/
function detectClientEnv(): string {
const ua = navigator.userAgent.toLowerCase()
if (ua.includes('micromessenger')) {
return 'wechat_pay'
}
if (ua.includes('alipayclient') || ua.includes('alipay')) {
return 'alipay'
}
if (ua.includes('unionpay') || ua.includes('cloudpay') || ua.includes('upwallet')) {
return 'union_pay'
}
if (ua.includes('aweme') || ua.includes('toutiao') || ua.includes('douyin')) {
return 'douyin'
}
return 'browser'
}
// 发起支付(演示)
function pay() {
const selected = payMethods.find(it => it.id === selectId.value)
showToast({
type: 'success',
message: t('cashier.demoPayStarted', { name: selected ? methodName(selected) : '' }),
})
/**
* 支付项展示名: 优先后端 name, 否则按 icon 走 i18n
*/
function methodName(item: CashierItemPublic) {
if (item.name) {
return item.name
}
const icon = item.icon || 'wechat'
const key = `cashier.method.${icon}`
const label = t(key)
return label === key ? icon : label
}
onMounted(startCountdown)
/**
* 图标 class 后缀(未知 icon 回退 wechat 样式)
*/
function iconClass(icon?: string) {
const known = ['wechat', 'alipay', 'union_pay', 'douyin']
if (icon && known.includes(icon)) {
return icon
}
if (icon === 'wechat_pay') {
return 'wechat'
}
return 'wechat'
}
onMounted(async () => {
await loadPage()
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
if (pollTimer) {
clearInterval(pollTimer)
}
})
/**
* 加载订单 + 支付项
*/
async function loadPage() {
loading.value = true
loadError.value = ''
try {
order.value = await getGatewayOrder(orderNo)
if (order.value.expiredTime) {
const exp = new Date(order.value.expiredTime).getTime()
remainSeconds.value = Math.max(0, Math.floor((exp - Date.now()) / 1000))
timer = setInterval(() => {
if (remainSeconds.value > 0) {
remainSeconds.value--
}
else if (timer) {
clearInterval(timer)
}
}, 1000)
}
if (!paid.value) {
const clientEnv = detectClientEnv()
payMethods.value = await listCashierItems({
orderNo,
cashierType: 'h5',
clientEnv,
})
// 默认选中推荐项, 否则第一项
const recommend = payMethods.value.find(i => i.recommend)
selectId.value = (recommend || payMethods.value[0])?.id || ''
}
}
catch (e: any) {
loadError.value = e?.message || t('cashier.loadFail')
}
finally {
loading.value = false
}
}
/**
* 发起收银台支付
*/
async function pay() {
if (paying.value || paid.value || !selectId.value) {
return
}
paying.value = true
try {
const result = await cashierPay({
orderNo,
itemId: selectId.value,
cashierType: 'h5',
clientEnv: detectClientEnv(),
device: 'mobile',
})
if (result?.status === 'success') {
order.value.status = 'paid'
showSuccessToast(t('cashier.paid'))
redirectIfNeeded()
return
}
if (result?.payBody && result.payBodyType === 'url') {
window.location.href = result.payBody
return
}
startPoll()
}
catch (e: any) {
showNotify({ type: 'danger', message: e?.message || t('cashier.payFail') })
}
finally {
paying.value = false
}
}
function startPoll() {
if (pollTimer) {
clearInterval(pollTimer)
}
pollTimer = setInterval(async () => {
try {
const latest = await getGatewayOrder(orderNo)
order.value = latest
if (latest.status === 'paid') {
if (pollTimer) {
clearInterval(pollTimer)
}
showSuccessToast(t('cashier.paid'))
redirectIfNeeded()
}
}
catch {
// 忽略轮询错误
}
}, 2000)
}
function redirectIfNeeded() {
if (order.value.returnUrl) {
setTimeout(() => {
window.location.href = order.value.returnUrl!
}, 1200)
}
}
</script>
<template>
<div class="cashier">
<!-- 顶部订单信息 -->
<div class="cashier__header enter-y">
<div class="cashier__price">
<span class="cashier__currency"></span>
<span class="cashier__amount">{{ order.amount }}</span>
</div>
<div class="cashier__countdown">
<span class="cashier__countdown-label">{{ t('cashier.remainTime') }}</span>
<span class="cashier__countdown-time">
{{ countdown.h }}:{{ countdown.m }}:{{ countdown.s }}
</span>
</div>
<div class="cashier__detail">
<div class="cashier__detail-row">
<span>{{ t('cashier.orderTitle') }}</span>
<span>{{ order.title }}</span>
</div>
<div class="cashier__detail-row">
<span>{{ t('cashier.orderNo') }}</span>
<span>{{ order.orderNo }}</span>
</div>
</div>
<div v-if="loading" class="cashier__header enter-y">
<van-loading color="#5d9dfe" size="24px" />
</div>
<!-- 支付方式选择 -->
<div class="cashier__body enter-y">
<div class="cashier__section-title">
{{ t('cashier.selectMethod') }}
</div>
<div class="cashier__list">
<div
v-for="item in payMethods"
:key="item.id"
class="cashier__item"
:class="{ 'cashier__item--active': item.id === selectId }"
@click="selectId = item.id"
>
<div class="cashier__item-info">
<div class="cashier__pay-icon" :class="`cashier__pay-icon--${item.icon}`">
<!-- 内联 SVG 品牌图标微信支付 / 支付宝 / 银联 -->
<svg v-if="item.icon === 'wechat'" viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M690.1 377.4c5.9 0 11.8.2 17.6.5-24.4-128.7-158.3-227.1-322.3-227.1C205.2 150.8 64 271.4 64 420.2c0 81.1 43.6 146.4 116.6 197.4l-29.2 88.4 102.2-52.6c36.5 7.2 65.9 14.6 101.9 14.6 5.4 0 10.8-.2 16.2-.5-3.4-11.8-5.3-24.1-5.3-36.9 0-153.7 129.4-253.2 323.7-253.2zM544.9 267.6c21.5 0 35.7 14.2 35.7 35.7 0 21.3-14.2 35.8-35.7 35.8s-42.9-14.5-42.9-35.8c0-21.5 21.4-35.7 42.9-35.7zM269.3 338.3c-21.5 0-43.3-14.2-43.3-35.7 0-21.3 21.8-35.8 43.3-35.8 21.3 0 35.6 14.5 35.6 35.8 0 21.5-14.3 35.7-35.6 35.7z" />
</svg>
<svg v-else-if="item.icon === 'alipay'" viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M843.1 608.6c-23.4-49.7-54.2-105.8-91.7-166.2-37.8-60.4-77.1-115.3-117.6-164.4-44.6-12.3-91.7-18.8-140.4-18.8-101.3 0-194.4 34.4-266.9 91.9-83.3 66-133.5 162.7-133.5 269.5 0 103.9 49.5 199.1 133.7 265.7 76.2 60.2 174.3 95.3 280.1 95.3 138.6 0 262.2-57.3 339-147.1-33.9-26-99-62.7-202.9-99.3-29.4 39.5-72.9 64.8-128.1 64.8-99.5 0-176.4-72.2-176.4-176.4 0-99.1 71.5-177 176.4-177 78.2 0 137.6 41.6 161.8 103.9 73.6 30.9 132.3 56.9 167.4 76.5l72.1 36.1z" />
</svg>
<svg v-else viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm130.4 590.4c-39.1 27.8-86.8 43.6-138.4 43.6-130.4 0-236-105.6-236-236s105.6-236 236-236c45.6 0 88.2 13 124 35.4l-58.5 58.5c-19.4-9-40.9-14-63.5-14-85.2 0-154.4 69.2-154.4 154.4S384.8 544.3 470 544.3c52.6 0 98.9-26.4 126.4-66.6l70.6 70.6c-7.8 2.1-15.8 4-24.6 6.7z" />
</svg>
</div>
<div class="cashier__pay-name">
{{ methodName(item) }}
<span v-if="item.recommend" class="cashier__recommend">{{ t('cashier.recommend') }}</span>
</div>
<div v-else-if="loadError" class="cashier__header enter-y">
<p class="cashier__error">
{{ loadError }}
</p>
</div>
<template v-else>
<!-- 顶部订单信息 -->
<div class="cashier__header enter-y">
<div class="cashier__price">
<span class="cashier__currency"></span>
<span class="cashier__amount">{{ amountYuan }}</span>
</div>
<div v-if="remainSeconds > 0 && !paid" class="cashier__countdown">
<span class="cashier__countdown-label">{{ t('cashier.remainTime') }}</span>
<span class="cashier__countdown-time">
{{ countdown.h }}:{{ countdown.m }}:{{ countdown.s }}
</span>
</div>
<div class="cashier__detail">
<div class="cashier__detail-row">
<span>{{ t('cashier.orderTitle') }}</span>
<span>{{ order.title || t('cashier.demoOrderTitle') }}</span>
</div>
<div class="cashier__detail-row">
<span>{{ t('cashier.orderNo') }}</span>
<span>{{ order.orderNo }}</span>
</div>
<div class="cashier__radio" :class="{ 'cashier__radio--checked': item.id === selectId }" />
</div>
</div>
</div>
<!-- 底部支付按钮 -->
<div class="cashier__footer enter-y">
<button class="cashier__pay-btn" @click="pay">
{{ t('cashier.payNow') }} {{ order.amount }}
</button>
</div>
<!-- 已支付 -->
<div v-if="paid" class="cashier__body enter-y">
<div class="cashier__section-title">
{{ t('cashier.paid') }}
</div>
</div>
<!-- 支付方式选择 -->
<div v-else class="cashier__body enter-y">
<div class="cashier__section-title">
{{ t('cashier.selectMethod') }}
</div>
<div v-if="!payMethods.length" class="cashier__empty">
{{ t('cashier.emptyItems') }}
</div>
<div v-else class="cashier__list">
<div
v-for="item in payMethods"
:key="item.id"
class="cashier__item"
:class="{ 'cashier__item--active': item.id === selectId }"
@click="selectId = item.id"
>
<div class="cashier__item-info">
<div class="cashier__pay-icon" :class="`cashier__pay-icon--${iconClass(item.icon)}`">
<svg v-if="iconClass(item.icon) === 'wechat'" viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M690.1 377.4c5.9 0 11.8.2 17.6.5-24.4-128.7-158.3-227.1-322.3-227.1C205.2 150.8 64 271.4 64 420.2c0 81.1 43.6 146.4 116.6 197.4l-29.2 88.4 102.2-52.6c36.5 7.2 65.9 14.6 101.9 14.6 5.4 0 10.8-.2 16.2-.5-3.4-11.8-5.3-24.1-5.3-36.9 0-153.7 129.4-253.2 323.7-253.2zM544.9 267.6c21.5 0 35.7 14.2 35.7 35.7 0 21.3-14.2 35.8-35.7 35.8s-42.9-14.5-42.9-35.8c0-21.5 21.4-35.7 42.9-35.7zM269.3 338.3c-21.5 0-43.3-14.2-43.3-35.7 0-21.3 21.8-35.8 43.3-35.8 21.3 0 35.6 14.5 35.6 35.8 0 21.5-14.3 35.7-35.6 35.7z" />
</svg>
<svg v-else-if="iconClass(item.icon) === 'alipay'" viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M843.1 608.6c-23.4-49.7-54.2-105.8-91.7-166.2-37.8-60.4-77.1-115.3-117.6-164.4-44.6-12.3-91.7-18.8-140.4-18.8-101.3 0-194.4 34.4-266.9 91.9-83.3 66-133.5 162.7-133.5 269.5 0 103.9 49.5 199.1 133.7 265.7 76.2 60.2 174.3 95.3 280.1 95.3 138.6 0 262.2-57.3 339-147.1-33.9-26-99-62.7-202.9-99.3-29.4 39.5-72.9 64.8-128.1 64.8-99.5 0-176.4-72.2-176.4-176.4 0-99.1 71.5-177 176.4-177 78.2 0 137.6 41.6 161.8 103.9 73.6 30.9 132.3 56.9 167.4 76.5l72.1 36.1z" />
</svg>
<svg v-else viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm130.4 590.4c-39.1 27.8-86.8 43.6-138.4 43.6-130.4 0-236-105.6-236-236s105.6-236 236-236c45.6 0 88.2 13 124 35.4l-58.5 58.5c-19.4-9-40.9-14-63.5-14-85.2 0-154.4 69.2-154.4 154.4S384.8 544.3 470 544.3c52.6 0 98.9-26.4 126.4-66.6l70.6 70.6c-7.8 2.1-15.8 4-24.6 6.7z" />
</svg>
</div>
<div class="cashier__pay-name">
{{ methodName(item) }}
<span v-if="item.recommend" class="cashier__recommend">{{ t('cashier.recommend') }}</span>
</div>
</div>
<div class="cashier__radio" :class="{ 'cashier__radio--checked': item.id === selectId }" />
</div>
</div>
</div>
<!-- 底部支付按钮 -->
<div v-if="!paid" class="cashier__footer enter-y">
<button class="cashier__pay-btn" :disabled="paying || !selectId" @click="pay">
{{ paying ? t('cashier.paying') : `${t('cashier.payNow')} ${amountYuan}` }}
</button>
</div>
</template>
</div>
</template>
@@ -168,6 +318,20 @@ onUnmounted(() => {
padding-bottom: 80px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
&__error {
color: @danger;
font-size: 14px;
padding: 24px;
text-align: center;
}
&__empty {
color: @text-sub;
font-size: 14px;
text-align: center;
padding: 24px 0;
}
&__header {
// header 加主色调浅渐变,与金额蓝色呼应
background: linear-gradient(180deg, #f0f6ff 0%, #ffffff 100%);
@@ -305,6 +469,10 @@ onUnmounted(() => {
&--union_pay {
background: #e60012;
}
&--douyin {
background: #111;
}
}
&__pay-name {
@@ -364,6 +532,10 @@ onUnmounted(() => {
background: @primary-dark;
opacity: 0.95;
}
&:disabled {
opacity: 0.6;
}
}
}
</style>

View File

@@ -1,43 +1,38 @@
<script lang="ts" setup>
import type { CashierItemPublic, GatewayOrderInfo } from '@/shared/api/gateway'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { cashierPay, getGatewayOrder, listCashierItems } from '@/shared/api/gateway'
defineOptions({ name: 'PcCashier' })
const { t } = useI18n()
const route = useRoute()
const orderNo = route.params.orderNo as string
// 演示订单数据mock不连后端
const order = computed(() => ({
title: t('cashier.demoOrderTitle'),
orderNo: route.params.orderNo as string,
amount: 0.01,
expiredTime: Date.now() + 15 * 60 * 1000,
}))
const loading = ref(true)
const paying = ref(false)
const loadError = ref('')
const order = ref<GatewayOrderInfo>({})
const payMethods = ref<CashierItemPublic[]>([])
const selectId = ref<string>('')
const payBody = ref('')
const showQrcode = ref(false)
// 演示支付方式mock— name 由 i18n 按 icon 渲染
interface PayMethod {
id: string
icon: 'wechat' | 'alipay' | 'union_pay'
recommend?: boolean
}
const payMethods: PayMethod[] = [
{ id: '1', icon: 'wechat', recommend: true },
{ id: '2', icon: 'alipay' },
{ id: '3', icon: 'union_pay' },
]
// 支付方式名称(跟随语言)
function methodName(item: PayMethod) {
return t(`cashier.method.${item.icon}`)
}
const selectId = ref<string>(payMethods[0]!.id)
// 倒计时(原生 setInterval项目无 @vueuse/core
const remainSeconds = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
const amountYuan = computed(() => {
if (!order.value.amount) {
return '0.00'
}
return (order.value.amount / 100).toFixed(2)
})
const paid = computed(() => order.value.status === 'paid')
const countdown = computed(() => {
const s = remainSeconds.value
const h = String(Math.floor(s / 3600)).padStart(2, '0')
@@ -46,135 +41,235 @@ const countdown = computed(() => {
return { h, m, s: sec }
})
function startCountdown() {
remainSeconds.value = Math.max(0, Math.floor((order.value.expiredTime - Date.now()) / 1000))
timer = setInterval(() => {
if (remainSeconds.value > 0) {
remainSeconds.value--
}
else if (timer) {
clearInterval(timer)
}
}, 1000)
function methodName(item?: CashierItemPublic | null) {
if (!item) {
return ''
}
if (item.name) {
return item.name
}
const icon = item.icon || 'wechat'
const key = `cashier.method.${icon}`
const label = t(key)
return label === key ? icon : label
}
// 支付后展示二维码占位
const showQrcode = ref(false)
function pay() {
showQrcode.value = true
function iconClass(icon?: string) {
if (icon === 'wechat' || icon === 'wechat_pay') {
return 'wechat'
}
if (icon === 'alipay') {
return 'alipay'
}
if (icon === 'union_pay') {
return 'union_pay'
}
return 'wechat'
}
onMounted(startCountdown)
onMounted(async () => {
await loadPage()
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
if (pollTimer) {
clearInterval(pollTimer)
}
})
async function loadPage() {
loading.value = true
loadError.value = ''
try {
order.value = await getGatewayOrder(orderNo)
if (order.value.expiredTime) {
const exp = new Date(order.value.expiredTime).getTime()
remainSeconds.value = Math.max(0, Math.floor((exp - Date.now()) / 1000))
timer = setInterval(() => {
if (remainSeconds.value > 0) {
remainSeconds.value--
}
else if (timer) {
clearInterval(timer)
}
}, 1000)
}
if (!paid.value) {
// WEB 收银台: cashierType=web, clientEnv 不传
payMethods.value = await listCashierItems({
orderNo,
cashierType: 'web',
})
const recommend = payMethods.value.find(i => i.recommend)
selectId.value = (recommend || payMethods.value[0])?.id || ''
}
}
catch (e: any) {
loadError.value = e?.message || t('cashier.loadFail')
}
finally {
loading.value = false
}
}
async function pay() {
if (paying.value || paid.value || !selectId.value) {
return
}
paying.value = true
try {
const result = await cashierPay({
orderNo,
itemId: selectId.value,
cashierType: 'web',
device: 'pc',
})
if (result?.status === 'success') {
order.value.status = 'paid'
return
}
if (result?.payBody) {
if (result.payBodyType === 'url') {
window.location.href = result.payBody
return
}
// 二维码类: 展示 payBody 文本(可后续接二维码组件)
payBody.value = result.payBody
showQrcode.value = true
startPoll()
return
}
startPoll()
}
catch (e: any) {
loadError.value = e?.message || t('cashier.payFail')
}
finally {
paying.value = false
}
}
function startPoll() {
if (pollTimer) {
clearInterval(pollTimer)
}
pollTimer = setInterval(async () => {
try {
const latest = await getGatewayOrder(orderNo)
order.value = latest
if (latest.status === 'paid') {
if (pollTimer) {
clearInterval(pollTimer)
}
if (latest.returnUrl) {
window.location.href = latest.returnUrl
}
}
}
catch {
// ignore
}
}, 2000)
}
</script>
<template>
<div class="pc-cashier">
<div class="pc-cashier__box">
<!-- 订单头部 -->
<div class="pc-cashier__header">
<div class="pc-cashier__countdown">
<span class="pc-cashier__countdown-label">{{ t('cashier.remainTimeShort') }}</span>
<span class="pc-cashier__countdown-time">
{{ countdown.h }}:{{ countdown.m }}:{{ countdown.s }}
</span>
</div>
<div class="pc-cashier__title">
{{ order.title }}
</div>
<div class="pc-cashier__price">
<span class="pc-cashier__price-label">{{ t('cashier.payableAmount') }}</span>
<p>
<span></span>{{ order.amount }}
</p>
</div>
<div class="pc-cashier__order-no">
{{ t('cashier.orderNoLabel') }}{{ order.orderNo }}
</div>
<div v-if="loading" class="pc-cashier__content">
{{ t('cashier.paying') }}
</div>
<!-- 内容区 -->
<div class="pc-cashier__content">
<!-- 支付方式网格支付前 -->
<div v-if="!showQrcode" class="pc-cashier__grid">
<div
v-for="item in payMethods"
:key="item.id"
class="pc-cashier__method"
:class="{ 'pc-cashier__method--active': item.id === selectId }"
@click="selectId = item.id"
>
<span class="pc-cashier__method-icon" :class="`pc-cashier__method-icon--${item.icon}`">
<!-- 内联 SVG 品牌图标微信支付 / 支付宝 / 银联 -->
<svg v-if="item.icon === 'wechat'" viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M690.1 377.4c5.9 0 11.8.2 17.6.5-24.4-128.7-158.3-227.1-322.3-227.1C205.2 150.8 64 271.4 64 420.2c0 81.1 43.6 146.4 116.6 197.4l-29.2 88.4 102.2-52.6c36.5 7.2 65.9 14.6 101.9 14.6 5.4 0 10.8-.2 16.2-.5-3.4-11.8-5.3-24.1-5.3-36.9 0-153.7 129.4-253.2 323.7-253.2zM544.9 267.6c21.5 0 35.7 14.2 35.7 35.7 0 21.3-14.2 35.8-35.7 35.8s-42.9-14.5-42.9-35.8c0-21.5 21.4-35.7 42.9-35.7zM269.3 338.3c-21.5 0-43.3-14.2-43.3-35.7 0-21.3 21.8-35.8 43.3-35.8 21.3 0 35.6 14.5 35.6 35.8 0 21.5-14.3 35.7-35.6 35.7z" />
</svg>
<svg v-else-if="item.icon === 'alipay'" viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M843.1 608.6c-23.4-49.7-54.2-105.8-91.7-166.2-37.8-60.4-77.1-115.3-117.6-164.4-44.6-12.3-91.7-18.8-140.4-18.8-101.3 0-194.4 34.4-266.9 91.9-83.3 66-133.5 162.7-133.5 269.5 0 103.9 49.5 199.1 133.7 265.7 76.2 60.2 174.3 95.3 280.1 95.3 138.6 0 262.2-57.3 339-147.1-33.9-26-99-62.7-202.9-99.3-29.4 39.5-72.9 64.8-128.1 64.8-99.5 0-176.4-72.2-176.4-176.4 0-99.1 71.5-177 176.4-177 78.2 0 137.6 41.6 161.8 103.9 73.6 30.9 132.3 56.9 167.4 76.5l72.1 36.1z" />
</svg>
<svg v-else viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm130.4 590.4c-39.1 27.8-86.8 43.6-138.4 43.6-130.4 0-236-105.6-236-236s105.6-236 236-236c45.6 0 88.2 13 124 35.4l-58.5 58.5c-19.4-9-40.9-14-63.5-14-85.2 0-154.4 69.2-154.4 154.4S384.8 544.3 470 544.3c52.6 0 98.9-26.4 126.4-66.6l70.6 70.6c-7.8 2.1-15.8 4-24.6 6.7z" />
</svg>
<div v-else-if="loadError" class="pc-cashier__content">
{{ loadError }}
</div>
<template v-else>
<!-- 订单头部 -->
<div class="pc-cashier__header">
<div v-if="remainSeconds > 0 && !paid" class="pc-cashier__countdown">
<span class="pc-cashier__countdown-label">{{ t('cashier.remainTimeShort') }}</span>
<span class="pc-cashier__countdown-time">
{{ countdown.h }}:{{ countdown.m }}:{{ countdown.s }}
</span>
<span class="pc-cashier__method-name">{{ methodName(item) }}</span>
<span v-if="item.recommend" class="pc-cashier__recommend">{{ t('cashier.recommend') }}</span>
</div>
<div class="pc-cashier__title">
{{ order.title || t('cashier.demoOrderTitle') }}
</div>
<div class="pc-cashier__price">
<span class="pc-cashier__price-label">{{ t('cashier.payableAmount') }}</span>
<p>
<span></span>{{ amountYuan }}
</p>
</div>
<div class="pc-cashier__order-no">
{{ t('cashier.orderNoLabel') }}{{ order.orderNo }}
</div>
</div>
<!-- 二维码占位支付后 -->
<div v-else class="pc-cashier__qrcode">
<div class="pc-cashier__qrcode-box">
<!-- 四角 L 型装饰角标提升扫码区仪式感 -->
<span class="pc-cashier__corner pc-cashier__corner--tl" />
<span class="pc-cashier__corner pc-cashier__corner--tr" />
<span class="pc-cashier__corner pc-cashier__corner--bl" />
<span class="pc-cashier__corner pc-cashier__corner--br" />
<svg viewBox="0 0 100 100" class="pc-cashier__qrcode-svg" aria-hidden="true">
<rect width="100" height="100" fill="#fff" />
<g fill="#1d2129">
<rect x="8" y="8" width="24" height="24" />
<rect x="14" y="14" width="12" height="12" fill="#fff" />
<rect x="18" y="18" width="4" height="4" />
<rect x="68" y="8" width="24" height="24" />
<rect x="74" y="14" width="12" height="12" fill="#fff" />
<rect x="78" y="18" width="4" height="4" />
<rect x="8" y="68" width="24" height="24" />
<rect x="14" y="74" width="12" height="12" fill="#fff" />
<rect x="18" y="78" width="4" height="4" />
<rect x="40" y="8" width="6" height="6" /><rect x="54" y="8" width="6" height="6" />
<rect x="40" y="20" width="6" height="6" /><rect x="54" y="20" width="6" height="6" />
<rect x="8" y="40" width="6" height="6" /><rect x="20" y="40" width="6" height="6" />
<rect x="40" y="40" width="6" height="6" /><rect x="54" y="40" width="6" height="6" />
<rect x="68" y="40" width="6" height="6" /><rect x="86" y="40" width="6" height="6" />
<rect x="40" y="54" width="6" height="6" /><rect x="68" y="54" width="6" height="6" />
<rect x="86" y="54" width="6" height="6" />
<rect x="40" y="68" width="6" height="6" /><rect x="54" y="68" width="6" height="6" />
<rect x="68" y="68" width="6" height="6" /><rect x="86" y="68" width="6" height="6" />
<rect x="40" y="80" width="6" height="6" /><rect x="68" y="80" width="6" height="6" />
<rect x="86" y="80" width="6" height="6" />
</g>
</svg>
<!-- 内容区 -->
<div class="pc-cashier__content">
<div v-if="paid" class="pc-cashier__method-name">
{{ t('cashier.paid') }}
</div>
<div class="pc-cashier__qrcode-tip">
<p class="pc-cashier__qrcode-title">
{{ t('cashier.qrcodePayDemo') }}
</p>
<p class="pc-cashier__qrcode-sub">
{{ t('cashier.qrcodeTip', { name: methodName(payMethods.find(i => i.id === selectId)!) }) }}
</p>
<!-- 支付方式网格支付前 -->
<div v-else-if="!showQrcode" class="pc-cashier__grid">
<div
v-for="item in payMethods"
:key="item.id"
class="pc-cashier__method"
:class="{ 'pc-cashier__method--active': item.id === selectId }"
@click="selectId = item.id"
>
<span class="pc-cashier__method-icon" :class="`pc-cashier__method-icon--${iconClass(item.icon)}`">
<svg v-if="iconClass(item.icon) === 'wechat'" viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M690.1 377.4c5.9 0 11.8.2 17.6.5-24.4-128.7-158.3-227.1-322.3-227.1C205.2 150.8 64 271.4 64 420.2c0 81.1 43.6 146.4 116.6 197.4l-29.2 88.4 102.2-52.6c36.5 7.2 65.9 14.6 101.9 14.6 5.4 0 10.8-.2 16.2-.5-3.4-11.8-5.3-24.1-5.3-36.9 0-153.7 129.4-253.2 323.7-253.2zM544.9 267.6c21.5 0 35.7 14.2 35.7 35.7 0 21.3-14.2 35.8-35.7 35.8s-42.9-14.5-42.9-35.8c0-21.5 21.4-35.7 42.9-35.7zM269.3 338.3c-21.5 0-43.3-14.2-43.3-35.7 0-21.3 21.8-35.8 43.3-35.8 21.3 0 35.6 14.5 35.6 35.8 0 21.5-14.3 35.7-35.6 35.7z" />
</svg>
<svg v-else-if="iconClass(item.icon) === 'alipay'" viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M843.1 608.6c-23.4-49.7-54.2-105.8-91.7-166.2-37.8-60.4-77.1-115.3-117.6-164.4-44.6-12.3-91.7-18.8-140.4-18.8-101.3 0-194.4 34.4-266.9 91.9-83.3 66-133.5 162.7-133.5 269.5 0 103.9 49.5 199.1 133.7 265.7 76.2 60.2 174.3 95.3 280.1 95.3 138.6 0 262.2-57.3 339-147.1-33.9-26-99-62.7-202.9-99.3-29.4 39.5-72.9 64.8-128.1 64.8-99.5 0-176.4-72.2-176.4-176.4 0-99.1 71.5-177 176.4-177 78.2 0 137.6 41.6 161.8 103.9 73.6 30.9 132.3 56.9 167.4 76.5l72.1 36.1z" />
</svg>
<svg v-else viewBox="0 0 1024 1024" width="22" height="22" aria-hidden="true">
<path fill="#fff" d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm130.4 590.4c-39.1 27.8-86.8 43.6-138.4 43.6-130.4 0-236-105.6-236-236s105.6-236 236-236c45.6 0 88.2 13 124 35.4l-58.5 58.5c-19.4-9-40.9-14-63.5-14-85.2 0-154.4 69.2-154.4 154.4S384.8 544.3 470 544.3c52.6 0 98.9-26.4 126.4-66.6l70.6 70.6c-7.8 2.1-15.8 4-24.6 6.7z" />
</svg>
</span>
<span class="pc-cashier__method-name">{{ methodName(item) }}</span>
<span v-if="item.recommend" class="pc-cashier__recommend">{{ t('cashier.recommend') }}</span>
</div>
</div>
<!-- 二维码/支付参数展示 -->
<div v-else class="pc-cashier__qrcode">
<div class="pc-cashier__qrcode-box">
<span class="pc-cashier__corner pc-cashier__corner--tl" />
<span class="pc-cashier__corner pc-cashier__corner--tr" />
<span class="pc-cashier__corner pc-cashier__corner--bl" />
<span class="pc-cashier__corner pc-cashier__corner--br" />
<p class="pc-cashier__qrcode-sub" style="word-break: break-all; padding: 8px; font-size: 12px;">
{{ payBody }}
</p>
</div>
<div class="pc-cashier__qrcode-tip">
<p class="pc-cashier__qrcode-title">
{{ t('cashier.qrcodePayDemo') }}
</p>
<p class="pc-cashier__qrcode-sub">
{{ t('cashier.qrcodeTip', { name: methodName(payMethods.find(i => i.id === selectId)) }) }}
</p>
</div>
</div>
</div>
</div>
<!-- 立即支付按钮支付前 -->
<button v-if="!showQrcode" class="pc-cashier__pay-btn" @click="pay">
{{ t('cashier.payNow') }}
</button>
<!-- 立即支付按钮支付前 -->
<button
v-if="!showQrcode && !paid"
class="pc-cashier__pay-btn"
:disabled="paying || !selectId"
@click="pay"
>
{{ paying ? t('cashier.paying') : t('cashier.payNow') }}
</button>
</template>
</div>
</div>
</template>

View File

@@ -1,5 +1,5 @@
/**
* 网关聚合扫码 API
* 网关聚合扫码 / 收银台 API
*/
import { RequestEnum } from '@/shared/enums/httpEnum'
import { http } from '@/shared/utils/http/axios'
@@ -26,7 +26,7 @@ export interface GatewayOrderInfo {
returnUrl?: string
}
/** 聚合支付结果 */
/** 聚合/收银台支付结果 */
export interface AggregatePayResult {
orderId?: string | number
bizOrderNo?: string
@@ -36,6 +36,16 @@ export interface AggregatePayResult {
payBodyType?: string
}
/** 收银台支付项(公开字段) */
export interface CashierItemPublic {
/** 支付项ID(后端 Long, 前端用 string 避免精度问题) */
id: string
name?: string
icon?: string
recommend?: boolean
sortNo?: number
}
/** 查询网关订单 */
export function getGatewayOrder(orderNo: string): Promise<GatewayOrderInfo> {
return http.request<GatewayOrderInfo>({
@@ -63,3 +73,47 @@ export function aggregatePay(data: {
isShowMessage: false,
})
}
/** 收银台支付项列表 */
export function listCashierItems(params: {
orderNo: string
cashierType: string
clientEnv?: string
}): Promise<CashierItemPublic[]> {
return http.request<CashierItemPublic[]>({
url: '/client/gateway/cashier/items',
method: RequestEnum.GET,
params,
}, {
isShowMessage: false,
}).then((list) => {
// id 统一为 string
return (list || []).map(item => ({
...item,
id: String(item.id),
}))
})
}
/** 收银台发起支付 */
export function cashierPay(data: {
orderNo: string
itemId: string | number
cashierType: string
clientEnv?: string
openId?: string
device?: string
clientIp?: string
}): Promise<AggregatePayResult> {
return http.request<AggregatePayResult>({
url: '/client/gateway/cashier/pay',
method: RequestEnum.POST,
data: {
...data,
// 后端 Long; 字符串数字可被 Jackson 反序列化
itemId: data.itemId,
},
}, {
isShowMessage: false,
})
}

View File

@@ -16,5 +16,10 @@
"wechat": "WeChat Pay",
"alipay": "Alipay",
"union_pay": "UnionPay"
}
},
"loadFail": "Failed to load order",
"payFail": "Failed to start payment",
"paid": "Payment successful",
"paying": "Paying...",
"emptyItems": "No payment methods available"
}

View File

@@ -16,5 +16,10 @@
"wechat": "Pembayaran WeChat",
"alipay": "Alipay",
"union_pay": "UnionPay"
}
},
"loadFail": "Gagal memuat pesanan",
"payFail": "Gagal memulai pembayaran",
"paid": "Pembayaran berhasil",
"paying": "Membayar...",
"emptyItems": "Tidak ada metode pembayaran"
}

View File

@@ -16,5 +16,10 @@
"wechat": "ウィーチャットペイ",
"alipay": "アリペイ",
"union_pay": "銀聯"
}
},
"loadFail": "注文の読み込みに失敗しました",
"payFail": "支払いの開始に失敗しました",
"paid": "支払い成功",
"paying": "支払い中...",
"emptyItems": "利用可能な支払い方法がありません"
}

View File

@@ -16,5 +16,10 @@
"wechat": "위챗페이",
"alipay": "알리페이",
"union_pay": "유니온페이"
}
},
"loadFail": "주문 로드 실패",
"payFail": "결제 시작 실패",
"paid": "결제 성공",
"paying": "결제 중...",
"emptyItems": "사용 가능한 결제 수단이 없습니다"
}

View File

@@ -16,5 +16,10 @@
"wechat": "WeChat Pay",
"alipay": "Alipay",
"union_pay": "UnionPay"
}
},
"loadFail": "Gagal memuatkan pesanan",
"payFail": "Gagal memulakan pembayaran",
"paid": "Pembayaran berjaya",
"paying": "Membayar...",
"emptyItems": "Tiada kaedah pembayaran"
}

View File

@@ -16,5 +16,10 @@
"wechat": "วีแชต เพย์",
"alipay": "อาลีเพย์",
"union_pay": "ยูเนี่ยนเพย์"
}
},
"loadFail": "โหลดคำสั่งซื้อไม่สำเร็จ",
"payFail": "เริ่มชำระเงินไม่สำเร็จ",
"paid": "ชำระเงินสำเร็จ",
"paying": "กำลังชำระเงิน...",
"emptyItems": "ไม่มีช่องทางชำระเงิน"
}

View File

@@ -16,5 +16,10 @@
"wechat": "WeChat trả tiền",
"alipay": "Alipay",
"union_pay": "UnionPay"
}
},
"loadFail": "Không tải được đơn hàng",
"payFail": "Không thể bắt đầu thanh toán",
"paid": "Thanh toán thành công",
"paying": "Đang thanh toán...",
"emptyItems": "Không có phương thức thanh toán"
}

View File

@@ -16,5 +16,10 @@
"wechat": "微信支付",
"alipay": "支付宝",
"union_pay": "银联支付"
}
},
"loadFail": "加载订单失败",
"payFail": "支付发起失败",
"paid": "支付成功",
"paying": "支付中...",
"emptyItems": "暂无可用支付方式"
}

View File

@@ -16,5 +16,10 @@
"wechat": "微信支付",
"alipay": "支付寶",
"union_pay": "銀聯支付"
}
},
"loadFail": "載入訂單失敗",
"payFail": "支付發起失敗",
"paid": "支付成功",
"paying": "支付中...",
"emptyItems": "暫無可用支付方式"
}

View File

@@ -16,5 +16,10 @@
"wechat": "微信支付",
"alipay": "支付寶",
"union_pay": "銀聯支付"
}
},
"loadFail": "載入訂單失敗",
"payFail": "支付發起失敗",
"paid": "支付成功",
"paying": "支付中...",
"emptyItems": "暫無可用支付方式"
}

View File

@@ -19,8 +19,8 @@ export const RoutePath = {
CASHIER: '/cashier/:orderNo',
/** 聚合扫码支付(跨端:两端各实现) */
AGGREGATE: '/aggregate/:orderNo',
/** 码牌支付(移动独占) */
CODE_PAY: '/code-pay/:code',
/** 码牌支付 H5(移动独占, path 与后端 getCodeLink /h/{code} 一致 */
CODE_PAY: '/h/:code',
/** 商户对账单PC 独占) */
MERCHANT_STATEMENT: '/merchant/statement',
/** 协议展示页(跨端:用户协议/隐私政策,链接可独立分享) */