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