This commit is contained in:
parent
96b3b1a798
commit
ba32064545
|
|
@ -1,5 +1,9 @@
|
|||
export default defineAppConfig({
|
||||
pages: ['pages/index/index'],
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/corp/index',
|
||||
'pages/pay/index',
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#F8F8F8',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export default definePageConfig({
|
||||
navigationBarTitleText: '首页',
|
||||
})
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
.corp-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.corp-page__error {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.corp-page__error-image {
|
||||
width: 192rpx;
|
||||
height: 192rpx;
|
||||
margin-top: 140rpx;
|
||||
}
|
||||
|
||||
.corp-page__error-text {
|
||||
width: 504rpx;
|
||||
min-height: 112rpx;
|
||||
margin-top: 82rpx;
|
||||
font-family: AlibabaPuHuiTi, sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 36rpx;
|
||||
color: #f64f50;
|
||||
line-height: 56rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { Image, View, WebView } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
|
||||
import './index.css'
|
||||
import { normalizeQrLinkCode, resolveQrScene } from './scene'
|
||||
|
||||
const LINK_PREFIX = '/p/'
|
||||
|
||||
export default function CorpPage() {
|
||||
const [url, setUrl] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
useLoad((options) => {
|
||||
const scene = resolveQrScene(options)
|
||||
console.log("options", options)
|
||||
const linkCode = normalizeQrLinkCode(scene)
|
||||
|
||||
if (!linkCode) {
|
||||
setErrorMessage(scene ? 'scene 参数无效' : '缺少 scene 参数')
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const loginResult = await Taro.login()
|
||||
setUrl(
|
||||
`http://nb.zuom8.cn${LINK_PREFIX}${encodeURIComponent(linkCode)}?mpCode=${encodeURIComponent(loginResult.code)}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize corporate page.', error)
|
||||
Taro.showToast({
|
||||
title: '页面加载失败',
|
||||
icon: 'none',
|
||||
})
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
return (
|
||||
<View className='corp-page'>
|
||||
{url ? <WebView src={url} /> : null}
|
||||
{errorMessage ? (
|
||||
<View className='corp-page__error'>
|
||||
<Image
|
||||
className='corp-page__error-image'
|
||||
src='/static/image/payerr.png'
|
||||
/>
|
||||
<View className='corp-page__error-text'>{errorMessage}</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeQrLinkCode, resolveQrScene } from './scene'
|
||||
|
||||
describe('resolveQrScene', () => {
|
||||
it('prefers a direct scene parameter', () => {
|
||||
expect(resolveQrScene({ scene: 'DIRECT', q: 'https%3A%2F%2Fexample.com%3Fscene%3DFROM_Q' })).toBe(
|
||||
'DIRECT',
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts scene from the encoded QR-code URL', () => {
|
||||
expect(
|
||||
resolveQrScene({
|
||||
q: 'https%3A%2F%2Fpay.batiao8.com%2Fh5%2Fpay%2Fcorp.html%3Fscene%3DPJQQ',
|
||||
scancode_time: '1784110188',
|
||||
$taroTimestamp: 1784110189254,
|
||||
}),
|
||||
).toBe('PJQQ')
|
||||
})
|
||||
|
||||
it('returns an empty string when neither source contains scene', () => {
|
||||
expect(resolveQrScene()).toBe('')
|
||||
expect(resolveQrScene({ q: 'https%3A%2F%2Fpay.batiao8.com%2Fh5%2Fpay%2Fcorp.html' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeQrLinkCode', () => {
|
||||
it('does not use a default link code when scene is missing', () => {
|
||||
expect(normalizeQrLinkCode()).toBeNull()
|
||||
expect(normalizeQrLinkCode('')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when scene is invalid', () => {
|
||||
expect(normalizeQrLinkCode('/p/')).toBeNull()
|
||||
expect(normalizeQrLinkCode('%E0%A4%A')).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts the link code from supported scene formats', () => {
|
||||
expect(normalizeQrLinkCode('/p/ABC123')).toBe('ABC123')
|
||||
expect(normalizeQrLinkCode('link=%2Fp%2FABC123')).toBe('ABC123')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
const LINK_PREFIX = '/p/'
|
||||
|
||||
function isValidLinkCode(code: string): boolean {
|
||||
return Boolean(code) && !/[/?#&=]/.test(code)
|
||||
}
|
||||
|
||||
export function resolveQrScene(options?: Record<string, unknown>): string {
|
||||
const directScene = typeof options?.scene === 'string' ? options.scene.trim() : ''
|
||||
if (directScene) {
|
||||
return directScene
|
||||
}
|
||||
|
||||
const qrCodeUrl = typeof options?.q === 'string' ? options.q.trim() : ''
|
||||
if (!qrCodeUrl) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(qrCodeUrl)
|
||||
const queryStart = decodedUrl.indexOf('?')
|
||||
if (queryStart === -1) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return new URLSearchParams(decodedUrl.slice(queryStart + 1)).get('scene')?.trim() || ''
|
||||
} catch (error) {
|
||||
console.warn('Invalid QR-code URL.', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeQrLinkCode(scene?: string): string | null {
|
||||
if (!scene) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedScene = decodeURIComponent(scene)
|
||||
const linkParam = new URLSearchParams(decodedScene).get('link')
|
||||
const rawCode = (linkParam || decodedScene).trim()
|
||||
const code = rawCode.startsWith(LINK_PREFIX)
|
||||
? rawCode.slice(LINK_PREFIX.length)
|
||||
: rawCode
|
||||
|
||||
return isValidLinkCode(code) ? code : null
|
||||
} catch (error) {
|
||||
console.warn('Invalid QR-code scene.', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default definePageConfig({
|
||||
navigationBarTitleText: '支付',
|
||||
})
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
.pay-page {
|
||||
min-height: 100vh;
|
||||
padding: 48px 32px;
|
||||
box-sizing: border-box;
|
||||
background: #f6f7fb;
|
||||
}
|
||||
|
||||
.pay-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
padding: 40px 28px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e8ebf1;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.pay-card--success {
|
||||
border-color: #17a34a;
|
||||
}
|
||||
|
||||
.pay-card--failed {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.pay-card--canceled {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.pay-label {
|
||||
color: #667085;
|
||||
font-size: 28px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.pay-amount {
|
||||
margin-top: 16px;
|
||||
color: #ff2626;
|
||||
font-size: 72px;
|
||||
font-weight: 700;
|
||||
line-height: 88px;
|
||||
}
|
||||
|
||||
.pay-status {
|
||||
margin-top: 20px;
|
||||
color: #344054;
|
||||
font-size: 28px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pay-info {
|
||||
margin-top: 24px;
|
||||
padding: 8px 28px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e8ebf1;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.pay-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.pay-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.pay-row__label {
|
||||
flex: 0 0 144px;
|
||||
color: #667085;
|
||||
font-size: 26px;
|
||||
line-height: 38px;
|
||||
}
|
||||
|
||||
.pay-row__label--heading {
|
||||
color: #000000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pay-row__value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #111827;
|
||||
font-size: 26px;
|
||||
line-height: 38px;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pay-plan {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pay-plan__icons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pay-plan__icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.pay-button {
|
||||
height: 88px;
|
||||
margin-top: 32px;
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
font-size: 32px;
|
||||
line-height: 88px;
|
||||
background: #07c160;
|
||||
}
|
||||
|
||||
.pay-button[disabled] {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: #84d8a9;
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { Button, Image, Text, View } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { decodeOrderParam } from '@/utils/base64'
|
||||
|
||||
import './index.css'
|
||||
|
||||
type PayStatus = 'loading' | 'paying' | 'success' | 'failed' | 'canceled'
|
||||
|
||||
type OrderInfo = {
|
||||
qrcode?: string | null
|
||||
orderId: string
|
||||
jumpUrl?: string | null
|
||||
qrcodeUrl?: string | null
|
||||
payFee?: number
|
||||
appId?: string
|
||||
timeStamp?: string
|
||||
nonceStr?: string
|
||||
package?: string
|
||||
signType?: string
|
||||
paySign?: string
|
||||
icon?: string
|
||||
outTradeNo?: string
|
||||
}
|
||||
|
||||
type FormInfo = {
|
||||
goods_type: string
|
||||
entity_phone?: string
|
||||
extra: {
|
||||
entity_address: string
|
||||
entity_address_name: string
|
||||
}
|
||||
}
|
||||
|
||||
type SelectedPlan = {
|
||||
name: string
|
||||
icon: string[]
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error && 'errMsg' in error) {
|
||||
return String((error as { errMsg?: string }).errMsg)
|
||||
}
|
||||
|
||||
return '支付失败'
|
||||
}
|
||||
|
||||
function hasPayParams(info: OrderInfo): boolean {
|
||||
return Boolean(info.timeStamp && info.nonceStr && info.package && info.paySign)
|
||||
}
|
||||
|
||||
export default function PayPage() {
|
||||
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null)
|
||||
const [formInfo, setFormInfo] = useState<FormInfo | null>(null)
|
||||
const [selectedPlan, setSelectedPlan] = useState<SelectedPlan | null>(null)
|
||||
const [status, setStatus] = useState<PayStatus>('loading')
|
||||
const [message, setMessage] = useState('正在准备订单')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const requestPay = async (info: OrderInfo): Promise<void> => {
|
||||
if (loading) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setStatus('paying')
|
||||
setMessage('正在拉起微信支付')
|
||||
|
||||
try {
|
||||
if (Taro.getEnv() === Taro.ENV_TYPE.WEAPP) {
|
||||
await new Promise((resolve, reject) => {
|
||||
Taro.requestPayment({
|
||||
timeStamp: info.timeStamp as string,
|
||||
nonceStr: info.nonceStr as string,
|
||||
package: info.package as string,
|
||||
signType: (info.signType as 'RSA') || 'RSA',
|
||||
paySign: info.paySign as string,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
}
|
||||
|
||||
setStatus('success')
|
||||
setMessage('支付成功')
|
||||
Taro.showToast({
|
||||
title: '支付成功',
|
||||
icon: 'success',
|
||||
})
|
||||
setTimeout(() => {
|
||||
Taro.navigateBack()
|
||||
}, 800)
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error)
|
||||
const isCancel = errorMessage.includes('cancel')
|
||||
|
||||
setStatus(isCancel ? 'canceled' : 'failed')
|
||||
setMessage(isCancel ? '已取消支付,可重新发起' : errorMessage)
|
||||
Taro.showToast({
|
||||
title: isCancel ? '已取消支付' : '支付失败',
|
||||
icon: 'none',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useLoad(() => {
|
||||
const { order, formInfo: encodedFormInfo, selectedPlanInfo } =
|
||||
Taro.getCurrentInstance().router?.params ?? {}
|
||||
const info = decodeOrderParam<OrderInfo>(order)
|
||||
const form = decodeOrderParam<FormInfo>(encodedFormInfo)
|
||||
const plan = decodeOrderParam<SelectedPlan>(selectedPlanInfo)
|
||||
|
||||
if (!info || !hasPayParams(info)) {
|
||||
setStatus('failed')
|
||||
setMessage('订单信息异常,无法发起支付')
|
||||
Taro.showToast({
|
||||
title: '订单信息异常',
|
||||
icon: 'none',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setOrderInfo(info)
|
||||
setFormInfo(form)
|
||||
setSelectedPlan(plan)
|
||||
void requestPay(info)
|
||||
})
|
||||
|
||||
return (
|
||||
<View className='pay-page'>
|
||||
<View className={`pay-card pay-card--${status}`}>
|
||||
<Text className='pay-label'>支付金额</Text>
|
||||
<Text className='pay-amount'>
|
||||
<Text style={{ fontSize: '40rpx' }}>¥</Text>
|
||||
{orderInfo?.payFee}
|
||||
</Text>
|
||||
<Text className='pay-status'>{message}</Text>
|
||||
</View>
|
||||
|
||||
{orderInfo ? (
|
||||
<View className='pay-info'>
|
||||
<View className='pay-row'>
|
||||
<Text className='pay-row__label pay-row__label--heading'>订单信息</Text>
|
||||
</View>
|
||||
<View className='pay-row'>
|
||||
<Text className='pay-row__label'>订单号</Text>
|
||||
<Text className='pay-row__value'>{orderInfo.outTradeNo}</Text>
|
||||
</View>
|
||||
<View className='pay-row'>
|
||||
<Text className='pay-row__label'>创建时间</Text>
|
||||
<Text className='pay-row__value'>{orderInfo.timeStamp}</Text>
|
||||
</View>
|
||||
<View className='pay-row'>
|
||||
<Text className='pay-row__label pay-row__label--heading'>商品信息</Text>
|
||||
</View>
|
||||
<View className='pay-row'>
|
||||
<Text className='pay-row__label'>商品类型</Text>
|
||||
<View className='pay-row__value pay-plan'>
|
||||
<View className='pay-plan__icons'>
|
||||
{selectedPlan?.icon?.map((icon, index) => (
|
||||
<Image className='pay-plan__icon' key={`${icon}-${index}`} src={icon} />
|
||||
))}
|
||||
</View>
|
||||
<Text>{selectedPlan?.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='pay-row'>
|
||||
<Text className='pay-row__label'>标注地址</Text>
|
||||
<Text className='pay-row__value'>{formInfo?.extra?.entity_address_name}</Text>
|
||||
</View>
|
||||
<View className='pay-row'>
|
||||
<Text className='pay-row__label'>地图位置</Text>
|
||||
<Text className='pay-row__value'>{formInfo?.extra?.entity_address}</Text>
|
||||
</View>
|
||||
<View className='pay-row'>
|
||||
<Text className='pay-row__label'>联系电话</Text>
|
||||
<Text className='pay-row__value'>{formInfo?.entity_phone}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{orderInfo ? (
|
||||
<Button
|
||||
className='pay-button'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={() => void requestPay(orderInfo)}
|
||||
>
|
||||
{loading ? '支付中' : status === 'success' ? '支付完成' : '立即支付'}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
const BASE64_CHARS =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
|
||||
function base64Decode(input: string): string {
|
||||
const cleaned = input.replace(/[^A-Za-z0-9+/]/g, '')
|
||||
let output = ''
|
||||
|
||||
for (let index = 0; index < cleaned.length; index += 4) {
|
||||
const first = BASE64_CHARS.indexOf(cleaned[index])
|
||||
const second = BASE64_CHARS.indexOf(cleaned[index + 1])
|
||||
const third = BASE64_CHARS.indexOf(cleaned[index + 2])
|
||||
const fourth = BASE64_CHARS.indexOf(cleaned[index + 3])
|
||||
|
||||
output += String.fromCharCode((first << 2) | (second >> 4))
|
||||
|
||||
if (third !== -1) {
|
||||
output += String.fromCharCode(((second & 15) << 4) | (third >> 2))
|
||||
}
|
||||
if (fourth !== -1) {
|
||||
output += String.fromCharCode(((third & 3) << 6) | fourth)
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export function base64ToUtf8(raw: string): string {
|
||||
const normalized = raw.replace(/ /g, '+')
|
||||
return decodeURIComponent(escape(base64Decode(normalized)))
|
||||
}
|
||||
|
||||
export function decodeOrderParam<T>(raw?: string): T | null {
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(base64ToUtf8(raw)) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue