From ba32064545f11426d55a53f14e347f6d4e89fbc9 Mon Sep 17 00:00:00 2001 From: zhangjianjun Date: Wed, 15 Jul 2026 18:11:18 +0800 Subject: [PATCH] ud --- src/app.config.ts | 6 +- src/pages/corp/index.config.ts | 3 + src/pages/corp/index.css | 28 +++++ src/pages/corp/index.tsx | 54 +++++++++ src/pages/corp/scene.test.ts | 43 +++++++ src/pages/corp/scene.ts | 50 ++++++++ src/pages/pay/index.config.ts | 3 + src/pages/pay/index.css | 130 +++++++++++++++++++++ src/pages/pay/index.tsx | 203 +++++++++++++++++++++++++++++++++ src/utils/base64.ts | 42 +++++++ 10 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 src/pages/corp/index.config.ts create mode 100644 src/pages/corp/index.css create mode 100644 src/pages/corp/index.tsx create mode 100644 src/pages/corp/scene.test.ts create mode 100644 src/pages/corp/scene.ts create mode 100644 src/pages/pay/index.config.ts create mode 100644 src/pages/pay/index.css create mode 100644 src/pages/pay/index.tsx create mode 100644 src/utils/base64.ts diff --git a/src/app.config.ts b/src/app.config.ts index 60c6c0b..c60086f 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -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', diff --git a/src/pages/corp/index.config.ts b/src/pages/corp/index.config.ts new file mode 100644 index 0000000..a7c25c7 --- /dev/null +++ b/src/pages/corp/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '首页', +}) diff --git a/src/pages/corp/index.css b/src/pages/corp/index.css new file mode 100644 index 0000000..34b8c8b --- /dev/null +++ b/src/pages/corp/index.css @@ -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; +} diff --git a/src/pages/corp/index.tsx b/src/pages/corp/index.tsx new file mode 100644 index 0000000..0b57ce8 --- /dev/null +++ b/src/pages/corp/index.tsx @@ -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 ( + + {url ? : null} + {errorMessage ? ( + + + {errorMessage} + + ) : null} + + ) +} diff --git a/src/pages/corp/scene.test.ts b/src/pages/corp/scene.test.ts new file mode 100644 index 0000000..b27b6d5 --- /dev/null +++ b/src/pages/corp/scene.test.ts @@ -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') + }) +}) diff --git a/src/pages/corp/scene.ts b/src/pages/corp/scene.ts new file mode 100644 index 0000000..4569cd4 --- /dev/null +++ b/src/pages/corp/scene.ts @@ -0,0 +1,50 @@ +const LINK_PREFIX = '/p/' + +function isValidLinkCode(code: string): boolean { + return Boolean(code) && !/[/?#&=]/.test(code) +} + +export function resolveQrScene(options?: Record): 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 + } +} diff --git a/src/pages/pay/index.config.ts b/src/pages/pay/index.config.ts new file mode 100644 index 0000000..9152bf1 --- /dev/null +++ b/src/pages/pay/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '支付', +}) diff --git a/src/pages/pay/index.css b/src/pages/pay/index.css new file mode 100644 index 0000000..1ddbd6d --- /dev/null +++ b/src/pages/pay/index.css @@ -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; +} diff --git a/src/pages/pay/index.tsx b/src/pages/pay/index.tsx new file mode 100644 index 0000000..5cf87fc --- /dev/null +++ b/src/pages/pay/index.tsx @@ -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(null) + const [formInfo, setFormInfo] = useState(null) + const [selectedPlan, setSelectedPlan] = useState(null) + const [status, setStatus] = useState('loading') + const [message, setMessage] = useState('正在准备订单') + const [loading, setLoading] = useState(false) + + const requestPay = async (info: OrderInfo): Promise => { + 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(order) + const form = decodeOrderParam(encodedFormInfo) + const plan = decodeOrderParam(selectedPlanInfo) + + if (!info || !hasPayParams(info)) { + setStatus('failed') + setMessage('订单信息异常,无法发起支付') + Taro.showToast({ + title: '订单信息异常', + icon: 'none', + }) + return + } + + setOrderInfo(info) + setFormInfo(form) + setSelectedPlan(plan) + void requestPay(info) + }) + + return ( + + + 支付金额 + + + {orderInfo?.payFee} + + {message} + + + {orderInfo ? ( + + + 订单信息 + + + 订单号 + {orderInfo.outTradeNo} + + + 创建时间 + {orderInfo.timeStamp} + + + 商品信息 + + + 商品类型 + + + {selectedPlan?.icon?.map((icon, index) => ( + + ))} + + {selectedPlan?.name} + + + + 标注地址 + {formInfo?.extra?.entity_address_name} + + + 地图位置 + {formInfo?.extra?.entity_address} + + + 联系电话 + {formInfo?.entity_phone} + + + ) : null} + + {orderInfo ? ( + + ) : null} + + ) +} diff --git a/src/utils/base64.ts b/src/utils/base64.ts new file mode 100644 index 0000000..113ce1c --- /dev/null +++ b/src/utils/base64.ts @@ -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(raw?: string): T | null { + if (!raw) { + return null + } + + try { + return JSON.parse(base64ToUtf8(raw)) as T + } catch { + return null + } +}