This commit is contained in:
zhangjianjun 2026-07-15 18:11:18 +08:00
parent 96b3b1a798
commit ba32064545
10 changed files with 561 additions and 1 deletions

View File

@ -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',

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '首页',
})

28
src/pages/corp/index.css Normal file
View File

@ -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;
}

54
src/pages/corp/index.tsx Normal file
View File

@ -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>
)
}

View File

@ -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')
})
})

50
src/pages/corp/scene.ts Normal file
View File

@ -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
}
}

View File

@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '支付',
})

130
src/pages/pay/index.css Normal file
View File

@ -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;
}

203
src/pages/pay/index.tsx Normal file
View File

@ -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>
)
}

42
src/utils/base64.ts Normal file
View File

@ -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
}
}