--- url: >- https://acquirer-api-docs-v4-en.pingpongx.com/notes/zh/integrate/sdk-v4-2/index.md description: 说明 OnePage Checkout 的一页式组件接入流程,覆盖 sdkAccessToken 初始化、prePay 创建订单和前端提交支付。 --- ## 接入摘要 OnePage Checkout 是适用于一页式结账场景的站内支付组件方案。商户服务端先调用 [获取 sdkAccessToken 接口](/notes/zh/checkout/api/getAccessToken/) 获取初始化凭证,商户前端再在现有结账页面中引入 OnePage JavaScript-SDK 并渲染 PingPong 支付组件。买家点击支付后,商户前端通常还需要通过 `beforeCheckoutHook` 调用商户后端创建订单,由商户后端进一步调用 [prePay(预下单)接口](/notes/zh/checkout/api/reserve/) 获取支付 token,并返回给 SDK 发起支付。该方案能够让地址、配送、订单摘要与支付区域在同一页面联动,减少页面跳转,同时保留商户站内结账体验。 ::: tip 适用场景 适合已经采用 OnePage Checkout 结账页,且需要在同一页面内联动金额、国家、商品信息与支付组件的团队;若只需要通用站内收银台,可参考[内嵌 SDK](/notes/zh/integrate/sdk-v4-preview/)。 ::: ## 支付流程 ```mermaid %%{init: { 'theme': 'base', 'themeVariables': { 'primaryColor': '#E3F2FD', 'primaryTextColor': '#0D47A1', 'primaryBorderColor': '#1976D2', 'lineColor': '#1565C0', 'secondaryColor': '#BBDEFB', 'tertiaryColor': '#90CAF9', 'background': '#F8FBFF', 'mainBkg': '#E3F2FD', 'secondBkg': '#BBDEFB', 'tertiaryBkg': '#90CAF9', 'actorBkg': '#2196F3', 'actorBorder': '#1976D2', 'actorTextColor': '#FFFFFF', 'actorLineColor': '#1565C0', 'signalColor': '#0D47A1', 'signalTextColor': '#0D47A1', 'c0': '#E8F4FD', 'c1': '#D1E7DD', 'c2': '#B3D9FF', 'c3': '#81C784', 'noteBkgColor': '#E1F5FE', 'noteTextColor': '#01579B', 'noteBorderColor': '#0288D1', 'loopTextColor': '#0D47A1', 'activationBkgColor': '#B3E5FC', 'activationBorderColor': '#0277BD' } }}%% %% agent-tag: onepage-checkout-flow sequenceDiagram participant Frontend as 💻 商户前端 participant Merchant as 🏪 商户后端 participant SDK as 📦 JS-SDK participant PP as 🔄 PingPong服务端 Note over Merchant, Frontend: 📋 准备阶段 Merchant->>PP: 1. 调用获取 sdkAccessToken 接口 PP-->>Merchant: 2. 返回 sdkAccessToken Merchant-->>Frontend: 3. 返回初始化参数 Note over Frontend, SDK: 🧩 收银台初始化阶段 Frontend->>SDK: 4. 引入 JS-SDK 并调用 PingPong.Checkout.create() SDK-->>Frontend: 5. 渲染支付组件 opt 订单信息变化 Frontend->>SDK: 6. 调用 updateCheckoutHook 更新 amount / tradeCountry / goods SDK-->>Frontend: 7. 完成局部更新 end Note over Frontend, PP: 💳 支付处理阶段 Frontend->>SDK: 8. 买家点击支付按钮 opt 支付前校验 SDK->>Frontend: 9. 触发 beforeCheckoutHook Frontend->>Merchant: 10. 调用商户下单服务 Merchant->>PP: 11. 调用 prePay(预下单)接口 PP-->>Merchant: 12. 返回支付 token Merchant-->>Frontend: 13. 返回支付 token Frontend-->>SDK: 14. 在 beforeCheckoutHook 中返回 token end SDK->>PP: 15. 携带 token 发起支付请求 alt 支付成功 PP-->>SDK: 16. 返回支付成功结果 SDK-->>Frontend: 17. 触发 checkoutResultHook(status=success) 或更新支付成功状态 else 支付处理中 PP-->>SDK: 18. 返回支付处理中结果 SDK-->>Frontend: 19. 触发 checkoutResultHook(status=pending) else 支付失败 PP-->>SDK: 20. 返回支付失败结果 SDK-->>Frontend: 21. 触发 checkoutResultHook / checkoutFailedHook 或展示失败信息 end ``` ::: steps 1. 获取初始化凭证 - 商户服务端调用 [获取 sdkAccessToken 接口](/notes/zh/checkout/api/getAccessToken/)。 - 获取用于初始化 OnePage Checkout 的 `sdkAccessToken`,并返回给前端页面。 2. 初始化收银台 - 商户前端引入与环境匹配的 OnePage JavaScript-SDK。 - 在结账页插入 `pp-checkout` 组件,并调用 `PingPong.Checkout.create()` 传入 `amount`、`currency`、`tradeCountry`、`sdkAccessToken` 等参数。 3. 渲染并联动支付组件 - PingPong 支付组件在商户结账页内完成渲染并展示可用支付方式。 - 当金额、国家或商品信息变化时,可调用 `updateCheckoutHook` 对收银台做局部更新。 4. 创建支付订单 - 买家在当前结账页内选择支付方式并点击支付按钮。 - SDK 触发 `beforeCheckoutHook` 后,商户前端可调用商户后端创建订单。 - 商户后端再调用 [prePay(预下单)接口](/notes/zh/checkout/api/reserve/) 获取支付 token,并将该 token 返回给前端。 - 前端在 `beforeCheckoutHook` 中将支付 token 返回给 SDK。 5. 提交支付并处理结果 - SDK 拿到支付 token 后,才会正式向 PingPong 发起支付请求。 - 支付成功或处理中时,可通过 `checkoutResultHook` 接收前端支付结果,并更新当前结账页状态或进入后续结果展示流程。 - 支付失败时,可使用 `checkoutResultHook` 统一接收失败结果;如历史上已配置 `checkoutFailedHook`,失败场景会优先走 `checkoutFailedHook`。 ::: ::: warning 关键注意事项 1. `sdkAccessToken` 仅用于初始化 OnePage Checkout,不等同于支付时使用的订单 token; 2. 支付 token 需要在买家点击支付后,由商户后端调用 [prePay(预下单)接口](/notes/zh/checkout/api/reserve/) 创建订单并返回给前端 SDK; 3. 请确保引入的 OnePage JavaScript-SDK 地址与当前运行环境一致,避免沙箱与生产环境脚本混用; 4. `updateCheckoutHook` 采用局部更新机制,只传入发生变化的字段即可,无需重复传未变化参数; 5. 当 `originalPay` 设置为 `false` 时,需要由商户自定义支付按钮并手动调用 `PingPong.Checkout.pay.run()` 触发支付; 6. 若配置 `paymentMethods` 参数,实际展示结果会与当前 `accId` 已开通的支付方式取交集,展示顺序以传入数组顺序为准; 7. `checkoutResultHook` 是浏览器侧回调,仅用于前端页面展示或跳转,最终支付结果仍应以服务端异步通知或交易查询为准。 ::: ## 集成流程 ### 1. 获取 sdkAccessToken 商户后端系统通过获取 sdkAccessToken 接口 得到 JS-SDK 访问凭证(token),该凭证用于前端 JS-SDK 的初始化调用。 ### 2. 引入 JavaScript-SDK 复制以下代码,通过 CDN 地址引入 PingPong OnePage JavaScript-SDK。 ::: code-tabs @tab 🧪 沙箱环境 ```html ``` @tab 🇪🇺 FRA 生产环境 ```html ``` @tab 🇸🇬 SG 生产环境 ```html ``` @tab 🇺🇸 US 生产环境 ```html ``` ::: ### 3. 初始化收银台 将 ```pp-checkout``` 标签插入 ```html``` ```body``` 中 ```html:title="components/checkout.html" :line-numbers ``` 调用 ```PingPong.Checkout.create``` 完成初始化 #### 入参 ::::: field-group :::: field name="amount" type="String" required 交易金额 该交易仅用于收银台展示,后续金额变更需调用 `updateCheckoutHook` 函数进行更新 :::: :::: field name="currency" type="String" required 交易币种 :::: :::: field name="tradeCountry" type="String" required 用于指定 PingPong 收银台国家 后续国家变更需调用 `updateCheckoutHook` 函数进行更新 :::: :::: field name="sdkAccessToken" type="String" required JS-SDK 访问凭证 :::: :::: field name="originalPay" type="Boolean" optional default="true" 控制是否使用默认的 PingPong 支付按钮 若将其设置为 `false`,则表示不使用内置支付按钮。此时,需要在页面内自定义支付按钮点击事件中调用 `PingPong.Checkout.pay.run()` 方法来触发支付流程。 :::: :::: field name="isChallengeIframe" type="Boolean" optional default="false" 控制是否使用 iframe 展示 3DS Challenge 页面 当设置为 `true` 时,将通过 iframe 承载 Challenge 流程。 :::: :::: field name="paymentMethods" type="String[]" optional 指定支付方式列表,内嵌收银台会根据指定的支付方式进行展示 传入的支付方式将与该 `accId` 下配置的支付方式进行交集运算。支付方式的展示顺序将根据传入数组的顺序进行排列。 :::: :::: field name="useTabMode" type="Boolean" optional default="true" 控制是否展示支付方式选项框 当设置为 `false` 时,如果商户仅有卡支付配置,将不展示选项框,直接显示支付表单。适用于简化用户体验的场景。 :::: :::: field name="goods" type="Array" optional 商品列表,包含以下子字段 :::: :::: field-group goods 子字段 ::: field name="goods.description" type="String" optional 商品描述 ::: ::: field name="goods.imgUrl" type="String" optional 商品图片 URL ::: ::: field name="goods.name" type="String" required 商品名称 ::: ::: field name="goods.number" type="String" required 商品数量 ::: ::: field name="goods.sku" type="String" optional 商品 SKU 编码 ::: ::: field name="goods.unitPrice" type="String" required 商品单价 ::: ::: field name="goods.virtualProduct" type="String" optional default="N" 是否为虚拟商品 `Y` - 虚拟商品,`N` - 实物商品 ::: :::: :::: field name="bizType" type="String" optional ApplePay 绑卡业务参数 ApplePay 交易必传,固定值: `CodeGrant` :::: :::: field name="recurringInfoDTO" type="Object" optional 配置 Recurring 付款(ApplePay 交易必传),包含以下子字段 :::: :::: field-group recurringInfoDTO 子字段 ::: field name="recurringInfoDTO.recurringPaymentStartDate" type="Date" optional 首次付款日期 eg: `"2024-06-01 00:00:00"` ::: ::: field name="recurringInfoDTO.recurringPaymentIntervalUnit" type="String" optional 表示年、月、日、时等日历单位的类型 enum: `year` / `month` / `day` / `hour` / `minute`,eg: `"month"` ::: ::: field name="recurringInfoDTO.recurringPaymentIntervalCount" type="String" optional 构成总支付间隔的间隔单位数 eg: `"6"` ::: ::: field name="recurringInfoDTO.recurringPaymentEndDate" type="Date" optional 最后付款日期 eg: `"2024-12-01 00:00:00"` ::: :::: ::::: ```js:line-numbers // 创建 PingPong 收银台 PingPong.Checkout.create({ amount: '1.08', // 交易金额 currency: 'USD', // 交易币种 tradeCountry: 'US', // 交易国家 originalPay: true, isChallengeIframe: true, // 使用 iframe 展示 3DS Challenge 页面 useTabMode: false, // 当仅有卡支付时,隐藏选项框 sdkAccessToken, // SDK访问令牌 paymentMethods: ['VISA', 'Klarna'], goods: [{ description: 'short legs', imgUrl: 'http://pic.bizhi360.com/bpic/30/5230.jpg', name: '한국어/English', number: '1', sku: '20230524001', unitPrice: '1', virtualProduct: 'N' }], // ApplePay 支付参数 bizType: 'CodeGrant', // ApplePay 交易必传 recurringInfoDTO: { recurringPaymentStartDate: "2024-06-01 00:00:00", recurringPaymentIntervalUnit: "month", recurringPaymentIntervalCount: "6", recurringPaymentEndDate: "2024-12-01 00:00:00" }, }) ``` #### 自定义支付按钮(可选) ```js:line-numbers title="src/config/payment.js" // 自定义支付按钮配置 // 初始化参数中 originalPay 为 false 时, 需自定义支付按钮点击事件 document.querySelector('#pay').onclick = function () { PingPong.Checkout.pay.run() // 手动触发支付 } ``` #### 事件监听(可选) SDK 支持监听初始化过程中的 ready 和 error 事件,方便外部进行状态管理和错误处理。 ```js:line-numbers title="src/config/payment.js" // 监听 SDK 初始化事件 // 监听初始化成功事件 document.querySelector('pp-checkout').addEventListener('ready', (e) => { console.log('SDK初始化成功'); // 初始化成功回调 // 可以在此处执行初始化完成后的逻辑 }); // 监听初始化失败事件 document.querySelector('pp-checkout').addEventListener('error', (e) => { console.log('SDK初始化失败', e.detail); // 错误信息 // 可以在此处执行错误处理逻辑,如显示错误提示、重试等 }); ``` **事件说明:** - `ready`: 当 SDK 初始化成功时触发,表示收银台已准备就绪 - `error`: 当 SDK 初始化失败时触发,事件详情包含在 `e.detail` 中 ### 4. 修改金额、国家和商品 在使用全局变量前,请确保Javascript-SDK 加载完成。 #### updateCheckoutHook(可选) ```updateCheckoutHook``` 用来更新收银台要素。 ##### ts 类型 ```ts:line-numbers title="src/types/hooks.ts" // updateCheckoutHook 类型定义 PingPong.Checkout.updateCheckoutHook: ({ amount?: string, tradeCountry?: string, goods?: Goods[] }) => void ``` ##### 使用说明 > ⚠️ **重要**:该方法采用**部分更新**机制,只需传入发生变化的字段即可,无需传入其他字段。 | 场景 | 传入参数 | 示例代码 | |------|----------|----------| | 国家发生变化 | `tradeCountry` | `PingPong.Checkout.updateCheckoutHook({ tradeCountry: newCountry });` | | 金额发生变化 | `amount` | `PingPong.Checkout.updateCheckoutHook({ amount: newAmount });` | | 商品发生变化 | `goods` | `PingPong.Checkout.updateCheckoutHook({ goods: newGoods });` | ##### 正确示例 ```js:line-numbers title="src/config/update.js" // 场景1: 用户切换国家 - 只传 tradeCountry const newCountry = 'US'; PingPong.Checkout.updateCheckoutHook({ tradeCountry: newCountry }); // 场景2: 用户使用优惠券后金额变化 - 只传 amount const newAmount = '99.99'; PingPong.Checkout.updateCheckoutHook({ amount: newAmount }); // 场景3: 商品信息变更 - 只传 goods const newGoods = [{ description: 'updated description', imgUrl: 'http://pic.bizhi360.com/bpic/30/5230.jpg', name: 'Updated Product Name', number: '2', sku: '20230524001', unitPrice: '2', virtualProduct: 'N' }]; PingPong.Checkout.updateCheckoutHook({ goods: newGoods }); ``` ##### ❌ 错误示例 > 以下方式**不推荐**,传入不必要的字段或 null 值: ```js:line-numbers title="src/config/update-error.js" // ❌ 错误: 不需要传入未变化的字段,也不需要传入 null PingPong.Checkout.updateCheckoutHook({ amount: newAmount ? newAmount : null, tradeCountry: newCountry ? newCountry : null, goods: newGoods ? newGoods : null }); // ❌ 错误: 只更新金额时,不需要同时传入 country 和 goods PingPong.Checkout.updateCheckoutHook({ amount: '99.99', tradeCountry: 'US', // 未变化的字段,不需要传 goods: [] // 未变化的字段,不需要传 }); ``` ### 5. 下单前检验 #### beforeCheckoutHook(可选) type: ```ts:line-numbers title="src/types/hooks.ts" // beforeCheckoutHook 类型定义 (({payMethod}) => void) | (({payMethod}) => Promise) ``` `beforeCheckoutHook` 用来设置发起支付请求前的钩子函数。 当你在用户点击支付按钮,发起支付请求前,需要执行你自己的业务逻辑,如:上报埋点、检查库存等,可以设置该钩子函数。 该函数接收一个包含 `payMethod` 的对象参数,表示用户选择的支付方式。 该函数可以返回一个`Promise`,后续的支付流程会等待该 Promise 状态变为 Fulfilled 后才会继续执行。如果你想在 Promise 状态为 Rejected 或者异步结果不满足你的业务条件时,可以抛出异常,SDK在捕获到异常后中断支付流程。 ::: note 典型接入方式 OnePage Checkout 的常见接法是:商户前端在 `beforeCheckoutHook` 中调用商户后端创建订单,商户后端再调用 [prePay(预下单)接口](/notes/zh/checkout/api/reserve/) 获取支付 token,最后由 `beforeCheckoutHook` 将该 token 返回给 SDK,SDK 再继续提交支付。 ::: ```js:line-numbers title="src/config/payment.js" PingPong.Checkout.beforeCheckoutHook = async ({payMethod}) => { console.log('用户选择的支付方式:', payMethod); const inventoryRes = await fetch('/api/requestInventory'); const {inventoryQuantity} = await inventoryRes.json(); if (inventoryQuantity < MIN_QUANTITY) { throw new Error('库存不足,需中断交易'); } // 调用商户后端创建订单,由商户后端进一步调用 prePay(预下单)接口 const orderRes = await fetch('/api/orders/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ payMethod }) }); const { token } = await orderRes.json(); if (!token) { throw new Error('订单创建失败,未获取到支付 token'); } return token; }; ``` ##### ts 类型 ```ts:line-numbers title="src/types/hooks.ts" // beforeCheckoutHook 返回类型定义 PingPong.Checkout.beforeCheckoutHook: (({payMethod}) => string) | (({payMethod}) => Promise) ``` ### 6. 发起支付 买家点击支付按钮后,SDK 会先等待 `beforeCheckoutHook` 返回支付 token;在拿到由商户后端调用prePay(预下单)接口创建的支付 token 后,才会继续发起支付请求并完成支付处理。 ### 7. 支付结果处理 #### checkoutResultHook(可选) type: ```ts:line-numbers title="src/types/hooks.ts" // checkoutResultHook 类型定义 (payload: CheckoutResultHookPayload) => void | Promise ``` `checkoutResultHook` 用于接收 SDK 前端可直接得到的支付结果,并由商户前端接管支付完成后的页面展示或跳转。请在用户发起支付前注册该 Hook。该 Hook 挂载在 `PingPong.Checkout` 上,不是 `` 标签属性。 `checkoutResultHook` 会把支付结果统一收敛为以下三种状态: | 状态 | 含义 | 建议处理 | |:---|:---|:---| | `success` | 支付成功 | 更新当前结账页状态,展示商户成功页,或跳转到商户成功页 | | `pending` | 支付处理中或待确认,包含 SDK 内部的 `PENDING`、`PROCESSING`、`REVIEW` 等非终态场景 | 展示处理中页面,并通过服务端查询或异步通知确认最终结果 | | `failed` | 支付失败 | 展示失败页,提供重试或返回商户站点的入口 | Payload 类型定义: ```ts:line-numbers title="src/types/checkout-result.ts" type CheckoutResultHookPayload = { status: 'success' | 'pending' | 'failed'; code: string; message: string; merchantTransactionId?: string; paymentMethod?: string; }; ``` Payload 字段说明: | 字段 | 类型 | 是否必返 | 说明 | |:---|:---|:---:|:---| | `status` | `success` \| `pending` \| `failed` | M | SDK 归一化后的前端支付结果 | | `code` | `string` | M | 支付结果码或错误码;无特定错误时可能返回 `0` | | `message` | `string` | M | 支付结果描述或错误描述 | | `merchantTransactionId` | `string` | O | 商户订单号 | | `paymentMethod` | `string` | O | 支付方式 | Payload 示例: ```json { "status": "success", "code": "0", "message": "Payment successful", "merchantTransactionId": "ORDER_202607200001", "paymentMethod": "VISA" } ``` 接入示例: ```js:line-numbers title="src/config/payment.js" PingPong.Checkout.checkoutResultHook = async (payload) => { const orderNo = encodeURIComponent(payload.merchantTransactionId || ''); if (payload.status === 'success') { window.location.href = `/payment/success?orderNo=${orderNo}`; return; } if (payload.status === 'pending') { window.location.href = `/payment/pending?orderNo=${orderNo}`; return; } window.location.href = `/payment/failed?code=${encodeURIComponent(payload.code)}`; }; ``` 触发与跳转规则: | 商户配置 | `success` / `pending` 场景 | `failed` 场景 | 结果页跳转 | |:---|:---|:---|:---| | 未配置 Hook | 不触发 Hook | 不触发 Hook | 保持原有结果页跳转逻辑 | | 仅配置 `checkoutResultHook` | 触发 `checkoutResultHook` | 触发 `checkoutResultHook` | 命中 Hook 后不再跳转 PingPong 结果页 | | 仅配置 `checkoutFailedHook` | 不触发 Hook | 触发 `checkoutFailedHook` | 保持原有失败处理逻辑 | | 同时配置两个 Hook | 触发 `checkoutResultHook` | 优先触发 `checkoutFailedHook` | 失败场景不重复回调 | ::: warning 注意 - `checkoutResultHook` 是浏览器侧回调,不能作为商户后台更新订单状态的唯一依据。最终支付结果请以 PingPong 服务端异步通知或订单查询接口为准。 - 如果同时配置 `checkoutResultHook` 和 `checkoutFailedHook`,支付失败场景会优先触发 `checkoutFailedHook`,SDK 不会再重复触发 `checkoutResultHook`。如需由一个 Hook 统一处理 `success`、`pending`、`failed`,请只配置 `checkoutResultHook`。 - `pending` 不是失败。请展示处理中状态,并在商户后台继续等待最终支付结果。 - Hook 支持返回 `Promise`,SDK 会等待 Promise 完成。请避免在 Hook 内执行长时间阻塞逻辑。 - Hook 内部异常不会恢复 PingPong 结果页跳转。商户应在 Hook 内自行处理异常,并准备兜底页面。 ::: ### 8. 错误处理 #### checkoutFailedHook(可选) type: ```ts:line-numbers title="src/types/hooks.ts" // checkoutFailedHook 类型定义 (() => void) | (() => Promise) ``` checkoutFailedHook 接收以下参数: ```ts:line-numbers title="src/types/hooks.ts" // checkoutFailedHook 参数类型定义 (code: string, message: string) => void | Promise; // code: string - 错误码 // message: string - 错误消息 ``` `checkoutFailedHook` 用来自定义错误逻辑 当用户支付失败时,PingPong 默认会弹窗提示用户失败原因。如果你想自定义弹窗 UI 或文本,可以设置该钩子函数。 该函数可以返回一个 Promise。如果返回 Promise,后续的流程会等待该 Promise 状态变为 Fulfilled 后才继续执行 ```js:line-numbers title="src/config/payment.js" PingPong.Checkout.checkoutFailedHook = (code: string, message: string) => { notification.open({ message: 'Error title', description: `${code}: ${message}` }) }; ``` ## 使用示例 ::: code-tree title="PingPong Checkout 集成示例" height="500px" ```html title="examples/onepage/pages/checkout.html" PingPong Checkout Demo

