This commit is contained in:
dzc committed 2026-10-28 18:49:38 +08:00
1 parent 8327e335df
commit 116b46033d
18 files changed
+4669 -59

No files matched your search

+2 -1
View File
@@ -8,4 +8,5 @@ VITE_IS_MICRO=true
VITE_APP_DEV_SERVICE=https://dzc-test-1.fy.takin.cc
# 生产环境后端地址;配置为空时由线上部署提供同源接口。
VITE_APP_PROD_SERVICE=https://www.zianxin.com/api
# VITE_APP_PROD_SERVICE=https://www.zianxin.com/api
VITE_APP_PROD_SERVICE=/api
+16 -5
View File
@@ -1,6 +1,6 @@
# 商户付款 H5
uni-app Vue 3 + TypeScript + UnoCSS + tm-ui 的独立 H5 项目。工程与蓝色主题参考 `../biz_zax_app_cloud`,只注册一个付款页面,默认中文、人民币、浅色主题。
uni-app Vue 3 + TypeScript + UnoCSS + tm-ui 的独立 H5 项目。工程与蓝色主题参考 `../biz_zax_app_cloud`,提供商户收银与付款结果页面,默认中文、人民币、浅色主题。
## 本地开发
@@ -24,7 +24,8 @@ pnpm build:h5
```text
src/
├── pages/pay/index.vue # 唯一页面:商户、金额与付款交互
├── pages/pay/index.vue # 收银页面:商户、金额与付款交互
├── pages/pay/pay-complete.vue # 只读付款结果页:Lottie、金额与交易信息
├── sheep/
│ ├── components/payment-keyboard.vue # 四列常驻键盘
│ ├── config/merchant.ts # 仅供设计参考的示例数据,真实页面不引用
@@ -37,7 +38,7 @@ src/
- 商户名称与标识通过匿名商户接口读取;`src/sheep/config/merchant.ts` 中的示例仅保留作设计参考,真实页面不引用。标识缺省或加载失败时展示统一店铺图形。
- 修改主题:编辑 `src/sheep/config/theme.ts`。页面基础色在 `pages.json`、`uni.scss` 和 HTML 的 theme-color 中保留一致的框架配置。
- 页面采用 Hash 路由,唯一页面为 `/pages/pay/index`。部署子路径统一在 `src/manifest.json` 的 `h5.router.base` 中修改,默认 `/`,避免在 Vite 中重复设置路径前缀。
- 页面采用 Hash 路由,收银页面为 `/pages/pay/index`,结果页为 `/pages/pay/pay-complete`。部署子路径统一在 `src/manifest.json` 的 `h5.router.base` 中修改,默认 `/`,避免在 Vite 中重复设置路径前缀。
- 接入 third 服务的匿名商户查询、线下收款、OAuth 回跳和真实支付。无签名入口或短期收银票据时展示错误,不回退到示例商户。
## 金额与 H5 交互
@@ -64,14 +65,24 @@ tm-ui `3.2.0` 从参考项目的 `src/uni_modules/tm-ui` 原样引入 `tm-sheet`
`VITE_IS_MICRO=true` 保留 `/third`;`false` 对接单体服务时移除 `/third`,代理规则共用。生产构建直接读取同一 `.env` 的 `VITE_APP_PROD_SERVICE`;若线上反向代理已提供同源接口,可将其设为空。Vite 开发代理不随生产构建发布。
- 商户码入口:`/#/pages/pay/index?id=...&tenantId=...&v=1&sign=...`,商户和租户 ID 按字符串处理。
- OAuth 回跳:`/#/pages/pay/index?tenantCode=...&ticket=...`,页面通过接口读取真实场景、固定金额和渠道参数。
- 收银入口:`/#/pages/pay/index?tenantCode=...&ticket=...`,页面通过接口读取真实场景、固定金额和渠道参数。支付宝 WAP 的返回/退出由后端固定入口回跳同一票据,并附加 `returned=1`;该标记只阻止自动重复唤起,不表示支付结果。
- `GET /third/aggregate-merchant-pay/merchant` 读取公开商户资料及 `supportedPlatforms`(10 微信、20 支付宝);商户可以只开通其中一个渠道。
- `POST /third/aggregate-merchant-pay/create` 提交整数分金额及稳定 `requestId`;不在前端指定业务类型或渠道商户。
- `GET /third/aggregate-order-pay/context/{tenantCode}/{ticket}` 读取收银上下文;现有 `/status/{tenantCode}/{ticket}` 确认结果。
- 后端把本 H5 来源加入精确 CORS 允许列表;跨域接口必须使用 HTTPS。OAuth 回调仍由后端接收,平台密钥和 OAuth 身份不进入前端。
线下收款在提交前保留数字键盘;订单聚合及已提交的收款金额锁定。OAuth 回跳只自动调起一次,取消后可手动继续,刷新不会自动重复弹出。JSBridge 返回成功后仍查询服务端状态;未确认原单成功或关闭之前不释放金额创建新请求。
线下收款在提交前保留数字键盘;订单聚合及已提交的收款金额锁定。新票据只自动调起一次,取消后可手动继续,刷新不会自动重复弹出。微信 JSBridge 返回成功或支付宝 WAP 返回后仍查询服务端状态;未确认原单成功或关闭之前不释放金额创建新请求。
支付宝 WAP 使用后端 `launchParams.mode=ALIPAY_WAP` 和签名 `payUrl`,直接前往 `https://openapi.alipay.com/gateway.do`,必须带 `method=alipay.trade.wap.pay`,不等待 JSBridge。签名支付地址仅保留在当前上下文内存,不写入二维码、Hash 或浏览器恢复存储;支付宝旧 `tradeNO/AlipayJSBridge.tradePay` 分支已移除,微信客户端桥保持原逻辑。WAP 返回、退出或浏览器后退只查询原单,不自动重复导航;手动“继续付款”会先重读上下文,已成功、关闭或过期时停止发起。
扫码客户端未在商户已开通渠道中时,在原单页展示“商户暂不支持微信/支付宝支付”及换用已开通 App 重新扫码的指引,隐藏金额与键盘。普通浏览器提示使用已支持 App 扫码;全部渠道停用时提示联系商户。可重新检查开通情况,不在当前客户端强制唤起另一个 App,也不会据此清除待确认的原付款请求。
完整后端配置、迁移、平台域名设置及发布顺序见 `../biz-api-cloud-zax/pjcloud-thirdparty/docs/aggregate-h5.md`。更换二维码使用的域名或签名密钥后需重新生成商户 URL 收款码,原 App Base64 收款码保持兼容。
## 付款结果页
付款成功或服务端明确关闭、过期、创建失败后,收银页自动进入 `/pages/pay/pay-complete`。入口只传 `tenantCode、ticket`,刷新后重新查询真实金额、商户、支付渠道和支付单号;不信任 URL 中的金额或成功状态。结果页仅调用已有 context/status 查询接口,不创建付款、不调起 JSBridge/WAP,也不释放待确认的 requestId。
结果页参考 App 的 `pay-complete.vue`,使用同版 `lottie-web 5.13.0` 和原始 `success/fail/loading` 矢量动画:成功、失败播放一次,查询中循环;减少动态效果时显示最终帧,后台暂停,销毁时清理。动画不可用时显示本地状态图标,文字与金额保持可读。
取消、网络错误和确认超时不会显示“支付失败”,而是保留待确认说明及只读重新查询入口。返回收银台仍使用同一票据,携带 `returned=1、resultViewed=1` 抑制再次自动付款及结果页往返跳转;该标记只影响导航,不改变服务端状态。确认成功或关闭后,线下商户入口可继续沿用原“再付一笔/重新输入金额”流程。
+1
View File
@@ -16,6 +16,7 @@
"@dcloudio/uni-app": "3.0.0-5000720260410001",
"@dcloudio/uni-components": "3.0.0-5000720260410001",
"@dcloudio/uni-h5": "3.0.0-5000720260410001",
"lottie-web": "5.13.0",
"pinia": "3.0.4",
"vue": "3.5.26"
},
+7
View File
@@ -14,6 +14,9 @@ importers:
"@dcloudio/uni-h5":
specifier: 3.0.0-5000720260410001
version: 3.0.0-5000720260410001(postcss@8.5.6)(vue@3.5.26(typescript@4.9.5))
lottie-web:
specifier: 5.13.0
version: 5.13.0
pinia:
specifier: 3.0.4
version: 3.0.4(typescript@4.9.5)(vue@3.5.26(typescript@4.9.5))
@@ -52,6 +55,9 @@ importers:
specifier: 1.8.27
version: 1.8.27(typescript@4.9.5)
packages:
lottie-web@5.13.0:
resolution:
integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==
"@ampproject/remapping@2.3.0":
resolution:
integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==
@@ -3103,6 +3109,7 @@ packages:
engines:
node: ">= 6"
snapshots:
lottie-web@5.13.0: {}
"@ampproject/remapping@2.3.0":
dependencies:
"@jridgewell/gen-mapping": 0.3.13
+7
View File
@@ -12,6 +12,13 @@
"navigationBarTitleText": "商户收款",
"disableScroll": true
}
},
{
"path": "pages/pay/pay-complete",
"style": {
"navigationBarTitleText": "付款结果",
"disableScroll": true
}
}
],
"globalStyle": {
+33 -3
View File
@@ -35,6 +35,7 @@
</template>
<view v-if="error" class="mt-32rpx text-28rpx text-secondary leading-relaxed" role="alert">{{ error }}</view>
<view v-else-if="feedback || loading" class="mt-32rpx text-28rpx leading-relaxed" :class="success ? 'text-primary' : 'text-secondary'" role="status" aria-live="polite">{{ feedback || '正在读取收款信息…' }}</view>
<view v-if="resultNavigationError" class="mt-24rpx text-26rpx text-secondary" role="alert">{{ resultNavigationError }}</view>
</tm-sheet>
</view>
</view>
@@ -43,7 +44,8 @@
<button v-if="unavailable" class="payment-action w-full rounded-24rpx" :class="loading || busy ? 'bg-disabled text-secondary' : 'bg-primary text-white'" :disabled="loading || busy" :aria-disabled="loading || busy" role="button" :tabindex="loading || busy ? -1 : 0" @click="retry" @keydown.enter.prevent="retry" @keydown.space.prevent="retry">{{ loading ? '正在检查' : '重新检查支付方式' }}</button>
<button v-else-if="error" class="payment-action w-full bg-primary text-white rounded-24rpx" role="button" tabindex="0" @click="retry" @keydown.enter.prevent="retry" @keydown.space.prevent="retry">重新查询</button>
<template v-else>
<button class="payment-action w-full rounded-24rpx" :class="canPay ? 'bg-primary text-white' : 'bg-disabled text-secondary'" :disabled="!canPay" :aria-disabled="!canPay" role="button" :tabindex="canPay ? 0 : -1" @click="pay" @keydown.enter.prevent="pay" @keydown.space.prevent="pay">
<button v-if="terminal" class="payment-action w-full bg-primary text-white rounded-24rpx" :disabled="resultNavigationPending" role="button" tabindex="0" @click="showResult" @keydown.enter.prevent="showResult" @keydown.space.prevent="showResult">{{ resultNavigationPending ? '正在查看结果' : '查看付款结果' }}</button>
<button v-else class="payment-action w-full rounded-24rpx" :class="canPay ? 'bg-primary text-white' : 'bg-disabled text-secondary'" :disabled="!canPay" :aria-disabled="!canPay" role="button" :tabindex="canPay ? 0 : -1" @click="pay" @keydown.enter.prevent="pay" @keydown.space.prevent="pay">
{{ success ? '付款成功' : loading ? '加载中' : busy ? '正在发起付款' : confirming ? '付款结果确认中' : terminal ? '本次收款已结束' : '继续付款' }}
</button>
<button v-if="canRestart" class="payment-action mt-16rpx w-full bg-surface text-primary rounded-24rpx" role="button" tabindex="0" @click="restart" @keydown.enter.prevent="restart" @keydown.space.prevent="restart">{{ success ? '再付一笔' : '重新输入金额' }}</button>
@@ -58,10 +60,38 @@ import PaymentKeyboard from "@@/components/payment-keyboard.vue"
import PaymentUnavailable from "@@/components/payment-unavailable.vue"
import { useMerchantPayment } from "@@/hooks/use-merchant-payment"
import { themeColors } from "@@/config/theme"
import { hasViewedPaymentResult, paymentResultRoute, resolvePaymentResultPhase } from "@@/utils/payment-result"
/** 页面与数字键盘共享真实收银状态,任何付款入口都经过同一个请求和金额守卫。 */
const { merchant, amount, loading, busy, error, feedback, editable, canPay, showKeyboard, canRestart, confirming, success, terminal, unavailable,
pay, restart, retry, append, remove } = useMerchantPayment()
const { merchant, amount, context, ticket, loading, busy, error, feedback, editable, canPay, showKeyboard, canRestart, confirming, success, terminal, unavailable,
pay, restart, retry, append, remove, suspend } = useMerchantPayment()
/** 结果路由正在切换时阻止重复点击,不影响服务端交易状态。 */
const resultNavigationPending = ref(false)
/** 路由不可用时留在已锁定的原收银页,允许再次查看结果。 */
const resultNavigationError = ref("")
/** 只传递原票据进入只读结果页,成功或失败均由该页重新查询服务端确认。 */
function showResult() {
if (!ticket.value || !context.value || resultNavigationPending.value) return
const phase = resolvePaymentResultPhase(context.value)
if (phase !== "success" && phase !== "error") return
resultNavigationPending.value = true
resultNavigationError.value = ""
suspend()
uni.redirectTo({
url: paymentResultRoute(ticket.value),
fail() {
resultNavigationPending.value = false
resultNavigationError.value = "暂时无法打开付款结果,请点击按钮重新查看。"
},
})
}
/** 明确终态自动展示结果;取消、处理中和网络异常仍停留原单,不伪造失败或再次付款。 */
watch([context, ticket], () => {
if (!hasViewedPaymentResult()) showResult()
})
/** 商户图片失败只切换占位标识,不影响真实商户名称和付款对象。 */
const merchantLogoFailed = ref(false)
/** 长金额按展示长度缩小字号,始终保留完整金额。 */
+192
View File
@@ -0,0 +1,192 @@
<template>
<view class="payment-shell result-shell flex flex-col w-full max-w-480px mx-auto overflow-hidden">
<view class="result-content flex-1 min-h-0 overflow-y-auto">
<view class="result-gutter px-32rpx pt-24rpx pb-24rpx">
<tm-sheet :margin="[0]" :padding="[32]" :round="32" color="white" class="result-card">
<view class="result-hero flex flex-col items-center text-center pt-12rpx pb-36rpx" role="status" aria-live="polite" aria-atomic="true">
<view class="result-animation flex-row-center shrink-0" aria-hidden="true">
<view v-if="phase === 'unknown'" class="size-160rpx flex-row-center rounded-full bg-soft">
<tm-icon name="loader-4-line" :size="80" :color="themeColors.strong" />
</view>
<payment-result-animation v-else :phase="animationPhase" />
</view>
<view id="payment-result-heading" class="result-title text-40rpx font-600 text-text leading-tight mt-8rpx"
role="heading" aria-level="1" tabindex="-1">{{ title }}</view>
<text class="result-description block max-w-full text-26rpx text-muted leading-relaxed mt-16rpx">{{ description }}</text>
<view v-if="amount !== undefined" class="result-amount mt-32rpx" :aria-label="`${phase === 'success' ? '实付金额' : '付款金额'},${amount} 元`">
<text class="block text-24rpx text-muted mb-12rpx">{{ phase === 'success' ? '实付金额' : '付款金额' }}</text>
<view class="flex justify-center items-baseline gap-8rpx text-text" aria-hidden="true">
<text class="text-40rpx font-500 shrink-0">¥</text>
<text class="result-number font-600" :class="amountSizeClass">{{ amount }}</text>
</view>
</view>
</view>
<view v-if="context" class="result-details border-t-1px border-t-solid border-border pt-20rpx">
<view class="result-detail-row flex justify-between items-start gap-24rpx py-18rpx">
<text class="text-26rpx text-muted shrink-0 leading-relaxed">收款商户</text>
<text class="result-detail-value min-w-0 text-28rpx text-text font-500 text-right leading-relaxed">{{ merchantName }}</text>
</view>
<view class="result-detail-row flex justify-between items-start gap-24rpx py-18rpx">
<text class="text-26rpx text-muted shrink-0 leading-relaxed">支付方式</text>
<text class="result-detail-value min-w-0 text-28rpx text-text text-right leading-relaxed">{{ platformName }}</text>
</view>
<view v-if="payNo" class="result-detail-row flex justify-between items-start gap-24rpx py-18rpx">
<text class="text-26rpx text-muted shrink-0 leading-relaxed">支付单号</text>
<text class="result-detail-value result-reference min-w-0 text-24rpx text-secondary text-right leading-relaxed">{{ payNo }}</text>
</view>
</view>
</tm-sheet>
<view class="result-caption text-center text-23rpx text-muted leading-relaxed px-16rpx mt-28rpx">
{{ phase === 'success' ? '感谢您的付款' : '如已扣款,请先查询结果,避免重复付款' }}
</view>
</view>
</view>
<view class="result-actions shrink-0 bg-surface px-32rpx pt-24rpx" :aria-busy="loading">
<button class="result-button w-full rounded-24rpx"
:class="loading ? 'bg-disabled text-secondary' : 'bg-primary text-white'"
:disabled="loading" :aria-disabled="loading" :tabindex="loading ? -1 : 0" role="button"
@click="handlePrimary" @keydown.enter.prevent="handlePrimary" @keydown.space.prevent="handlePrimary">{{ primaryLabel }}</button>
<button v-if="showSecondary" class="result-button result-secondary w-full rounded-24rpx mt-12rpx text-strong bg-surface"
:disabled="loading" :aria-disabled="loading" :tabindex="loading ? -1 : 0" role="button"
@click="handleSecondary" @keydown.enter.prevent="handleSecondary" @keydown.space.prevent="handleSecondary">{{ secondaryLabel }}</button>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, watch } from "vue"
import PaymentResultAnimation from "@@/components/payment-result-animation.vue"
import { usePaymentResult } from "@@/hooks/use-payment-result"
import { themeColors } from "@@/config/theme"
/** 结果页只读取原票据的服务端状态,不在页面内创建交易或调起支付。 */
const { phase, title, description, amount, merchantName, platformName, payNo, context, loading, canReturnToCashier,
refresh, returnToCashier } = usePaymentResult()
/** 结果未知使用静态时钟,其余状态由动画组件分别展示加载、成功或失败。 */
const animationPhase = computed(() => phase.value === "success" ? "success" : phase.value === "error" ? "error" : "loading")
/** 长金额缩小字号但保留全部数字,金额始终来自服务端整数分。 */
const amountSizeClass = computed(() => (amount.value?.length || 0) > 10 ? "text-52rpx" : "text-76rpx")
/** 已有明确结果且票据上下文完整时,主操作返回收银台;其他情况只查询原单。 */
const primaryReturns = computed(() => canReturnToCashier.value && (phase.value === "success" || phase.value === "error"))
/** 主按钮区分请求进行中、成功完成、失败返回和结果重查,不使用客户端回调推断扣款。 */
const primaryLabel = computed(() => loading.value ? "正在查询…" : primaryReturns.value ? phase.value === "success" ? "完成" : "返回收银台" : "重新查询结果")
/** 完整上下文才提供返回入口;失败页额外保留只读重查,以接收渠道延迟成功。 */
const showSecondary = computed(() => canReturnToCashier.value && phase.value !== "success")
/** 失败结果的次操作为重查;尚未确认的结果可返回同一票据收银台继续处理。 */
const secondaryLabel = computed(() => primaryReturns.value ? "重新查询结果" : "返回收银台")
/** 完成或返回仍使用原票据,查询操作不会生成新的付款意图。 */
function handlePrimary() {
if (loading.value) return
if (primaryReturns.value) returnToCashier()
else void refresh()
}
/** 未确认状态返回原收银台,明确失败状态只重查原单,不直接重新付款。 */
function handleSecondary() {
if (loading.value) return
if (primaryReturns.value) void refresh()
else returnToCashier()
}
/** 结果阶段变化后把无滚动焦点交给标题,使读屏用户能感知异步确认结果。 */
watch(phase, async () => {
await nextTick()
document.getElementById("payment-result-heading")?.focus({ preventScroll: true })
}, { immediate: true })
</script>
<style scoped lang="scss">
.result-shell {
background: linear-gradient(180deg, var(--pay-tint) 0, var(--pay-background) 360px);
}
.result-content {
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-left: env(safe-area-inset-left, 0px);
overscroll-behavior: contain;
scroll-padding-block: 16px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar { display: none; }
}
.result-card {
box-shadow: 0 8px 28px rgba(44, 83, 118, 0.035);
}
.result-animation {
width: 136px;
height: 136px;
}
.result-title {
outline: none;
}
.result-description,
.result-detail-value {
overflow-wrap: anywhere;
}
.result-number {
line-height: 1.2;
font-variant-numeric: tabular-nums;
letter-spacing: -0.025em;
overflow-wrap: anywhere;
}
.result-reference {
font-variant-numeric: tabular-nums;
word-break: break-all;
}
.result-actions {
padding-right: max(16px, env(safe-area-inset-right, 0px));
padding-left: max(16px, env(safe-area-inset-left, 0px));
padding-bottom: max(20px, env(safe-area-inset-bottom, 0px));
}
.result-button {
min-height: 52px;
padding: 14px 16px;
font-size: 17px;
line-height: 24px;
border: 0;
&::after { border: 0; }
&:focus-visible { outline: 2px solid var(--pay-strong); outline-offset: 2px; }
&:active { opacity: 0.85; }
}
.result-secondary {
min-height: 44px;
padding-block: 10px;
font-size: 15px;
}
// 短屏压缩装饰空间,保持金额与操作可见;极短横屏仅内容区内部滚动。
@media (max-height: 650px) {
.result-animation { width: 108px; height: 108px; }
.result-hero { padding-top: 0; padding-bottom: 14px; }
.result-description { margin-top: 8px; }
.result-amount { margin-top: 14px; }
.result-details { padding-top: 8px; }
.result-detail-row { padding-block: 8px; }
.result-caption { margin-top: 12px; }
}
@media (max-height: 480px) {
.result-animation { width: 80px; height: 80px; }
.result-gutter { padding-top: 10px; padding-bottom: 10px; }
.result-hero { padding-bottom: 10px; }
.result-title { font-size: 20px; margin-top: 0; }
.result-description { font-size: 13px; }
.result-number { font-size: 28px; }
.result-detail-row { padding-block: 6px; }
.result-actions { padding-top: 12px; padding-bottom: max(12px, env(safe-area-inset-bottom, 0px)); }
}
</style>
+11 -3
View File
@@ -27,7 +27,7 @@ export interface MerchantInfo {
supportedPlatforms: PaymentPlatform[]
}
/** OAuth 后的短期收银凭据,不是平台授权码。 */
/** 服务端签发的短期收银凭据,不是平台授权码或支付结果。 */
export interface CashierTicket {
/** 服务端租户唯一编码。 */
tenantCode: string
@@ -48,6 +48,14 @@ export interface PaymentResult {
payNo?: string
}
/** 收银渠道参数:微信沿用 JSAPI 字段,支付宝仅接收 WAP 官方签名地址。 */
export interface PaymentLaunchParams extends Record<string, unknown> {
/** 支付宝必须为 ALIPAY_WAP;仅微信的客户端桥参数允许缺省。 */
mode?: "ALIPAY_WAP"
/** 仅 WAP 使用的支付宝官方网关 HTTPS 地址,不写入浏览器恢复存储。 */
payUrl?: string
}
/** H5 票据上下文,金额和场景均不可由 URL 覆盖。 */
export interface CashierContext extends PaymentResult {
/** ORDER 为已有订单,OFFLINE 为付款人输入金额的线下收款。 */
@@ -59,14 +67,14 @@ export interface CashierContext extends PaymentResult {
/** 10 为微信,20 为支付宝。 */
selectedPlatform?: "10" | "20" | null
/** 尚可付款时提供的渠道签名参数,终态时缺省。 */
launchParams?: Record<string, unknown>
launchParams?: PaymentLaunchParams
}
/** 创建结果中的跳转地址由后端固定配置产生。 */
export interface CreatedPayment {
/** 服务端聚合会话编号。 */
aggregateNo: string
/** 带随机访问令牌的既有聚合 OAuth 入口。 */
/** 带随机访问令牌的服务端聚合入口,由后端决定 OAuth 或 WAP 收银路径。 */
cashierUrl: string
/** 服务端支付截止时间,仅供提示,前端不能据此自行确认关单。 */
expireTime: string
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import type { AnimationItem } from "lottie-web"
import loadingAnimation from "@/static/lottie-json/loading.json"
import successAnimation from "@/static/lottie-json/success.json"
import errorAnimation from "@/static/lottie-json/fail.json"
/** 只负责动画展示;付款结果必须由父页面依据服务端状态决定。 */
type AnimationPhase = "loading" | "success" | "error"
/** 结果状态由父页面传入,不从动画完成事件推断付款成功。 */
const props = defineProps<{
/** 父页面从服务端结果映射的展示阶段,不能由动画反向修改。 */
phase: AnimationPhase
}>()
/** 本地纯矢量动画沿用参考 App 资源,不请求第三方动画或图片服务。 */
const animations = { loading: loadingAnimation, success: successAnimation, error: errorAnimation }
/** uni-app 的 view 可能返回组件代理,通过 $el 获取实际的 H5 容器。 */
const container = ref<HTMLElement | { $el: HTMLElement }>()
/** 动画加载失败时使用同状态的静态图标,结果文案仍由父页面展示。 */
const failed = ref(false)
/** 当前降级图形,仅作为紧邻结果文案的装饰。 */
const fallbackIcon = computed(() => props.phase === "success" ? "checkbox-circle-line" : props.phase === "error" ? "close-circle-line" : "loader-4-line")
/** 当前 Lottie 实例只属于本次状态,状态切换和组件卸载时销毁。 */
let animation: AnimationItem | undefined
/** 异步模块加载和 DOM 就绪的版本号,阻止旧状态覆盖新动画。 */
let revision = 0
/** 挂载前不访问浏览器对象,状态监听只有在挂载后才启动动画。 */
let mounted = false
/** 卸载后异步任务不得重新创建实例或修改视图。 */
let disposed = false
/** 用户的减少动态效果偏好,保留句柄以便卸载监听。 */
let motionQuery: MediaQueryList | undefined
/** 成功和失败动画播放完成后不因页面恢复而重复播放。 */
let completed = false
/** 销毁当前实例,Lottie 会同步释放 SVG、计时器及实例上的事件监听。 */
function destroyAnimation() {
animation?.destroy()
animation = undefined
}
/** 后台暂停动画;减少动态效果时直接停在最终可读帧,不影响父页面的支付查询。 */
function updatePlayback() {
if (!animation) return
if (motionQuery?.matches) {
animation.goToAndStop(Math.max(0, animation.totalFrames - 1), true)
completed = props.phase !== "loading"
} else if (document.hidden) animation.pause()
else if (!completed) animation.play()
}
/** 渲染异常仅切换静态图形,不改变或传播任何付款业务状态。 */
function showFallback(current: number) {
if (disposed || current !== revision) return
failed.value = true
destroyAnimation()
}
/** 每次状态切换使用动画数据副本,避免 Lottie 对共享 JSON 的内部处理污染下一次挂载。 */
async function mountAnimation() {
const current = ++revision
destroyAnimation()
failed.value = false
completed = false
await nextTick()
if (disposed || current !== revision) return
try {
// 仅在 H5 实际挂载后加载 DOM 依赖,页面销毁期间完成的导入不再创建实例。
const module = await import("lottie-web")
if (disposed || current !== revision) return
const host = container.value && "$el" in container.value ? container.value.$el : container.value
if (!(host instanceof HTMLElement)) { showFallback(current); return }
const instance = module.default.loadAnimation({
container: host,
renderer: "svg",
autoplay: false,
loop: props.phase === "loading",
animationData: JSON.parse(JSON.stringify(animations[props.phase])),
rendererSettings: { progressiveLoad: true, preserveAspectRatio: "xMidYMid meet" },
})
animation = instance
instance.addEventListener("data_failed", () => showFallback(current))
instance.addEventListener("error", () => showFallback(current))
instance.addEventListener("DOMLoaded", () => {
if (!disposed && current === revision) updatePlayback()
})
instance.addEventListener("complete", () => {
if (!disposed && current === revision) completed = true
})
updatePlayback()
} catch { showFallback(current) }
}
/** 状态切换后重建对应动画,旧状态的延迟导入由 revision 丢弃。 */
watch(() => props.phase, () => { if (mounted) void mountAnimation() })
/** 浏览器挂载后监听可见性和动态偏好,首次动画尺寸由父容器提供。 */
onMounted(() => {
mounted = true
if (typeof window.matchMedia === "function") {
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
if (typeof motionQuery.addEventListener === "function") motionQuery.addEventListener("change", updatePlayback)
else motionQuery.addListener(updatePlayback)
}
document.addEventListener("visibilitychange", updatePlayback)
void mountAnimation()
})
/** 释放浏览器监听和动画,页面快速往返时不遗留旧实例或异步写入。 */
onBeforeUnmount(() => {
disposed = true
revision++
document.removeEventListener("visibilitychange", updatePlayback)
if (motionQuery) {
if (typeof motionQuery.removeEventListener === "function") motionQuery.removeEventListener("change", updatePlayback)
else motionQuery.removeListener(updatePlayback)
}
destroyAnimation()
})
</script>
<template>
<view class="size-full relative" :class="{ 'result-error-visual': phase === 'error' }" aria-hidden="true">
<view ref="container" v-show="!failed" class="result-animation-canvas size-full" />
<view v-if="failed" class="size-full flex-row-center result-animation-fallback" :class="`result-animation-${phase}`">
<tm-icon :name="fallbackIcon" :size="144" color="" />
</view>
</view>
</template>
<style scoped lang="scss">
// 原失败资源自带较宽留白,仅缩放图形画布,使成功和失败的视觉重心保持一致。
.result-error-visual {
overflow: hidden;
.result-animation-canvas { transform: scale(2.1); transform-origin: center; }
}
.result-animation-canvas :deep(svg) {
display: block;
width: 100%;
height: 100%;
}
.result-animation-fallback {
color: var(--pay-primary);
}
.result-animation-success {
color: var(--pay-success, var(--pay-primary));
}
.result-animation-error {
color: var(--pay-danger, var(--pay-strong));
}
</style>
+4
View File
@@ -4,6 +4,10 @@ export const themeColors = {
primary: "#0094ff",
/** 同色系深蓝,用于浅色背景上的小字号文本 */
strong: "#006ec9",
/** 付款成功图标与状态文案,沿用参考 App 的绿色结果语义 */
success: "#14855f",
/** 失败及异常状态文案,使用深红保障白色表面的小字对比度 */
danger: "#c53d49",
/** 页面底色,与参考项目 globalStyle 保持一致 */
background: "#ecf4f9",
/** 页面上方的柔和蓝色渐变起点,仅承担背景装饰 */
+89 -15
View File
@@ -5,6 +5,7 @@ import { amountToFen, formatFen, newPaymentRequestId, parsePaymentEntry } from "
import { invokePayment } from "@@/utils/payment-bridge"
import { paymentUnavailable, resolvePaymentPlatform } from "@@/utils/payment-platform"
import { usePaymentAmount } from "@@/hooks/use-payment-amount"
import { paymentPagePath } from "@@/utils/payment-result"
/** 本标签页的一次线下付款意图,只保存公开入口和恢复信息,不保存平台授权身份。 */
interface PendingPayment {
@@ -14,7 +15,7 @@ interface PendingPayment {
amount: number
/** 请求超时和刷新时复用的 UUID。 */
requestId: string
/** 创建成功后可继续 OAuth 的固定服务端地址。 */
/** 创建成功后可继续收银的固定服务端入口,不保存支付宝签名支付地址。 */
cashierUrl?: string
}
@@ -25,7 +26,7 @@ const POLL_INTERVAL_MS = 4000
/** 渠道已确认终态,只有这些状态可以结束当前付款意图。 */
const TERMINAL_STATUSES = ["30", "40", "50", "60"]
/** 管理两种收银场景的授权、金额锁定、继续付款及服务端结果确认。 */
/** 管理两种收银场景的授权/WAP 跳转、金额锁定、继续付款及服务端结果确认。 */
export function useMerchantPayment() {
/** 手动输入的人民币元字符串,只在未提交线下收款时可修改。 */
const input = usePaymentAmount()
@@ -33,7 +34,7 @@ export function useMerchantPayment() {
const merchant = ref<MerchantInfo>()
/** 已通过格式校验的静态签名入口,真实有效性仍由后端判断。 */
const entry = ref<MerchantEntry>()
/** OAuth 回跳的短期收银票据。 */
/** JSAPI/WAP 共用的短期收银票据,回跳仍复用同一票据。 */
const ticket = ref<CashierTicket>()
/** 后端固定的金额、渠道和状态。 */
const context = ref<CashierContext>()
@@ -66,6 +67,8 @@ export function useMerchantPayment() {
let revision = 0
/** 防止轮询请求与页面恢复请求并行。 */
let checking = false
/** WAP 即将离开页面时保留提交锁;返回或 bfcache 恢复后才解除,不持久化支付地址。 */
let redirecting = false
/** 本页面是否已经销毁。 */
let disposed = false
/** 仅未提交的签名商户入口允许数字输入。 */
@@ -108,9 +111,15 @@ export function useMerchantPayment() {
pollTimer = undefined
}
/** 离开收银页前停止轮询并使旧请求失效;保留原金额、票据和付款意图。 */
function suspend() { revision++; stopPolling() }
/** 按服务端终态更新提示,不以本机时间或 JSBridge 回调自行判定付款成功。 */
function applyStatus(result: PaymentResult) {
if (!context.value) return
// 继续付款前的上下文查询可能与上一轮查单同时返回,旧的待支付结果不能覆盖已确认终态。
if ((context.value.paySuccess && !result.paySuccess)
|| (TERMINAL_STATUSES.includes(context.value.status) && !TERMINAL_STATUSES.includes(result.status))) return
context.value = { ...context.value, ...result }
if (result.paySuccess) feedback.value = "付款成功"
else if (result.status === "40") feedback.value = "交易已关闭,未完成付款"
@@ -125,7 +134,7 @@ export function useMerchantPayment() {
/** 排队下一次状态查询,前一个请求结束后才会启动。 */
function queuePolling() {
stopPolling()
if (!disposed && ticket.value && !terminal.value && !document.hidden && !error.value && !unavailable.value) {
if (!disposed && ["/", "/pages/pay/index"].includes(paymentPagePath()) && ticket.value && !terminal.value && !busy.value && !redirecting && !document.hidden && !error.value && !unavailable.value) {
pollTimer = setTimeout(() => { void checkStatus() }, POLL_INTERVAL_MS)
}
}
@@ -148,27 +157,58 @@ export function useMerchantPayment() {
}
}
/** 调起当前已绑定的平台订单;取消后保持原单号,继续付款仍复用本单。 */
async function launch() {
const data = context.value
/** 手动继续前重读服务端上下文,刷新签名参数并阻止已结束的订单再次导航。 */
async function refreshCashierContext() {
if (!ticket.value) return
const current = revision
const data = await getCashierContext(ticket.value)
if (disposed || current !== revision) return
// 查询期间若查单已确认结束,保留该结果;只有新的成功结果可以进一步确认付款成功。
if (terminal.value && !data.paySuccess) return context.value
context.value = data
merchant.value = data.merchant
error.value = ""
applyStatus(data)
return data
}
/** 调起当前平台原单;首次使用刚读取的上下文,手动继续时必须重新确认服务端状态。 */
async function launch(refresh = true) {
let data = context.value
if (!data?.launchParams || !data.selectedPlatform || data.status !== "20" || busy.value || confirming.value || unavailable.value) return
busy.value = true
stopPolling()
const current = revision
try {
if (refresh) data = await refreshCashierContext()
if (disposed || current !== revision || !data?.launchParams || !data.selectedPlatform || data.status !== "20"
|| context.value?.status !== "20" || terminal.value || confirming.value || unavailable.value) return
redirecting = data.launchParams.mode === "ALIPAY_WAP"
const result = await invokePayment(data.selectedPlatform, data.launchParams)
if (disposed || current !== revision) return
confirming.value = result === "confirming"
if (redirecting) {
feedback.value = "正在前往支付宝收银台,请勿重复付款"
return
}
feedback.value = result === "cancelled" ? "已取消付款,可继续付款" : result === "failed" ? "未完成付款,可继续付款" : "付款结果确认中,请勿重复付款"
await checkStatus()
} catch (cause) {
if (!disposed && current === revision) feedback.value = cause instanceof Error ? cause.message : "未能调起付款,请稍后重试"
if (!disposed && current === revision) {
redirecting = false
feedback.value = cause instanceof Error ? cause.message : "未能调起付款,请稍后重试"
}
} finally {
if (!disposed && current === revision) { busy.value = false; queuePolling() }
if (!disposed && current === revision) {
if (redirecting) stopPolling()
else { busy.value = false; queuePolling() }
}
}
}
/** 入口加载失败始终显示错误,只有真实商户加载成功后才开放输入或支付。 */
async function load() {
if (! ["/", "/pages/pay/index"].includes(paymentPagePath())) return
const current = ++revision
stopPolling()
loading.value = true
@@ -194,11 +234,18 @@ export function useMerchantPayment() {
pending.value = stored
}
applyStatus(data)
// 只在新的 OAuth 票据第一次到达时自动调起,刷新不会重复自动弹出收银台。
// WAP 回跳只查询原单;返回提示没有成功语义,不能触发首次自动导航循环。
const marker = `merchant-pay:launched:${parsed.ticket.ticket}`
if (!unavailable.value && !sessionStorage.getItem(marker) && data.status === "20" && data.launchParams) {
if (parsed.returned) {
sessionStorage.setItem(marker, "1")
void launch()
if (!terminal.value && !unavailable.value) {
feedback.value = "正在查询付款结果…"
await checkStatus()
if (!disposed && current === revision && !terminal.value) feedback.value = "尚未确认付款成功,可继续付款或稍后查询"
}
} else if (!unavailable.value && !sessionStorage.getItem(marker) && data.status === "20" && data.launchParams) {
sessionStorage.setItem(marker, "1")
void launch(false)
}
queuePolling()
}
@@ -271,15 +318,40 @@ export function useMerchantPayment() {
/** 只有未提交的线下金额允许删除。 */
function remove() { if (editable.value) input.remove() }
/** 离开 WAP 收银台后解除本页导航锁,保留原金额与请求,只查询服务端真实状态。 */
async function resumePayment() {
if (! ["/", "/pages/pay/index"].includes(paymentPagePath())) return
const returning = redirecting
if (redirecting) {
redirecting = false
busy.value = false
confirming.value = false
feedback.value = "正在查询付款结果…"
}
if (ticket.value && !terminal.value) {
const current = revision
await checkStatus()
if (returning && !disposed && current === revision && !terminal.value && !error.value) feedback.value = "尚未确认付款成功,可继续付款或稍后查询"
}
}
/** 后台恢复时先查真实状态,页面隐藏期间停止轮询。 */
function handleVisibility() {
if (document.hidden) stopPolling()
else if (ticket.value && !terminal.value) void checkStatus()
else void resumePayment()
}
/** 浏览器后退可能恢复冻结的页面及 busy 状态;恢复后不自动再次进入支付宝。 */
function handlePageShow(event: PageTransitionEvent) {
// 初次 pageshow 可能晚于接口返回,只有历史页面恢复才解除导航锁,避免打断首次跳转。
if (event.persisted) void resumePayment()
}
/** 单页 Hash 入口变化也重新校验,避免浏览器复用同一页面时沿用上一商户或金额。 */
function handleHashChange() {
if (! ["/", "/pages/pay/index"].includes(paymentPagePath())) { suspend(); return }
busy.value = false
redirecting = false
context.value = undefined
ticket.value = undefined
entry.value = undefined
@@ -295,6 +367,7 @@ export function useMerchantPayment() {
onMounted(() => {
document.addEventListener("visibilitychange", handleVisibility)
window.addEventListener("hashchange", handleHashChange)
window.addEventListener("pageshow", handlePageShow)
void load()
})
/** 释放轮询和监听,未返回的请求不能再修改页面状态。 */
@@ -302,8 +375,9 @@ export function useMerchantPayment() {
disposed = true; revision++; stopPolling()
document.removeEventListener("visibilitychange", handleVisibility)
window.removeEventListener("hashchange", handleHashChange)
window.removeEventListener("pageshow", handlePageShow)
})
return { merchant, amount, loading, busy, error, feedback, editable, canPay, showKeyboard, canRestart, confirming, success, terminal, unavailable,
pay, restart, retry, append, remove }
return { merchant, amount, context, ticket, loading, busy, error, feedback, editable, canPay, showKeyboard, canRestart, confirming, success, terminal, unavailable,
pay, restart, retry, append, remove, suspend }
}
+194
View File
@@ -0,0 +1,194 @@
import { computed, onMounted, onUnmounted, ref } from "vue"
import { getCashierContext, getPaymentStatus } from "@@/api/payment"
import type { CashierContext, CashierTicket, PaymentResult } from "@@/api/payment"
import { formatFen, parsePaymentEntry } from "@@/utils/payment-entry"
import { paymentCashierRoute, paymentPagePath, resolvePaymentResultPhase } from "@@/utils/payment-result"
/** 结果页只读查询的串行间隔,与原收银页一致,避免同 IP 触发高频限流。 */
const POLL_INTERVAL_MS = 4000
/** 单次结果页主动确认最多持续两分钟;超时后仍为结果未知,用户可手动重查。 */
const POLL_WINDOW_MS = 120000
/** 服务端明确的付款终态;暂停轮询后仍可手动查询迟到的真实结果。 */
const TERMINAL_STATUSES = ["30", "40", "50", "60"]
/** 独立结果页只访问查询接口,永远不创建会话、不调起支付,也不释放待确认付款意图。 */
export function usePaymentResult() {
/** 当前短期票据;无效入口不能请求接口或返回无凭据收银页。 */
const ticket = ref<CashierTicket>()
/** 金额、商户和支付渠道全部取自服务端票据上下文。 */
const context = ref<CashierContext>()
/** 当前查询请求是否尚未完成,用于禁用重复点击。 */
const loading = ref(true)
/** 查询失败、票据失效或轮询超时的提示,不能据此声明付款失败。 */
const error = ref("")
/** 结果页离开后阻止旧请求回写。 */
let revision = 0
/** 当前在途查询所属的页面版本;切换票据允许新版本查询,旧 finally 不能解锁新请求。 */
let activeQueryRevision: number | undefined
/** 页面是否已销毁。 */
let disposed = false
/** 当前自动确认窗口的开始时间,单位毫秒,不用于判断交易终态。 */
let startedAt = 0
/** 仅为当前结果页安排一次下一轮状态查询。 */
let pollTimer: ReturnType<typeof setTimeout> | undefined
/** 成功只认 paySuccess,失败只认后端明确终态;网络和票据错误保持未知。 */
const phase = computed(() => resolvePaymentResultPhase(context.value, !!error.value))
/** 不把缺失或异常金额显示成 0.00,避免虚假的付款凭证。 */
const amount = computed(() => context.value && Number.isSafeInteger(context.value.amount) && context.value.amount > 0 ? formatFen(context.value.amount) : undefined)
/** 商户名称仅来自已加载的真实上下文。 */
const merchantName = computed(() => context.value?.merchant?.name || "收款信息暂不可用")
/** 支付方式按已绑定渠道展示,尚未绑定时不猜测用户的支付平台。 */
const platformName = computed(() => context.value?.selectedPlatform === "10" ? "微信支付" : context.value?.selectedPlatform === "20" ? "支付宝支付" : "待确认")
/** 本地支付流水号;不使用不存在的消费订单编号。 */
const payNo = computed(() => context.value?.payNo || "待生成")
/** 只有已确认真实商户上下文的原票据可以返回收银,返回不会自动再次付款。 */
const canReturnToCashier = computed(() => !!ticket.value && !!context.value)
/** 结果标题与动画共同表达状态,不能只靠红绿颜色区分。 */
const title = computed(() => phase.value === "success" ? "支付成功" : phase.value === "error" ? "支付未完成" : phase.value === "unknown" ? "结果暂未确认" : "正在确认付款结果")
/** 关闭、到期和创建失败说明具体原因;未知结果明确要求继续确认原单。 */
const description = computed(() => {
if (phase.value === "success") return "本次付款已完成,请向商户确认收款。"
if (phase.value === "error") {
if (context.value?.status === "50") return "本次收款请求已过期,可返回收银台重新核对。"
if (context.value?.status === "60") return "本次付款未能创建,请返回收银台或重新扫码。"
return "本次交易已关闭,未完成付款。"
}
return error.value || "正在查询实际付款状态,请稍候,不要重复付款。"
})
/** 清除下一次轮询,不中断也不重复发出已经在途的请求。 */
function stopPolling() {
if (pollTimer) clearTimeout(pollTimer)
pollTimer = undefined
}
/** 路由已离开或切换票据时,令此前请求过期并停止后台轮询。 */
function suspend() { revision++; stopPolling() }
/** 只在同一结果页可见且未结束时继续查询,超过确认窗口显示未知而不是失败。 */
function queuePolling() {
stopPolling()
if (disposed || document.hidden || paymentPagePath() !== "/pages/pay/pay-complete" || error.value || !context.value
|| context.value.paySuccess || TERMINAL_STATUSES.includes(context.value.status)) return
if (Date.now() - startedAt >= POLL_WINDOW_MS) {
error.value = "付款结果仍在确认,请稍后重新查询;如已扣款,请勿重复付款。"
return
}
pollTimer = setTimeout(() => { void query(false) }, POLL_INTERVAL_MS)
}
/** 合并当前票据的服务端状态;旧待支付响应不能覆盖已经确认的成功。 */
function applyResult(result: PaymentResult) {
if (!context.value || (context.value.paySuccess && !result.paySuccess)) return
if (TERMINAL_STATUSES.includes(context.value.status) && !TERMINAL_STATUSES.includes(result.status)) return
if (typeof result.paySuccess !== "boolean" || !["10", "15", "20", "30", "40", "50", "60"].includes(result.status)) {
throw new Error("付款结果暂不完整,请稍后重新查询")
}
// 状态接口只合并凭证需要的字段,额外调起参数不能混入结果页。
context.value = { ...context.value, status: result.status, paySuccess: result.paySuccess,
payNo: typeof result.payNo === "string" && result.payNo ? result.payNo : context.value.payNo }
}
/** 首次读取上下文,此后只查状态;整个 Hook 不调用创建或任何支付 Bridge。 */
async function query(reloadContext: boolean) {
if (activeQueryRevision === revision || disposed || !ticket.value || document.hidden
|| paymentPagePath() !== "/pages/pay/pay-complete") return
const current = revision
activeQueryRevision = current
loading.value = true
try {
if (reloadContext || !context.value) {
const data = await getCashierContext(ticket.value)
if (disposed || current !== revision) return
// 结果页不保留或存储渠道调起参数,只展示服务端的收款凭证信息。
if (!data?.merchant?.id || typeof data.merchant.name !== "string" || !data.merchant.name.trim()
|| !Number.isSafeInteger(data.amount) || data.amount <= 0 || typeof data.paySuccess !== "boolean"
|| !["10", "15", "20", "30", "40", "50", "60"].includes(data.status)) {
throw new Error("付款信息暂不完整,请重新查询;如已扣款,请勿重复付款。")
}
const { launchParams: _launchParams, ...summary } = data
if (!context.value || (!context.value.paySuccess && (data.paySuccess
|| !TERMINAL_STATUSES.includes(context.value.status) || TERMINAL_STATUSES.includes(data.status)))) {
context.value = summary
}
} else {
const data = await getPaymentStatus(ticket.value)
if (disposed || current !== revision) return
applyResult(data)
}
error.value = ""
} catch (cause) {
if (!disposed && current === revision) error.value = cause instanceof Error ? cause.message : "暂时无法查询付款结果,请稍后重试"
} finally {
if (activeQueryRevision === current) activeQueryRevision = undefined
if (!disposed && current === revision) { loading.value = false; queuePolling() }
}
}
/** 从结果页刷新、浏览器后退或直接分享入口重新解析票据,URL 中的金额或状态无效。 */
async function load() {
suspend()
context.value = undefined
ticket.value = undefined
error.value = ""
startedAt = Date.now()
loading.value = true
try {
const entry = parsePaymentEntry(window.location.href)
if (entry.kind !== "cashier") throw new Error("付款结果凭据无效,请使用原付款页面查询")
ticket.value = entry.ticket
await query(true)
} catch (cause) {
error.value = cause instanceof Error ? cause.message : "付款结果凭据不可用,请重新扫码查询"
loading.value = false
}
}
/** 用户明确请求时重新读取完整上下文,查询失败不清除原金额、票据或幂等请求号。 */
async function refresh() {
if (activeQueryRevision === revision || disposed) return
stopPolling()
startedAt = Date.now()
if (!ticket.value) await load()
else await query(true)
}
/** 返回同一票据对应的收银页,不改变 requestId 或金额,也不自动调起第二次支付。 */
function returnToCashier() {
if (!canReturnToCashier.value || !ticket.value || activeQueryRevision === revision) return
suspend()
uni.redirectTo({ url: paymentCashierRoute(ticket.value), fail() { error.value = "暂时无法返回收银台,请稍后重试" } })
}
/** 页面不可见时暂停;再次可见仅查询状态,不重新打开支付收银台。 */
function handleVisibility() {
if (document.hidden) stopPolling()
else if (paymentPagePath() === "/pages/pay/pay-complete") { startedAt = Date.now(); void query(!context.value) }
}
/** bfcache 恢复不重放动画对应的付款,只恢复一次只读状态确认。 */
function handlePageShow(event: PageTransitionEvent) { if (event.persisted) handleVisibility() }
/** Hash 切换到其他页面时停止,切换同页票据时重新读取商户上下文。 */
function handleHashChange() {
if (paymentPagePath() !== "/pages/pay/pay-complete") { suspend(); return }
void load()
}
/** 初始化只读查询并绑定可见性,页面缺少有效票据时仅显示恢复提示。 */
onMounted(() => {
document.addEventListener("visibilitychange", handleVisibility)
window.addEventListener("pageshow", handlePageShow)
window.addEventListener("hashchange", handleHashChange)
void load()
})
/** 清理轮询与监听,避免页面销毁后旧接口结果影响新的付款。 */
onUnmounted(() => {
disposed = true; suspend()
document.removeEventListener("visibilitychange", handleVisibility)
window.removeEventListener("pageshow", handlePageShow)
window.removeEventListener("hashchange", handleHashChange)
})
return { phase, title, description, amount, merchantName, platformName, payNo, context, loading, canReturnToCashier, refresh, returnToCashier }
}
+43 -29
View File
@@ -1,37 +1,56 @@
import type { PaymentLaunchParams } from "@@/api/payment"
/** 客户端回调只代表调起结果,最终付款状态必须再查询后端。 */
export type BridgeResult = "confirming" | "cancelled" | "failed"
/** 两种客户端桥的最小接口,不引入 App SDK 或其他平台能力。 */
interface PaymentBridge {
/** 支付宝 tradePay 接口。 */
call?: (name: string, params: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => void
/** 微信 getBrandWCPayRequest 接口。 */
invoke?: (name: string, params: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => void
/** 微信客户端注入的最小支付桥,支付宝只走 WAP,不再保留 tradeNO/JSAPI 调起分支。 */
interface WechatPaymentBridge {
/** 微信 getBrandWCPayRequest,调用结果仍需后端确认。 */
invoke: (name: string, params: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) => void
}
/** 当前客户端注入的全局桥对象,仅在 H5 运行期访问。 */
type BridgeWindow = Window & { AlipayJSBridge?: PaymentBridge; WeixinJSBridge?: PaymentBridge }
/** 微信桥只在 H5 运行时读取,不引入原生 App 支付 SDK。 */
type BridgeWindow = Window & { WeixinJSBridge?: WechatPaymentBridge }
/** 把平台回调归一化;9000/ok 仍进入服务端结果确认,不直接展示支付成功。 */
export function bridgeResult(platform: "10" | "20", result: Record<string, unknown>): BridgeResult {
if (platform === "20") {
const code = String(result.resultCode || "")
if (["9000", "8000", "6004"].includes(code)) return "confirming"
return code === "6001" ? "cancelled" : "failed"
/** 微信客户端回调只归一化调起状态,ok 仍需服务端结果确认。 */
export function wechatBridgeResult(result: Record<string, unknown>): BridgeResult {
const message = String(result.err_msg || "").toLowerCase()
return message.endsWith(":ok") ? "confirming" : message.endsWith(":cancel") ? "cancelled" : "failed"
}
/** 仅允许手机网站支付的官方 HTTPS 网关,禁止接口参数把页面导航到任意来源。 */
export function validateAlipayWapUrl(value: unknown): string {
if (typeof value !== "string") throw new Error("支付宝付款地址不可用,请重新查询")
let url: URL
try { url = new URL(value) } catch { throw new Error("支付宝付款地址不可用,请重新查询") }
if (url.protocol !== "https:" || url.hostname !== "openapi.alipay.com" || url.pathname !== "/gateway.do"
|| url.username || url.password || (url.port && url.port !== "443") || url.hash
|| url.searchParams.getAll("method").length !== 1 || url.searchParams.get("method") !== "alipay.trade.wap.pay") {
throw new Error("支付宝付款地址不可用,请重新查询")
}
const message = String(result.err_msg || "").toLowerCase()
return message.endsWith(":ok") ? "confirming" : message.endsWith(":cancel") ? "cancelled" : "failed"
return url.href
}
/** 等待客户端桥就绪并调起一次;取消或失败不自动重复唤起。 */
export function invokePayment(platform: "10" | "20", params: Record<string, unknown>): Promise<BridgeResult> {
/** WAP 导航或客户端桥均只发起一次;返回值仍需后端确认,不能据此展示成功。 */
export function invokePayment(platform: "10" | "20", params: PaymentLaunchParams): Promise<BridgeResult> {
const client = navigator.userAgent.toLowerCase()
if ((platform === "10" && !client.includes("micromessenger")) || (platform === "20" && !client.includes("alipayclient"))) {
return Promise.reject(new Error("请在原微信或支付宝客户端中继续付款"))
}
if (platform === "20") {
try {
if (params.mode !== "ALIPAY_WAP") throw new Error("支付宝收银参数不可用,请重新查询")
// 不等待 JSBridge,浏览器离开前只进入确认状态;回跳参数不承担成功判定。
window.location.assign(validateAlipayWapUrl(params.payUrl))
return Promise.resolve("confirming")
} catch (cause) {
return Promise.reject(cause)
}
}
if (params.mode !== undefined) return Promise.reject(new Error("付款方式不可用,请重新查询"))
return new Promise((resolve, reject) => {
/** 当前桥等待和回调仅属于本次用户调起操作。 */
const readyEvent = platform === "20" ? "AlipayJSBridgeReady" : "WeixinJSBridgeReady"
const readyEvent = "WeixinJSBridgeReady"
let invoked = false
let settled = false
let timer: ReturnType<typeof setTimeout>
@@ -48,13 +67,9 @@ export function invokePayment(platform: "10" | "20", params: Record<string, unkn
function launch() {
if (invoked || settled) return
const host = window as BridgeWindow
const bridge = platform === "20" ? host.AlipayJSBridge : host.WeixinJSBridge
if (!bridge || (platform === "20" ? !bridge.call : !bridge.invoke)) return
if (
(platform === "20" && typeof params.tradeNO !== "string") ||
(platform === "10" &&
!["appId", "timeStamp", "nonceStr", "package", "signType", "paySign"].every((key) => typeof params[key] === "string"))
) {
const bridge = host.WeixinJSBridge
if (!bridge?.invoke) return
if (!["appId", "timeStamp", "nonceStr", "package", "signType", "paySign"].every((key) => typeof params[key] === "string")) {
finish(undefined, new Error("付款参数不可用,请重新扫码"))
return
}
@@ -63,9 +78,8 @@ export function invokePayment(platform: "10" | "20", params: Record<string, unkn
// 用户在平台收银台停留较久时只转入查单,不据超时认定失败或再发起一笔支付。
timer = setTimeout(() => finish("confirming"), 30000)
try {
const callback = (result: Record<string, unknown>) => finish(bridgeResult(platform, result))
if (platform === "20") bridge.call!("tradePay", { tradeNO: params.tradeNO }, callback)
else bridge.invoke!("getBrandWCPayRequest", params, callback)
const callback = (result: Record<string, unknown>) => finish(wechatBridgeResult(result))
bridge.invoke("getBrandWCPayRequest", params, callback)
} catch {
finish(undefined, new Error("支付组件调用失败,请稍后继续付款"))
}
+7 -3
View File
@@ -3,21 +3,25 @@ import type { CashierTicket, MerchantEntry } from "@@/api/payment"
/** 初版单笔上限为 9,999.99 元,与后端配置同步,单位为分。 */
export const MAX_OFFLINE_AMOUNT = 999999
/** 二维码只能是签名商户入口或短期收银票据两种形式。 */
export type PaymentEntry = { kind: "merchant"; entry: MerchantEntry } | { kind: "cashier"; ticket: CashierTicket }
/** 二维码只能是签名商户入口或短期收银票据;returned 仅防止 WAP 回跳重复调起,不表示支付结果。 */
export type PaymentEntry = { kind: "merchant"; entry: MerchantEntry } | { kind: "cashier"; ticket: CashierTicket; returned: boolean }
/** 解析 Hash 路由参数;拒绝重复参数及混用入口,避免跳转时悄悄切换收款对象。 */
export function parsePaymentEntry(href: string): PaymentEntry {
const url = new URL(href)
const params = new URLSearchParams(url.hash.includes("?") ? url.hash.slice(url.hash.indexOf("?") + 1) : url.search)
if (Array.from(params.keys()).some(key => params.getAll(key).length !== 1)) throw new Error("收款链接无效,请重新扫码")
// 后端授权或下单失败会回到固定错误入口,不能把该入口误判为二维码参数缺失。
if (params.get("error") === "UNAVAILABLE") throw new Error("付款暂时不可用,请稍后重试或联系商户")
if (params.has("ticket") || params.has("tenantCode")) {
if (params.has("id") || params.has("sign") || params.has("tenantId")) throw new Error("收银凭据无效,请重新扫码")
if (params.has("returned") && params.get("returned") !== "1") throw new Error("收银凭据无效,请重新扫码")
const ticket = params.get("ticket") || ""
const tenantCode = params.get("tenantCode") || ""
if (!/^[A-Za-z0-9_-]{32}$/.test(ticket) || !tenantCode || tenantCode.length > 128) throw new Error("收银凭据无效,请重新扫码")
return { kind: "cashier", ticket: { ticket, tenantCode } }
return { kind: "cashier", ticket: { ticket, tenantCode }, returned: params.get("returned") === "1" }
}
if (params.has("returned")) throw new Error("收款链接无效,请重新扫码")
const entry = { id: params.get("id") || "", tenantId: params.get("tenantId") || "", v: params.get("v") || "", sign: params.get("sign") || "" }
if (!/^[1-9]\d{0,18}$/.test(entry.id) || !/^[1-9]\d{0,18}$/.test(entry.tenantId)
|| entry.v !== "1" || !/^[0-9a-f]{64}$/.test(entry.sign)) throw new Error("收款链接无效,请重新扫码")
+39
View File
@@ -0,0 +1,39 @@
import type { CashierTicket, PaymentResult } from "@@/api/payment"
/** 结果页仅依据服务端事实区分成功、明确失败和仍待确认;网络异常不属于支付失败。 */
export type PaymentResultPhase = "loading" | "success" | "error" | "unknown"
/** 服务端明确结束且未付款的状态:关闭、过期、创建失败;不把处理中或取消桥回调作为失败。 */
const FAILED_STATUSES = ["40", "50", "60"]
/** 成功标记优先于状态展示,URL 参数和客户端调起结果均不能参与判定。 */
export function resolvePaymentResultPhase(result?: PaymentResult, unavailable = false): PaymentResultPhase {
if (result?.paySuccess === true) return "success"
if (result && FAILED_STATUSES.includes(result.status)) return "error"
if (unavailable || result?.status === "30") return "unknown"
return "loading"
}
/** 获取当前 uni-app Hash 页面;离开收银页时旧 Hook 必须停止,避免结果页再次调起付款。 */
export function paymentPagePath(href = window.location.href): string {
const hash = new URL(href).hash.slice(1)
return (hash.split("?")[0] || "/").replace(/\/$/, "") || "/"
}
/** 结果页地址只带后端票据,不携带金额、成功状态或支付宝支付签名。 */
export function paymentResultRoute(ticket: CashierTicket): string {
const query = new URLSearchParams({ tenantCode: ticket.tenantCode, ticket: ticket.ticket }).toString()
return `/pages/pay/pay-complete?${query}`
}
/** 返回原收银上下文只允许手动继续原单,resultViewed 只抑制结果页来回导航,不参与付款判定。 */
export function paymentCashierRoute(ticket: CashierTicket): string {
const query = new URLSearchParams({ tenantCode: ticket.tenantCode, ticket: ticket.ticket, returned: "1", resultViewed: "1" }).toString()
return `/pages/pay/index?${query}`
}
/** 用户已从结果页返回时保留原收银展示;该提示不能改变服务端状态或开放新交易。 */
export function hasViewedPaymentResult(href = window.location.href): boolean {
const hash = new URL(href).hash
return new URLSearchParams(hash.slice(hash.indexOf("?") + 1)).get("resultViewed") === "1"
}
+1
View File
@@ -0,0 +1 @@
{"v":"4.6.3","fr":24,"ip":0,"op":21,"w":320,"h":320,"nm":"checklist","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 13","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":300},"p":{"a":0,"k":[160,159.5,0]},"a":{"a":0,"k":[0,-34,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"n":["0p667_1_0p167_0p167","0p667_1_0p167_0p167"],"t":6,"s":[15.021,15.021],"e":[0,0]},{"t":17}]},"p":{"a":0,"k":[0,0]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":6,"s":[0.8274509,0.1843137,0.1843137,1],"e":[0.8274509,0.1843137,0.1843137,1]},{"t":17}]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"n":"0p667_1_0p167_0p167","t":6,"s":[-8.142,-92.147],"e":[-7.675,-162.544],"to":[0.07779947668314,-11.7327470779419],"ti":[-0.07779947668314,11.7327470779419]},{"t":17}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[83.981,100],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"n":["0p667_1_0p167_0p167"],"t":6,"s":[20.367],"e":[6.367]},{"t":17}],"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 2","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"n":["0p833_0p833_0p167_0p167","0p833_0p833_0p167_0p167"],"t":6,"s":[15.021,15.021],"e":[0,0]},{"t":21}]},"p":{"a":0,"k":[0,0]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":6,"s":[0.8274509,0.1843137,0.1843137,1],"e":[0.8274509,0.1843137,0.1843137,1]},{"t":17}]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":6,"s":[16.585,-99.759],"e":[28.521,-187.495],"to":[1.9892578125,-14.6227216720581],"ti":[-1.9892578125,14.6227216720581]},{"t":21}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[97.419,116],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":6,"s":[14.733],"e":[8.733]},{"t":21}],"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":2,"mn":"ADBE Vector Group"}],"ip":6,"op":22,"st":-21,"bm":0,"sr":1},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 12","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":250},"p":{"a":0,"k":[160,159.5,0]},"a":{"a":0,"k":[0,-34,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"n":["0p667_1_0p167_0p167","0p667_1_0p167_0p167"],"t":6,"s":[15.021,15.021],"e":[0,0]},{"t":17}]},"p":{"a":0,"k":[0,0]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":6,"s":[0.8274509,0.1843137,0.1843137,1],"e":[0.8274509,0.1843137,0.1843137,1]},{"t":17}]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"n":"0p667_1_0p167_0p167","t":6,"s":[-8.142,-92.147],"e":[-7.675,-162.544],"to":[0.07779947668314,-11.7327470779419],"ti":[-0.07779947668314,11.7327470779419]},{"t":17}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[83.981,100],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"n":["0p667_1_0p167_0p167"],"t":6,"s":[20.367],"e":[6.367]},{"t":17}],"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 2","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"n":["0p833_0p833_0p167_0p167","0p833_0p833_0p167_0p167"],"t":6,"s":[15.021,15.021],"e":[0,0]},{"t":21}]},"p":{"a":0,"k":[0,0]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":6,"s":[0.8274509,0.1843137,0.1843137,1],"e":[0.8274509,0.1843137,0.1843137,1]},{"t":17}]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":6,"s":[16.585,-99.759],"e":[28.521,-187.495],"to":[1.9892578125,-14.6227216720581],"ti":[-1.9892578125,Line truncated
+1
View File
@@ -0,0 +1 @@
{"v":"5.5.7","meta":{"g":"LottieFiles AE 0.1.20","a":"","k":"","d":"","tc":""},"fr":60,"ip":0,"op":79,"w":50,"h":50,"nm":"spinner","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[70]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[60]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[50]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40,"s":[40]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[30]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[20]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[10]},{"t":80,"s":[0]}],"ix":11},"r":{"a":0,"k":360,"ix":10},"p":{"a":0,"k":[24.688,24.938,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[4,12],"ix":2},"p":{"a":0,"k":[0,-4],"ix":3},"r":{"a":0,"k":15,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.28627450980392155,0.3137254901960784,0.3411764705882353,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0.248,-12.664],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[10]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[70]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[60]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40,"s":[50]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[40]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[30]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[20]},{"t":80,"s":[10]}],"ix":11},"r":{"a":0,"k":405,"ix":10},"p":{"a":0,"k":[24.688,24.938,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[4,12],"ix":2},"p":{"a":0,"k":[0,-4],"ix":3},"r":{"a":0,"k":15,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.28627450980392155,0.3137254901960784,0.3411764705882353,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0.248,-12.664],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[20]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[10]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[70]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40,"s":[60]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[50]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[40]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[30]},{"t":80,"s":[20]}],"ix":11},"r":{"a":0,"k":450,"ix":10},"p":{"a":0,"k":[24.688,24.938,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[4,12],"ix":2},"p":{"a":0,"k":[0,-4],"ix":3},"r":{"a":0,"k":15,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.28627450980392155,0.3137254901960784,0.3411764705882353,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0.248,-12.664],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"bLine truncated
File diff suppressed because it is too large. Load diff