Billing Information

Shipping Information

``` ```css title="examples/onepage/assets/css/checkout.css" /** * PingPong Checkout 样式文件 * 负责结账页面的所有样式定义 */ /* ========== 变量定义 ========== */ :root { --primary-color: #007bff; --primary-hover: #0056b3; --success-color: #28a745; --error-color: #dc3545; --warning-color: #ffc107; --border-color: #e1e5e9; --background-light: #f8f9fa; --text-primary: #333; --text-secondary: #666; --shadow-sm: 0 2px 4px rgba(0,0,0,0.1); --shadow-md: 0 2px 8px rgba(0,0,0,0.1); --shadow-lg: 0 4px 12px rgba(0,123,255,0.3); --border-radius: 8px; --border-radius-sm: 4px; --border-radius-lg: 12px; --transition: all 0.3s ease; } /* ========== 基础样式重置 ========== */ * { box-sizing: border-box; } body { margin: 0; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background-color: var(--background-light); color: var(--text-primary); line-height: 1.6; } /* ========== 主容器 ========== */ .checkout-container { max-width: 800px; margin: 0 auto; padding: 24px; background: white; border-radius: var(--border-radius); box-shadow: var(--shadow-md); } /* ========== 结账区域 ========== */ .checkout-section { margin-bottom: 32px; } .billing-form, .shipping-form { margin-bottom: 24px; } .billing-form h3, .shipping-form h3 { margin: 0 0 16px 0; color: var(--text-primary); font-size: 18px; font-weight: 600; } /* ========== 表单组件 ========== */ .form-group { margin-bottom: 16px; } .form-group label { display: block; margin-bottom: 6px; font-weight: 600; color: var(--text-primary); font-size: 14px; } .form-group input, .form-group select { width: 100%; max-width: 320px; padding: 10px 12px; border: 1px solid var(--border-color); border-radius: var(--border-radius-sm); font-size: 14px; transition: border-color 0.2s ease, box-shadow 0.2s ease; background: white; } .form-group input:focus, .form-group select:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(0,123,255,0.1); } .form-group input::placeholder { color: #999; } /* ========== PingPong 收银台 ========== */ .pingpong-checkout { margin: 24px 0; padding: 20px; background: white; border: 1px solid var(--border-color); border-radius: var(--border-radius); box-shadow: var(--shadow-sm); } /* ========== 支付区域 ========== */ .payment-section { text-align: center; padding: 24px; background: linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%); border-radius: var(--border-radius); border: 1px solid var(--border-color); } /* ========== 优惠券区域 ========== */ .coupon-section { margin-bottom: 20px; } .coupon-section label { display: block; margin-bottom: 8px; font-weight: 600; color: var(--text-primary); font-size: 14px; } .coupon-section input { width: 100%; max-width: 280px; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: var(--border-radius); font-size: 14px; transition: var(--transition); background: white; } .coupon-section input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(0,123,255,0.1); } /* ========== 支付按钮 ========== */ .pay-button { background: linear-gradient(135deg, var(--primary-color) 0%, #0056b3 100%); color: white; border: none; padding: 16px 48px; border-radius: var(--border-radius); cursor: pointer; font-size: 16px; font-weight: 700; transition: var(--transition); min-width: 200px; position: relative; overflow: hidden; text-transform: uppercase; letter-spacing: 0.5px; } .pay-button:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); background: linear-gradient(135deg, #0056b3 0%, var(--primary-hover) 100%); } .pay-button:active { transform: translateY(0); box-shadow: var(--shadow-md); } .pay-button:disabled { background: #6c757d; cursor: not-allowed; transform: none; box-shadow: none; opacity: 0.7; } .pay-button.loading { color: transparent; pointer-events: none; } .pay-button.loading::after { content: ''; position: absolute; width: 20px; height: 20px; top: 50%; left: 50%; margin-left: -10px; margin-top: -10px; border: 2px solid white; border-radius: 50%; border-top-color: transparent; animation: spin 1s ease-in-out infinite; } /* ========== 消息提示 ========== */ .message { padding: 12px 16px; border-radius: var(--border-radius-sm); margin: 16px 0; font-size: 14px; font-weight: 500; border-left: 4px solid; } .message.error { background: #f8d7da; border-left-color: var(--error-color); color: #721c24; } .message.success { background: #d4edda; border-left-color: var(--success-color); color: #155724; } .message.warning { background: #fff3cd; border-left-color: var(--warning-color); color: #856404; } .message.info { background: #d1ecf1; border-left-color: var(--primary-color); color: #0c5460; } /* ========== 加载状态 ========== */ .loading-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.8); display: flex; align-items: center; justify-content: center; z-index: 9999; } .loading-overlay .spinner { width: 40px; height: 40px; border: 4px solid var(--border-color); border-top-color: var(--primary-color); border-radius: 50%; animation: spin 1s linear infinite; } /* ========== 动画定义 ========== */ @keyframes spin { to { transform: rotate(360deg); } } @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } @keyframes slideIn { from { transform: translateX(-100%); } to { transform: translateX(0); } } /* ========== 响应式设计 ========== */ @media (max-width: 768px) { body { padding: 10px; } .checkout-container { padding: 16px; border-radius: var(--border-radius-sm); } .form-group input, .form-group select { max-width: 100%; } .coupon-section input { max-width: 100%; } .pay-button { width: 100%; min-width: unset; padding: 14px 24px; } .pingpong-checkout { padding: 16px; } } @media (max-width: 480px) { .checkout-container { padding: 12px; } .payment-section { padding: 20px 16px; } } /* ========== 打印样式 ========== */ @media print { .pay-button, .loading-overlay { display: none !important; } .checkout-container { box-shadow: none; border: 1px solid #ddd; } } /* ========== 可访问性增强 ========== */ @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } /* 高对比度模式支持 */ @media (prefers-contrast: high) { :root { --border-color: #000; --text-primary: #000; --background-light: #fff; } } /* ========== 工具类 ========== */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } .text-center { text-align: center !important; } .text-left { text-align: left !important; } .text-right { text-align: right !important; } .mb-0 { margin-bottom: 0 !important; } .mb-1 { margin-bottom: 8px !important; } .mb-2 { margin-bottom: 16px !important; } .mb-3 { margin-bottom: 24px !important; } .mb-4 { margin-bottom: 32px !important; } ``` ```javascript title="examples/onepage/js/config/app-config.js" /** * 应用配置文件 * 包含所有应用级别的配置常量 */ /** * PingPong SDK 配置 */ export const PINGPONG_CONFIG = { // SDK 基础配置 amount: '1.08', currency: 'USD', tradeCountry: 'US', originalPay: false, isChallengeIframe: false, useTabMode: true, sdkAccessToken: 'your-sdk-access-token-here', paymentMethods: ['VISA', 'MasterCard', 'Klarna'], goods: [{ description: 'short legs', imgUrl: 'http://pic.bizhi360.com/bpic/30/5230.jpg', name: '한국어/English', number: '1', sku: '20230524001', unitPrice: '1', virtualProduct: 'N' }], // ApplePay 配置(可选) applePay: { bizType: 'CodeGrant', recurringInfoDTO: { recurringPaymentStartDate: '', recurringPaymentIntervalUnit: 'month', recurringPaymentIntervalCount: '', recurringPaymentEndDate: '' } } }; /** * API 端点配置 */ export const API_ENDPOINTS = { // PingPong API getAccessToken: '/api/auth/sdk-token', createOrder: '/api/orders/create', validateInventory: '/api/inventory/validate', // 业务 API validateCoupon: '/api/coupons/validate', logError: '/api/logs/error', // 支付相关 paymentStatus: '/api/payments/status', refundPayment: '/api/payments/refund' }; /** * 国家货币映射配置 */ export const COUNTRY_CURRENCY_MAP = { 'US': 'USD', 'GB': 'GBP', 'DE': 'EUR', 'FR': 'EUR', 'IT': 'EUR', 'ES': 'EUR', 'CA': 'CAD', 'AU': 'AUD', 'JP': 'JPY', 'CN': 'CNY' }; /** * 错误代码映射配置 */ export const ERROR_MESSAGES = { 'PAYMENT_DECLINED': '支付被拒绝,请检查卡片信息或尝试其他支付方式', 'INSUFFICIENT_FUNDS': '余额不足,请使用其他支付方式', 'INVALID_CARD': '卡片信息无效,请检查后重试', 'CARD_EXPIRED': '卡片已过期,请使用其他卡片', 'NETWORK_ERROR': '网络连接异常,请检查网络后重试', 'TIMEOUT': '支付超时,请重试', 'FRAUD_DETECTED': '安全验证失败,请联系客服', 'CURRENCY_NOT_SUPPORTED': '不支持该货币,请选择其他货币', 'AMOUNT_INVALID': '支付金额无效', 'AMOUNT_TOO_LOW': '支付金额过低', 'AMOUNT_TOO_HIGH': '支付金额过高', 'MERCHANT_ERROR': '商户系统错误,请联系客服', 'SDK_TOKEN_EXPIRED': '访问凭证已过期,请刷新页面重试', 'SDK_TOKEN_INVALID': '访问凭证无效,请联系客服' }; /** * 应用常量配置 */ export const APP_CONSTANTS = { // 防抖延迟时间(毫秒) DEBOUNCE_DELAY: { AMOUNT_INPUT: 500, COUNTRY_CHANGE: 0, COUPON_INPUT: 1000 }, // 验证规则 VALIDATION: { MIN_AMOUNT: 0.01, MAX_AMOUNT: 999999.99, COUPON_LENGTH_MIN: 3, COUPON_LENGTH_MAX: 50 }, // 超时配置(毫秒) TIMEOUTS: { PAYMENT: 300000, // 5分钟 API_CALL: 30000, // 30秒 MESSAGE_DISPLAY: { ERROR: 5000, SUCCESS: 3000, WARNING: 4000 } }, // 重试配置 RETRY: { MAX_ATTEMPTS: 3, DELAY_BASE: 1000 } }; /** * 开发环境配置 */ export const DEV_CONFIG = { isDevelopment: process.env.NODE_ENV === 'development', enableDebugLogs: true, mockAPI: false, mockCouponCode: 'SAVE10' // 模拟有效的优惠券代码 }; /** * 默认导出所有配置 */ export default { pingpong: PINGPONG_CONFIG, api: API_ENDPOINTS, countryCurrency: COUNTRY_CURRENCY_MAP, errorMessages: ERROR_MESSAGES, constants: APP_CONSTANTS, dev: DEV_CONFIG }; ``` ```javascript title="examples/onepage/js/services/pingpong-service.js" /** * PingPong SDK 服务 * 负责与 PingPong SDK 的所有交互 */ import { PINGPONG_CONFIG, ERROR_MESSAGES } from '../config/app-config.js'; /** * PingPong 服务类 */ export class PingPongService { constructor() { this.isInitialized = false; this.eventListeners = new Map(); } /** * 初始化 PingPong Checkout * @param {Object} config - 配置对象 * @returns {Promise} */ async initialize(config = {}) { try { // 检查 SDK 是否已加载 if (typeof PingPong === 'undefined') { throw new Error('PingPong SDK 未加载'); } // 合并配置 const finalConfig = { ...PINGPONG_CONFIG, ...config }; // 初始化收银台 await PingPong.Checkout.create(finalConfig); this.isInitialized = true; this.setupEventListeners(); console.log('✅ PingPong Checkout 初始化成功'); } catch (error) { console.error('❌ PingPong Checkout 初始化失败:', error); throw error; } } /** * 更新收银台参数 * @param {Object} updates - 更新的参数 * @returns {void} */ updateCheckout(updates) { try { if (!this.isInitialized) { throw new Error('PingPong Checkout 未初始化'); } if (!PingPong?.Checkout?.updateCheckoutHook) { throw new Error('PingPong SDK updateCheckoutHook 方法不可用'); } // 验证参数 const validUpdates = this.validateUpdateParams(updates); // 调用 SDK 更新方法 PingPong.Checkout.updateCheckoutHook(validUpdates); console.log('✅ 收银台更新成功:', validUpdates); } catch (error) { console.error('❌ 更新收银台失败:', error); throw error; } } /** * 开始支付流程 * @returns {Promise} */ async startPayment() { try { if (!this.isInitialized) { throw new Error('PingPong Checkout 未初始化'); } if (!PingPong?.Checkout?.pay?.run) { throw new Error('PingPong 支付方法不可用'); } await PingPong.Checkout.pay.run(); console.log('💰 支付流程已启动'); } catch (error) { console.error('❌ 启动支付失败:', error); throw error; } } /** * 设置支付前 Hook * @param {Function} hook - Hook 函数 */ setBeforeCheckoutHook(hook) { if (typeof hook !== 'function') { throw new Error('beforeCheckoutHook 必须是函数'); } PingPong.Checkout.beforeCheckoutHook = hook; console.log('✅ beforeCheckoutHook 设置成功'); } /** * 设置支付结果 Hook * @param {Function} hook - Hook 函数 */ setCheckoutResultHook(hook) { if (typeof hook !== 'function') { throw new Error('checkoutResultHook 必须是函数'); } PingPong.Checkout.checkoutResultHook = hook; console.log('✅ checkoutResultHook 设置成功'); } /** * 设置支付失败 Hook * @param {Function} hook - Hook 函数 */ setCheckoutFailedHook(hook) { if (typeof hook !== 'function') { throw new Error('checkoutFailedHook 必须是函数'); } PingPong.Checkout.checkoutFailedHook = hook; console.log('✅ checkoutFailedHook 设置成功'); } /** * 设置事件监听器 */ setupEventListeners() { const checkoutElement = document.querySelector('pp-checkout'); if (checkoutElement) { // 初始化成功事件 checkoutElement.addEventListener('ready', (e) => { console.log('🚀 SDK初始化成功,收银台准备就绪'); this.emit('ready', e.detail); }); // 初始化失败事件 checkoutElement.addEventListener('error', (e) => { console.error('💥 SDK初始化失败:', e.detail); this.emit('error', e.detail); }); console.log('✅ 事件监听器设置完成'); } } /** * 添加自定义事件监听器 * @param {string} event - 事件名称 * @param {Function} callback - 回调函数 */ on(event, callback) { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, []); } this.eventListeners.get(event).push(callback); } /** * 移除事件监听器 * @param {string} event - 事件名称 * @param {Function} callback - 回调函数 */ off(event, callback) { if (this.eventListeners.has(event)) { const listeners = this.eventListeners.get(event); const index = listeners.indexOf(callback); if (index > -1) { listeners.splice(index, 1); } } } /** * 触发事件 * @param {string} event - 事件名称 * @param {*} data - 事件数据 */ emit(event, data) { if (this.eventListeners.has(event)) { this.eventListeners.get(event).forEach(callback => { try { callback(data); } catch (error) { console.error(`事件处理器执行失败 [${event}]:`, error); } }); } } /** * 验证更新参数 * @param {Object} updates - 更新参数 * @returns {Object} 验证后的参数 */ validateUpdateParams(updates) { const validUpdates = {}; // 验证金额 if (updates.amount !== undefined) { const amount = parseFloat(updates.amount); if (isNaN(amount) || amount <= 0) { throw new Error('金额必须是大于0的数字'); } validUpdates.amount = amount.toFixed(2); } // 验证国家 if (updates.tradeCountry !== undefined) { if (!updates.tradeCountry || typeof updates.tradeCountry !== 'string') { throw new Error('国家代码必须是有效的字符串'); } validUpdates.tradeCountry = updates.tradeCountry; } // 验证货币 if (updates.currency !== undefined) { if (!updates.currency || typeof updates.currency !== 'string') { throw new Error('货币代码必须是有效的字符串'); } validUpdates.currency = updates.currency; } return validUpdates; } /** * 获取 SDK 版本信息 * @returns {string} SDK 版本 */ getVersion() { return typeof PingPong !== 'undefined' ? PingPong.version : 'unknown'; } /** * 检查 SDK 是否可用 * @returns {boolean} SDK 是否可用 */ isSDKAvailable() { return typeof PingPong !== 'undefined' && typeof PingPong.Checkout !== 'undefined'; } /** * 销毁服务 */ destroy() { this.eventListeners.clear(); this.isInitialized = false; console.log('🔧 PingPong 服务已销毁'); } } // 创建单例实例 export const pingpongService = new PingPongService(); export default pingpongService; ``` ```javascript title="examples/onepage/js/utils/ui-utils.js" /** * UI 工具函数 * 负责界面操作和状态管理 */ import { ERROR_MESSAGES, APP_CONSTANTS } from '../config/app-config.js'; /** * 消息管理器 */ export class MessageManager { constructor() { this.activeMessages = new Set(); } /** * 显示消息 * @param {string} message - 消息内容 * @param {string} type - 消息类型 (error, success, warning, info) * @param {number} duration - 显示时长(毫秒) */ show(message, type = 'info', duration = null) { // 移除现有消息 this.clear(); // 创建消息元素 const messageEl = document.createElement('div'); messageEl.className = `message ${type}`; messageEl.textContent = message; // 设置 ARIA 属性 messageEl.setAttribute('role', 'alert'); messageEl.setAttribute('aria-live', 'polite'); // 插入到页面 const container = document.querySelector('.checkout-container'); if (container) { container.insertBefore(messageEl, container.firstChild); } else { document.body.insertBefore(messageEl, document.body.firstChild); } this.activeMessages.add(messageEl); // 自动移除 const timeout = duration || this.getDefaultDuration(type); setTimeout(() => { this.remove(messageEl); }, timeout); return messageEl; } /** * 移除消息 * @param {HTMLElement} messageEl - 消息元素 */ remove(messageEl) { if (messageEl && messageEl.parentNode) { messageEl.parentNode.removeChild(messageEl); this.activeMessages.delete(messageEl); } } /** * 清除所有消息 */ clear() { this.activeMessages.forEach(messageEl => { this.remove(messageEl); }); } /** * 获取默认显示时长 * @param {string} type - 消息类型 * @returns {number} 时长(毫秒) */ getDefaultDuration(type) { return APP_CONSTANTS.TIMEOUTS.MESSAGE_DISPLAY[type.toUpperCase()] || 3000; } /** * 显示错误消息 * @param {string} message - 错误消息 * @param {number} duration - 显示时长 */ error(message, duration) { return this.show(message, 'error', duration); } /** * 显示成功消息 * @param {string} message - 成功消息 * @param {number} duration - 显示时长 */ success(message, duration) { return this.show(message, 'success', duration); } /** * 显示警告消息 * @param {string} message - 警告消息 * @param {number} duration - 显示时长 */ warning(message, duration) { return this.show(message, 'warning', duration); } /** * 显示信息消息 * @param {string} message - 信息消息 * @param {number} duration - 显示时长 */ info(message, duration) { return this.show(message, 'info', duration); } } /** * 加载状态管理器 */ export class LoadingManager { constructor() { this.loadingOverlay = null; this.loadingButtons = new Map(); } /** * 显示全屏加载 * @param {string} message - 加载消息 */ showGlobal(message = 'Loading...') { if (this.loadingOverlay) return; this.loadingOverlay = document.createElement('div'); this.loadingOverlay.className = 'loading-overlay'; this.loadingOverlay.innerHTML = `

${message}

`; document.body.appendChild(this.loadingOverlay); } /** * 隐藏全屏加载 */ hideGlobal() { if (this.loadingOverlay && this.loadingOverlay.parentNode) { this.loadingOverlay.parentNode.removeChild(this.loadingOverlay); this.loadingOverlay = null; } } /** * 显示按钮加载状态 * @param {HTMLElement} button - 按钮元素 * @param {string} loadingText - 加载时显示的文本 */ showButtonLoading(button, loadingText = 'Processing...') { if (!button || this.loadingButtons.has(button)) return; // 保存原始状态 const originalText = button.textContent; const originalDisabled = button.disabled; // 设置加载状态 button.disabled = true; button.textContent = loadingText; button.classList.add('loading'); // 保存状态 this.loadingButtons.set(button, { originalText, originalDisabled, loadingText }); } /** * 隐藏按钮加载状态 * @param {HTMLElement} button - 按钮元素 */ hideButtonLoading(button) { if (!button || !this.loadingButtons.has(button)) return; const savedState = this.loadingButtons.get(button); // 恢复原始状态 button.disabled = savedState.originalDisabled; button.textContent = savedState.originalText; button.classList.remove('loading'); // 清除保存的状态 this.loadingButtons.delete(button); } /** * 隐藏所有按钮加载状态 */ hideAllButtonLoading() { this.loadingButtons.forEach((_, button) => { this.hideButtonLoading(button); }); } } /** * 表单验证工具 */ export class FormValidator { constructor() { this.rules = new Map(); } /** * 添加验证规则 * @param {string} fieldId - 字段ID * @param {Array} rules - 验证规则数组 */ addRule(fieldId, rules) { this.rules.set(fieldId, rules); } /** * 验证单个字段 * @param {string} fieldId - 字段ID * @returns {Object} 验证结果 */ validateField(fieldId) { const field = document.getElementById(fieldId); if (!field) { return { valid: false, error: '字段不存在' }; } const rules = this.rules.get(fieldId) || []; const value = field.value.trim(); for (const rule of rules) { const result = rule(value, field); if (!result.valid) { return result; } } return { valid: true }; } /** * 验证整个表单 * @param {Array} fieldIds - 要验证的字段ID数组 * @returns {Object} 验证结果 */ validateForm(fieldIds = []) { const errors = []; const validFields = []; for (const fieldId of fieldIds) { const result = this.validateField(fieldId); if (result.valid) { validFields.push(fieldId); } else { errors.push(`${fieldId}: ${result.error}`); } } return { isValid: errors.length === 0, errors, validFields }; } /** * 清除字段错误状态 * @param {string} fieldId - 字段ID */ clearFieldError(fieldId) { const field = document.getElementById(fieldId); if (field) { field.classList.remove('error'); const errorEl = field.parentNode.querySelector('.field-error'); if (errorEl) { errorEl.remove(); } } } /** * 显示字段错误 * @param {string} fieldId - 字段ID * @param {string} error - 错误信息 */ showFieldError(fieldId, error) { const field = document.getElementById(fieldId); if (!field) return; // 添加错误样式 field.classList.add('error'); // 移除现有错误信息 this.clearFieldError(fieldId); // 添加错误信息 const errorEl = document.createElement('div'); errorEl.className = 'field-error'; errorEl.textContent = error; errorEl.setAttribute('role', 'alert'); field.parentNode.appendChild(errorEl); } } /** * 通用验证规则 */ export const ValidationRules = { required: (message = '此字段为必填项') => (value) => ({ valid: value.length > 0, error: message }), min: (min, message) => (value) => { const num = parseFloat(value); return { valid: !isNaN(num) && num >= min, error: message || `值不能小于 ${min}` }; }, max: (max, message) => (value) => { const num = parseFloat(value); return { valid: !isNaN(num) && num <= max, error: message || `值不能大于 ${max}` }; }, email: (message = '请输入有效的邮箱地址') => (value) => ({ valid: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value), error: message }), minLength: (min, message) => (value) => ({ valid: value.length >= min, error: message || `长度不能少于 ${min} 个字符` }), maxLength: (max, message) => (value) => ({ valid: value.length <= max, error: message || `长度不能超过 ${max} 个字符` }), pattern: (regex, message) => (value) => ({ valid: regex.test(value), error: message || '格式不正确' }) }; /** * 防抖函数 * @param {Function} func - 要防抖的函数 * @param {number} delay - 延迟时间(毫秒) * @returns {Function} 防抖后的函数 */ export function debounce(func, delay) { let timeoutId; return function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => func.apply(this, args), delay); }; } /** * 格式化货币 * @param {number} amount - 金额 * @param {string} currency - 货币代码 * @param {string} locale - 地区代码 * @returns {string} 格式化后的货币 */ export function formatCurrency(amount, currency = 'USD', locale = 'en-US') { return new Intl.NumberFormat(locale, { style: 'currency', currency: currency }).format(amount); } // 创建单例实例 export const messageManager = new MessageManager(); export const loadingManager = new LoadingManager(); export const formValidator = new FormValidator(); export default { messageManager, loadingManager, formValidator, ValidationRules, debounce, formatCurrency }; ``` ```javascript:line-numbers:collapsed-lines=10 title="examples/onepage/js/main.js" /** * 主入口文件 * 负责应用初始化和模块协调 */ import { pingpongService } from './services/pingpong-service.js'; import { messageManager, loadingManager, formValidator, debounce, ValidationRules } from './utils/ui-utils.js'; import { PINGPONG_CONFIG, API_ENDPOINTS, COUNTRY_CURRENCY_MAP, ERROR_MESSAGES, APP_CONSTANTS, DEV_CONFIG } from './config/app-config.js'; /** * 主应用类 */ class CheckoutApp { constructor() { this.isInitialized = false; this.currentOrder = null; this.paymentInProgress = false; } /** * 初始化应用 */ async initialize() { try { console.log('🚀 开始初始化 Checkout 应用...'); // 设置表单验证规则 this.setupFormValidation(); // 初始化 PingPong SDK await this.initializePingPong(); // 设置事件监听器 this.setupEventListeners(); // 设置支付 Hooks this.setupPaymentHooks(); this.isInitialized = true; messageManager.success('收银台初始化完成'); console.log('✅ Checkout 应用初始化完成'); } catch (error) { console.error('❌ 应用初始化失败:', error); messageManager.error(`初始化失败: ${error.message}`); } } /** * 设置表单验证规则 */ setupFormValidation() { // 金额验证 formValidator.addRule('amount', [ ValidationRules.required('请输入支付金额'), ValidationRules.min(APP_CONSTANTS.VALIDATION.MIN_AMOUNT, '金额不能小于 0.01'), ValidationRules.max(APP_CONSTANTS.VALIDATION.MAX_AMOUNT, '金额不能大于 999,999.99') ]); // 国家验证 formValidator.addRule('country', [ ValidationRules.required('请选择国家') ]); // 优惠券验证(可选) formValidator.addRule('coupon', [ ValidationRules.minLength(APP_CONSTANTS.VALIDATION.COUPON_LENGTH_MIN, '优惠券代码至少3个字符'), ValidationRules.maxLength(APP_CONSTANTS.VALIDATION.COUPON_LENGTH_MAX, '优惠券代码不能超过50个字符') ]); } /** * 初始化 PingPong SDK */ async initializePingPong() { try { // 获取 SDK 访问令牌 const accessToken = await this.getSDKAccessToken(); // 初始化配置 const config = { ...PINGPONG_CONFIG, sdkAccessToken: accessToken }; await pingpongService.initialize(config); } catch (error) { console.error('PingPong 初始化失败:', error); throw new Error('收银台初始化失败,请刷新页面重试'); } } /** * 设置事件监听器 */ setupEventListeners() { // 金额输入事件 const amountInput = document.getElementById('amount'); if (amountInput) { amountInput.addEventListener('input', debounce(this.handleAmountChange.bind(this), APP_CONSTANTS.DEBOUNCE_DELAY.AMOUNT_INPUT) ); } // 国家选择事件 const countrySelect = document.getElementById('country'); if (countrySelect) { countrySelect.addEventListener('change', this.handleCountryChange.bind(this) ); } // 优惠券输入事件 const couponInput = document.getElementById('coupon'); if (couponInput) { couponInput.addEventListener('input', debounce(this.handleCouponChange.bind(this), APP_CONSTANTS.DEBOUNCE_DELAY.COUPON_INPUT) ); } // 支付按钮点击事件 const payButton = document.getElementById('customPay'); if (payButton) { payButton.addEventListener('click', this.handlePaymentClick.bind(this) ); } // PingPong 事件监听 pingpongService.on('ready', this.handlePingPongReady.bind(this)); pingpongService.on('error', this.handlePingPongError.bind(this)); console.log('✅ 事件监听器设置完成'); } /** * 设置支付 Hooks */ setupPaymentHooks() { // 支付前 Hook pingpongService.setBeforeCheckoutHook(async ({payMethod}) => { console.log('🔍 开始支付前验证...'); console.log('💳 用户选择的支付方式:', payMethod); // 表单验证 const validation = formValidator.validateForm(['amount', 'country']); if (!validation.isValid) { throw new Error(validation.errors.join('; ')); } // 显示加载状态 this.paymentInProgress = true; loadingManager.showButtonLoading( document.getElementById('customPay'), 'Processing Payment...' ); try { // 库存检查 await this.checkInventory(); // 创建订单 const orderToken = await this.createOrder(); console.log('✅ 支付前验证完成'); return orderToken; } catch (error) { this.paymentInProgress = false; loadingManager.hideAllButtonLoading(); throw error; } }); // 支付结果 Hook:接管支付完成后的页面展示或跳转 pingpongService.setCheckoutResultHook(async (payload) => { console.log('💳 支付结果回调:', payload); this.paymentInProgress = false; loadingManager.hideAllButtonLoading(); const orderNo = encodeURIComponent( payload.merchantTransactionId || this.currentOrder?.merchantTransactionId || '' ); if (payload.status === 'success') { messageManager.success('支付成功'); window.location.href = `/payment/success?orderNo=${orderNo}`; return; } if (payload.status === 'pending') { messageManager.info('支付处理中,请稍后刷新订单状态'); window.location.href = `/payment/pending?orderNo=${orderNo}`; return; } window.location.href = `/payment/failed?code=${encodeURIComponent(payload.code)}`; }); // 支付失败 Hook pingpongService.setCheckoutFailedHook((code, message) => { console.error(`💳 支付失败 [${code}]: ${message}`); this.paymentInProgress = false; loadingManager.hideAllButtonLoading(); // 显示用户友好的错误信息 const userMessage = ERROR_MESSAGES[code] || message || '支付失败,请重试'; messageManager.error(userMessage); // 记录错误日志 this.logPaymentError(code, message); }); console.log('✅ 支付 Hooks 设置完成'); } /** * 处理金额变更 * @param {Event} event - 输入事件 */ handleAmountChange(event) { const amount = event.target.value; try { pingpongService.updateCheckout({ amount }); // 清除字段错误 formValidator.clearFieldError('amount'); } catch (error) { console.error('金额更新失败:', error); formValidator.showFieldError('amount', '金额格式不正确'); } } /** * 处理国家变更 * @param {Event} event - 选择事件 */ handleCountryChange(event) { const country = event.target.value; const currency = COUNTRY_CURRENCY_MAP[country]; try { pingpongService.updateCheckout({ tradeCountry: country, currency: currency }); // 更新货币显示 this.updateCurrencyDisplay(currency); // 清除字段错误 formValidator.clearFieldError('country'); } catch (error) { console.error('国家更新失败:', error); formValidator.showFieldError('country', '国家选择不正确'); } } /** * 处理优惠券变更 * @param {Event} event - 输入事件 */ async handleCouponChange(event) { const couponCode = event.target.value.trim(); if (!couponCode) { formValidator.clearFieldError('coupon'); return; } try { await this.validateCoupon(couponCode); formValidator.clearFieldError('coupon'); } catch (error) { console.error('优惠券验证失败:', error); formValidator.showFieldError('coupon', '优惠券无效'); } } /** * 处理支付按钮点击 * @param {Event} event - 点击事件 */ async handlePaymentClick(event) { event.preventDefault(); if (this.paymentInProgress) { return; } try { // 表单验证 const validation = formValidator.validateForm(['amount', 'country']); if (!validation.isValid) { messageManager.error(validation.errors.join('; ')); return; } // 启动支付 await pingpongService.startPayment(); } catch (error) { console.error('支付启动失败:', error); messageManager.error('支付启动失败,请重试'); } } /** * 处理 PingPong 初始化完成 */ handlePingPongReady() { console.log('🎉 PingPong SDK 准备就绪'); // 启用支付按钮 const payButton = document.getElementById('customPay'); if (payButton) { payButton.disabled = false; } } /** * 处理 PingPong 初始化错误 * @param {*} error - 错误信息 */ handlePingPongError(error) { console.error('💥 PingPong SDK 初始化错误:', error); // 禁用支付按钮 const payButton = document.getElementById('customPay'); if (payButton) { payButton.disabled = true; } messageManager.error(`收银台初始化失败: ${error.message || '未知错误'}`); } /** * 获取 SDK 访问令牌 * @returns {Promise} 访问令牌 */ async getSDKAccessToken() { try { const response = await fetch(API_ENDPOINTS.getAccessToken, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`获取访问令牌失败: ${response.status}`); } const result = await response.json(); return result.token; } catch (error) { console.error('获取 SDK 访问令牌失败:', error); // 开发环境使用模拟令牌 if (DEV_CONFIG.isDevelopment) { return 'mock-sdk-access-token'; } throw error; } } /** * 验证优惠券 * @param {string} couponCode - 优惠券代码 */ async validateCoupon(couponCode) { try { const response = await fetch(API_ENDPOINTS.validateCoupon, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ couponCode }) }); if (!response.ok) { throw new Error('优惠券验证失败'); } const result = await response.json(); if (result.valid) { messageManager.success(`优惠券有效,已享受${result.discount * 100}%折扣`); // 更新金额 const currentAmount = parseFloat(document.getElementById('amount').value); const newAmount = (currentAmount * (1 - result.discount)).toFixed(2); pingpongService.updateCheckout({ amount: newAmount }); document.getElementById('amount').value = newAmount; } else { throw new Error(result.message || '优惠券无效'); } } catch (error) { // 开发环境模拟 if (DEV_CONFIG.isDevelopment && couponCode === DEV_CONFIG.mockCouponCode) { messageManager.success('优惠券有效,已享受10%折扣'); const currentAmount = parseFloat(document.getElementById('amount').value); const newAmount = (currentAmount * 0.9).toFixed(2); pingpongService.updateCheckout({ amount: newAmount }); document.getElementById('amount').value = newAmount; return; } throw error; } } /** * 检查库存 */ async checkInventory() { try { const response = await fetch(API_ENDPOINTS.validateInventory, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: this.getCartItems() }) }); if (!response.ok) { throw new Error('库存检查失败'); } const result = await response.json(); if (!result.success) { throw new Error(result.message || '库存不足'); } console.log('📦 库存检查通过'); } catch (error) { console.error('库存检查异常:', error); // 开发环境跳过库存检查 if (DEV_CONFIG.isDevelopment) { console.log('📦 开发环境跳过库存检查'); return; } throw error; } } /** * 创建订单 * @returns {Promise} 订单令牌 */ async createOrder() { try { const orderData = { amount: document.getElementById('amount').value, currency: COUNTRY_CURRENCY_MAP[document.getElementById('country').value], tradeCountry: document.getElementById('country').value, couponCode: document.getElementById('coupon').value, items: this.getCartItems(), customerInfo: this.getCustomerInfo() }; const response = await fetch(API_ENDPOINTS.createOrder, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(orderData) }); if (!response.ok) { throw new Error('订单创建失败'); } const result = await response.json(); if (result.success && result.token) { this.currentOrder = result; console.log('📝 订单创建成功'); return result.token; } else { throw new Error(result.message || '订单创建失败'); } } catch (error) { console.error('订单创建异常:', error); // 开发环境返回模拟令牌 if (DEV_CONFIG.isDevelopment) { return 'mock-order-token-' + Date.now(); } throw error; } } /** * 获取购物车商品信息 * @returns {Array} 商品列表 */ getCartItems() { // 这里应该从实际的购物车组件获取商品信息 return [ { id: 'item_001', name: '示例商品', quantity: 1, price: '1.08' } ]; } /** * 获取客户信息 * @returns {Object} 客户信息 */ getCustomerInfo() { // 这里应该从实际的表单获取客户信息 return { email: 'customer@example.com', name: 'Customer Name' }; } /** * 更新货币显示 * @param {string} currency - 货币代码 */ updateCurrencyDisplay(currency) { const currencyElements = document.querySelectorAll('[data-currency]'); currencyElements.forEach(element => { element.textContent = currency; element.setAttribute('data-currency', currency); }); } /** * 记录支付错误日志 * @param {string} code - 错误代码 * @param {string} message - 错误信息 */ async logPaymentError(code, message) { const errorLog = { timestamp: new Date().toISOString(), errorCode: code, errorMessage: message, userAgent: navigator.userAgent, orderId: this.currentOrder?.id, amount: document.getElementById('amount')?.value, currency: COUNTRY_CURRENCY_MAP[document.getElementById('country')?.value], url: window.location.href }; try { await fetch(API_ENDPOINTS.logError, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(errorLog) }); } catch (error) { console.error('日志记录失败:', error); } } /** * 销毁应用 */ destroy() { this.paymentInProgress = false; pingpongService.destroy(); messageManager.clear(); loadingManager.hideAllButtonLoading(); this.isInitialized = false; console.log('🔧 Checkout 应用已销毁'); } } // 创建应用实例 const app = new CheckoutApp(); // 页面加载完成后初始化 document.addEventListener('DOMContentLoaded', async () => { try { await app.initialize(); } catch (error) { console.error('应用启动失败:', error); messageManager.error('应用启动失败,请刷新页面重试'); } }); // 页面卸载时清理 window.addEventListener('beforeunload', () => { app.destroy(); }); // 导出应用实例(用于调试) if (DEV_CONFIG.isDevelopment) { window.checkoutApp = app; window.pingpongService = pingpongService; window.messageManager = messageManager; } ``` ::: ## 常见问题 ### Q1: 为什么我的结账流程无法启动,系统提示 beforeCheckoutHook 相关错误? **A:** 当系统提示 `"The beforeCheckoutHook is not defined or is not a function"` 时,表明您的 one-page sdk 配置中缺少有效的 beforeCheckoutHook 函数。请确保在配置中正确定义此函数,它是启动结账流程的必要条件。 ### Q2: 我已定义 beforeCheckoutHook 函数,但仍收到 token 相关错误,如何解决? **A:** 错误 `"Invalid or missing token returned by beforeCheckoutHook"` 表明您的 beforeCheckoutHook 函数未返回有效的 token。请确保该函数返回一个非空字符串类型的 token,这是支付处理的必要凭证。 ### Q3: 创建支付时,系统提示 amount 参数错误,应如何处理? **A:** 当系统显示 `"Invalid or missing amount: must be a non-empty string"` 时,表明您提供的 amount 参数不符合要求。请确保 amount 参数是非空字符串类型,例如 `"10.99"`。 ### Q4: 如何确保我使用的货币代码有效? **A:** 系统支持特定的货币代码列表。如果收到 `"Invalid or missing currency"` 错误,请检查您提供的货币代码是否在支持列表中。常用的代码包括 `"USD"`、`"EUR"`、`"CNY"` 等。完整列表请参考 API 文档。 ### Q5: sdkAccessToken 验证失败的原因是什么? **A:** 错误 `"Invalid or missing sdkAccessToken"` 表明您提供的 sdkAccessToken 无效或缺失。请确保提供一个有效的、非空字符串类型的 sdkAccessToken,这是身份验证的关键凭证。 ### Q6: 配置 paymentMethods 时需要注意什么? **A:** 如果您选择配置 paymentMethods 参数,必须确保它是数组类型。若收到 `"Invalid paymentMethods: must be an array or undefined"` 错误,请检查您提供的值是否为数组,或考虑完全移除此参数以使用默认支付方式。 ### Q7: 如何确保 one-page sdk 配置的正确性? **A:** 建议在实施前全面测试您的配置。特别注意: - beforeCheckoutHook 函数必须正确定义并返回有效 token - 所有必需参数(amount、currency、sdkAccessToken)必须符合类型要求 - 可选参数如 paymentMethods 必须符合预期格式 ### Q8: 支付流程中断时如何快速排查问题? **A:** 请检查控制台错误信息,它通常会精确指出问题所在。根据错误信息对照本 FAQ 进行相应调整。如果问题持续,请确保您使用的是最新版本的 SDK,并查阅完整的技术文档。