收银台小程序示例(道具直购)
可直接使用的收银台页面,配合网关产品 wechat_xpay_goods 使用。接口说明见 商户接入文档 第 7 节。
放进小程序
-
复制整个
pages/cashier/目录(index.js、index.wxml、index.wxss、index.json、core.js)到小程序项目,并在app.json的pages中加入"pages/cashier/index"。 -
把
index.js中的API改为网关的公网 https 地址,并加入小程序后台的 request 合法域名。 -
小程序后台 → 开发管理 → 消息推送:URL 填
https://<网关域名>/api/aggpay/notify/wechat_xpay/<渠道账号ID>,Token / EncodingAESKey 与渠道账号凭证一致,数据格式选 JSON,建议选安全模式。保存时微信会发 GET 校验,网关会自动应答。 -
可选:深色模式。页面样式已适配,需要在
app.json中打开,并把导航栏颜色改成主题变量:
{
"darkmode": true,
"themeLocation": "theme.json",
"window": {
"navigationBarBackgroundColor": "@navBgColor",
"navigationBarTextStyle": "@navTextStyle",
"backgroundColor": "@bgColor",
"backgroundTextStyle": "@bgTextStyle"
}
}
theme.json:
{
"light": { "navBgColor": "#f4f5f7", "navTextStyle": "black", "bgColor": "#f4f5f7", "bgTextStyle": "dark" },
"dark": { "navBgColor": "#111214", "navTextStyle": "white", "bgColor": "#111214", "bgTextStyle": "light" }
}
不开深色模式时,把 index.json 里的 "backgroundTextStyle": "@bgTextStyle" 改成 "dark"。
页面行为
| 文件 | 内容 |
|---|---|
core.js |
纯逻辑(不依赖 wx):金额格式、倒计时、错误码 → 中文提示、订单状态 → 页面状态、轮询间隔 |
index.js |
页面状态与 wx 调用 |
- 进入页面自动调起支付(用户已在 App 内确认购买)。之后按状态给出主按钮:继续支付(已取消)/ 重新支付(可重试的失败)/ 刷新结果(确认中)/ 返回 App(仅从 App 打开、场景值 1069 时;否则提示手动返回)。下拉刷新等同「刷新结果」。
- 同一时刻只进行一个操作,连点不会重复调用
prepare(每单最多 20 次)。 - 付款后轮询
/status,间隔从 1 秒逐步放慢到 10 秒,约 46 秒后显示「支付结果确认中」;轮询期间断网不会判为失败。 - 切到后台时暂停轮询和倒计时,回到前台继续,并对「已取消 / 失败 / 确认中」静默复查一次;离开页面后清理全部定时器。
- 倒计时按网关响应头
Date校正本机时钟;到 0 时先向网关复查,再显示「订单已过期」。已付款等待确认的订单不会因过期显示失败。 prepare返回 409 时先查订单:已支付 → 支付成功;已关闭 → 订单已关闭;已过期 → 订单已过期;仍待支付 → 「暂时无法支付」(订单已被其他微信号打开,或尝试次数过多)。- 只在 Android / 鸿蒙 / Windows 微信上调起支付;其他设备(含 iOS)只展示订单,不调用
prepare,避免把当前微信号绑定为付款人。 - 返回 App 时
app-parameter为paid/pending/cancel/failed,仅供 App 刷新界面。
App 端拉起(微信 OpenSDK)
App 后端调网关 /api/aggpay/v1/orders 下单,拿到:
"pay_action": {"type": "mini_program", "value": {"appid": "wx...", "username": "gh_...", "path": "pages/cashier/index?t=...", "mini_program_type": 0}}
App 用它拉起(App 与小程序须绑定在同一个微信开放平台账号下):
// Android
WXLaunchMiniProgram.Req req = new WXLaunchMiniProgram.Req();
req.userName = value.username; // gh_...
req.path = value.path; // 原样使用,含一次性令牌
req.miniprogramType = value.mini_program_type; // 0 正式版 1 开发版 2 体验版
api.sendReq(req);
// iOS
WXLaunchMiniProgramReq *req = [WXLaunchMiniProgramReq object];
req.userName = value[@"username"];
req.path = value[@"path"];
req.miniProgramType = (WXMiniProgramType)[value[@"mini_program_type"] integerValue];
[WXApi sendReq:req completion:nil];
发货以 App 后端收到的网关签名通知(order.paid)为准,不要以小程序返回或 launchApp 的参数为准。
示例源码
pages/cashier/index.js
// 收银台页面示例(虚拟支付·道具直购)。复制整个 pages/cashier/ 到小程序项目。
// App 通过 WXLaunchMiniProgram 打开 path = "pages/cashier/index?t=<一次性令牌>"。
//
// 要求:
// - 下面的 API 域名加入小程序后台 "request 合法域名"。
// - 支付结果以网关的服务端通知为准;本页的状态只用于展示,App 不能凭它发货。
//
// 交互约定:
// - 进入页面自动调起支付(用户已在 App 内确认)。
// - 同一时刻只有一个操作在进行(inflight 锁),连点不会重复 wx.login / prepare(每单最多 20 次 prepare)。
// - 所有定时器登记在 this.timers;onHide 暂停、onShow 恢复、onUnload 全部清理。
// - 倒计时按网关时间(响应头 Date 校正本机时钟偏差);到 0 时以 /status 为准,不凭本机时间直接判定。
const API = 'https://pay.example.com/api/aggpay/cashier'; // 改成你的网关公网 https 域名
const core = require('./core');
let clockOffset = 0; // 网关时间 - 本机时间(毫秒)
function now() {
return Date.now() + clockOffset;
}
function call(path, data) {
return new Promise((resolve, reject) => {
wx.request({
url: API + path,
method: 'POST',
data,
timeout: 15000,
success: (r) => {
const date = r.header && (r.header.Date || r.header.date);
const serverMs = date ? Date.parse(date) : NaN;
if (Number.isFinite(serverMs)) clockOffset = serverMs - Date.now();
if (r.data && r.data.code === 0) {
resolve(r.data.data);
} else {
reject({ status: r.statusCode, code: r.data && r.data.code, message: (r.data && r.data.message) || '' });
}
},
fail: (e) => reject({ network: true, message: (e && e.errMsg) || '' }),
});
});
}
function login() {
return new Promise((resolve, reject) => {
wx.login({
success: (r) => (r.code ? resolve(r.code) : reject({ login: true })),
fail: () => reject({ login: true }),
});
});
}
function requestVirtualPayment(v) {
return new Promise((resolve, reject) => {
wx.requestVirtualPayment({
mode: v.mode, // short_series_goods
signData: v.signData, // 网关返回的 JSON 字符串,原样传入,不能重新序列化
paySig: v.paySig,
signature: v.signature,
success: resolve,
fail: reject,
});
});
}
function platform() {
try {
return (wx.getDeviceInfo ? wx.getDeviceInfo() : wx.getSystemInfoSync()).platform || 'unknown';
} catch (e) {
return 'unknown';
}
}
function enterScene() {
try {
return wx.getEnterOptionsSync().scene || 0;
} catch (e) {
return 0;
}
}
Page({
data: {
phase: 'init',
icon: 'spin',
title: '正在准备支付',
desc: '请稍候',
detail: '',
payText: '',
canRefresh: false,
canBack: false,
backPrimary: false,
busy: false,
order: null,
amountInt: '',
amountDec: '',
remain: '',
urgent: false,
fromApp: false,
appParameter: 'pending',
platform: '',
},
onLoad(query) {
this.alive = true;
this.hidden = false;
this.timers = {};
this.phase = 'init';
this.inflight = false;
this.polling = false;
this.pollIndex = 0;
this.paidAttempt = false; // 支付窗口返回成功后为 true
this.order = null;
this.expireAt = null;
this.token = core.isToken(query && query.t) ? query.t : '';
const scene = enterScene();
this.setData({ fromApp: scene === 1069, platform: platform() });
if (!this.token) {
this.show('invalid');
return;
}
if (!core.isPayablePlatform(this.data.platform)) {
// 不在不支持的设备上调用 prepare:prepare 会把当前微信用户绑定为付款人。
this.showOrderOnly();
return;
}
this.pay();
},
onShow() {
if (!this.alive || !this.hidden) return;
this.hidden = false;
this.tick();
if (this.phase === 'checking') {
if (!this.polling && !this.timers.poll) this.poll();
} else if (core.REFRESHABLE[this.phase]) {
this.refresh(false); // 静默复查:从别处付款 / 订单已关闭
}
},
onHide() {
this.hidden = true;
this.clear('tick');
this.clear('poll');
},
onUnload() {
this.alive = false;
Object.keys(this.timers).forEach((name) => this.clear(name));
},
onPullDownRefresh() {
const done = () => wx.stopPullDownRefresh();
if (core.REFRESHABLE[this.phase] && !this.inflight) {
this.refresh(true).then(done, done);
} else {
done();
}
},
// ---------------------------------------------------------------- 内部工具
set(patch) {
if (this.alive) this.setData(patch);
},
later(name, ms, fn) {
this.clear(name);
this.timers[name] = setTimeout(() => {
delete this.timers[name];
if (this.alive) fn();
}, ms);
},
clear(name) {
if (this.timers[name]) {
clearTimeout(this.timers[name]);
delete this.timers[name];
}
},
show(phase, extra) {
this.phase = phase;
if (phase !== 'checking') this.clear('poll');
const v = core.present(phase, extra);
v.phase = phase;
v.appParameter = core.appParameter(phase);
this.set(v);
this.tick();
if (phase === 'paid') {
try {
wx.vibrateShort({ type: 'light' });
} catch (e) {
// 可选反馈
}
}
},
setOrder(o) {
if (!o) return;
this.order = o;
this.expireAt = core.parseTime(o.expire_at);
const a = core.splitAmount(o.amount);
this.set({ order: { subject: o.subject || '商品', orderNo: o.order_no || '' }, amountInt: a.int, amountDec: a.dec });
},
// 倒计时;到 0 时空闲阶段向网关复查(多半会进入 expired)
tick() {
this.clear('tick');
const visible = this.order && this.order.status === 'created' && this.expireAt !== null && core.COUNTDOWN[this.phase];
if (!visible) {
if (this.data.remain) this.set({ remain: '', urgent: false });
return;
}
const left = this.expireAt - now();
if (left <= 0) {
this.set({ remain: '00:00', urgent: true });
if (core.REFRESHABLE[this.phase]) this.refresh(false);
return;
}
this.set({ remain: core.formatRemain(left), urgent: left < 60000 });
if (!this.hidden) this.later('tick', left % 1000 || 1000, () => this.tick());
},
// 拉取订单状态;网络 / 服务错误返回 null
async fetch() {
try {
const o = await call('/status', { t: this.token });
this.setOrder(o);
return o;
} catch (e) {
return null;
}
},
// 订单已到终态则切换阶段并返回 true
applyOutcome(o) {
const out = core.outcome(o, now(), this.paidAttempt);
if (!out) return false;
this.show(out);
return true;
},
// ---------------------------------------------------------------- 操作
async pay() {
if (!this.alive || this.inflight || !core.CAN_PAY[this.phase]) return;
this.inflight = true;
this.set({ busy: true });
try {
await this.payFlow();
} finally {
this.inflight = false;
this.set({ busy: false });
}
// 流程中倒计时到 0 时复查被锁跳过了,这里补一次
if (this.alive) this.tick();
},
async payFlow() {
this.paidAttempt = false;
this.show('loading');
let res;
try {
// 每次都用新的 wx.login code:网关用它换取 openid / session_key(session_key 不落库)。
res = await call('/prepare', { t: this.token, code: await login() });
} catch (e) {
if (!this.alive) return;
if (e && e.status === 409) {
// 已支付 / 已关闭 / 已过期 / 被其他微信号占用 / 尝试过多:以订单状态为准
const o = await this.fetch();
if (!this.alive) return;
if (!o) return this.show('failed', core.prepareError({ network: true }));
if (!this.applyOutcome(o)) this.show('blocked');
return;
}
const c = core.prepareError(e);
return this.show(c.phase, c);
}
if (!this.alive) return;
this.setOrder(res.order);
if (!res.invoke) {
return this.show('closed', { title: '订单无法支付', desc: '支付渠道拒绝了这笔订单,请回到 App 重新下单' });
}
if (!this.alive) return;
this.show('paying');
try {
await requestVirtualPayment(res.invoke.value);
} catch (e) {
if (!this.alive) return;
const f = core.payError(e);
if (f.cancelled) return this.show('cancelled');
// 失败也可能是“已扣款但回包丢失”,先查一次
this.show('loading', { title: '正在核对支付结果', desc: '请稍候' });
const o = await this.fetch();
if (!this.alive) return;
if (o && this.applyOutcome(o)) return;
return this.show('failed', f);
}
if (!this.alive) return;
this.paidAttempt = true;
this.pollIndex = 0;
this.show('checking');
this.poll();
},
// 支付后轮询(推送 + 查单确认后才会变成 succeeded)
async poll() {
if (!this.alive || this.phase !== 'checking' || this.polling) return;
this.polling = true;
const o = await this.fetch();
this.polling = false;
if (!this.alive || this.phase !== 'checking') return;
if (o && this.applyOutcome(o)) return;
const delay = core.POLL_DELAYS[this.pollIndex];
if (delay === undefined) {
this.show('pending');
return;
}
this.pollIndex += 1;
if (!this.hidden) this.later('poll', delay, () => this.poll()); // 切到后台时暂停,onShow 继续
},
async refresh(manual) {
if (!this.alive || this.inflight || !core.REFRESHABLE[this.phase]) return;
this.inflight = true;
if (manual) this.set({ busy: true });
try {
const o = await this.fetch();
if (!this.alive) return;
if (o && this.applyOutcome(o)) return;
if (manual) wx.showToast({ title: o ? '暂未查到支付结果' : '网络不稳定,请稍后重试', icon: 'none' });
} finally {
this.inflight = false;
this.set({ busy: false });
}
},
onRefreshTap() {
this.refresh(true);
},
async showOrderOnly() {
this.inflight = true;
const o = await this.fetch();
this.inflight = false;
if (!this.alive) return;
if (o && this.applyOutcome(o)) return;
this.show('unsupported', { desc: '当前设备(' + this.data.platform + ')不支持虚拟支付,请在 Android 手机上打开' });
},
copyOrderNo() {
const no = this.data.order && this.data.order.orderNo;
if (no) wx.setClipboardData({ data: no });
},
onLaunchAppError(e) {
wx.showToast({ title: '请手动返回 App', icon: 'none' });
console.warn('launchApp failed', e && e.detail);
},
});
pages/cashier/index.wxml
<view class="page">
<!-- 状态 -->
<view class="hero">
<view class="icon icon--{{icon}}"><view class="glyph"></view></view>
<view class="title">{{title}}</view>
<view class="desc" wx:if="{{desc}}">{{desc}}</view>
<view class="detail" wx:if="{{detail}}">{{detail}}</view>
</view>
<!-- 订单 -->
<view class="card" wx:if="{{order}}">
<view class="subject">{{order.subject}}</view>
<view class="amount {{phase === 'paid' ? 'amount--paid' : ''}}">
<text class="cur">¥</text><text class="int">{{amountInt}}</text><text class="dec">.{{amountDec}}</text>
</view>
<view class="rows">
<view class="row" wx:if="{{remain}}">
<text class="k">剩余支付时间</text>
<text class="v num {{urgent ? 'v--urgent' : ''}}">{{remain}}</text>
</view>
<view class="row row--tap" hover-class="row--hover" bindtap="copyOrderNo">
<text class="k">订单号</text>
<text class="v mono">{{order.orderNo}}</text>
<text class="copy">复制</text>
</view>
</view>
</view>
<view class="card card--skeleton" wx:elif="{{phase === 'init' || phase === 'loading'}}">
<view class="bone bone--s"></view>
<view class="bone bone--l"></view>
<view class="bone bone--m"></view>
</view>
<view class="note">支付结果以 App 内的订单状态为准</view>
<!-- 操作栏 -->
<view class="bar" wx:if="{{payText || canRefresh || canBack}}">
<button wx:if="{{payText}}" class="btn btn--primary" hover-class="btn--hover"
loading="{{busy}}" disabled="{{busy}}" bindtap="pay">{{payText}}</button>
<button wx:if="{{canRefresh}}" class="btn btn--primary" hover-class="btn--hover"
loading="{{busy}}" disabled="{{busy}}" bindtap="onRefreshTap">刷新结果</button>
<!-- 只有从 App 打开(场景值 1069)时 launchApp 才可用 -->
<button wx:if="{{canBack && fromApp}}" class="btn {{backPrimary ? 'btn--primary' : 'btn--ghost'}}" hover-class="btn--hover"
disabled="{{busy}}" open-type="launchApp" app-parameter="{{appParameter}}" binderror="onLaunchAppError">返回 App</button>
<view wx:if="{{canBack && !fromApp}}" class="hint">请手动返回 App 查看订单</view>
</view>
</view>
pages/cashier/index.wxss
/* 颜色集中在变量里;深色模式见文件末尾(app.json 已开启 darkmode) */
page {
--bg: #f4f5f7;
--card: #ffffff;
--text: #16181d;
--text-2: #5c6270;
--text-3: #9aa0ab;
--line: #eceef2;
--brand: #07c160;
--brand-press: #06ad56;
--brand-soft: rgba(7, 193, 96, 0.12);
--wait: #2f6bff;
--wait-soft: rgba(47, 107, 255, 0.12);
--warn: #f29a1f;
--warn-soft: rgba(242, 154, 31, 0.14);
--stop: #8b919c;
--stop-soft: rgba(139, 145, 156, 0.14);
--danger: #e5484d;
--bone: #eceef2;
--bone-hi: #f6f7f9;
background: var(--bg);
}
.page {
min-height: 100vh;
box-sizing: border-box;
padding: 72rpx 32rpx calc(260rpx + env(safe-area-inset-bottom));
color: var(--text);
}
/* ---------------------------------------------------------------- 状态 */
.hero { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 0 24rpx 48rpx; }
.title { margin-top: 32rpx; font-size: 40rpx; font-weight: 600; letter-spacing: 1rpx; }
.desc { margin-top: 16rpx; font-size: 28rpx; line-height: 1.6; color: var(--text-2); }
.detail { margin-top: 12rpx; font-size: 24rpx; color: var(--text-3); }
.icon { position: relative; width: 128rpx; height: 128rpx; border-radius: 50%; animation: pop 0.32s ease-out; }
.glyph { position: absolute; left: 0; top: 0; right: 0; bottom: 0; }
/* 进行中:旋转圆环 */
.icon--spin { background: var(--brand-soft); animation: none; }
.icon--spin .glyph {
margin: 20rpx; border-radius: 50%;
border: 8rpx solid transparent; border-top-color: var(--brand); border-right-color: var(--brand);
animation: spin 0.9s linear infinite;
}
/* 成功:对勾 */
.icon--ok { background: var(--brand); box-shadow: 0 16rpx 40rpx var(--brand-soft); }
.icon--ok .glyph::after {
content: ''; position: absolute; left: 42rpx; top: 30rpx; width: 26rpx; height: 50rpx;
border-right: 10rpx solid #fff; border-bottom: 10rpx solid #fff; border-radius: 3rpx;
transform: rotate(45deg);
}
/* 等待:时钟 */
.icon--wait { background: var(--wait-soft); }
.icon--wait .glyph { margin: 28rpx; border: 7rpx solid var(--wait); border-radius: 50%; }
.icon--wait .glyph::before,
.icon--wait .glyph::after { content: ''; position: absolute; left: 50%; background: var(--wait); border-radius: 4rpx; }
.icon--wait .glyph::before { width: 7rpx; height: 22rpx; top: 12rpx; margin-left: -3.5rpx; }
.icon--wait .glyph::after { width: 18rpx; height: 7rpx; top: 30rpx; margin-left: -3.5rpx; }
/* 警告 / 终止:感叹号 */
.icon--warn { background: var(--warn-soft); }
.icon--stop { background: var(--stop-soft); }
.icon--warn .glyph::before, .icon--stop .glyph::before,
.icon--warn .glyph::after, .icon--stop .glyph::after { content: ''; position: absolute; left: 50%; margin-left: -5rpx; width: 10rpx; border-radius: 5rpx; }
.icon--warn .glyph::before, .icon--stop .glyph::before { top: 30rpx; height: 44rpx; }
.icon--warn .glyph::after, .icon--stop .glyph::after { top: 84rpx; height: 12rpx; }
.icon--warn .glyph::before, .icon--warn .glyph::after { background: var(--warn); }
.icon--stop .glyph::before, .icon--stop .glyph::after { background: var(--stop); }
/* ---------------------------------------------------------------- 订单卡片 */
.card {
background: var(--card); border-radius: 24rpx; padding: 48rpx 40rpx 16rpx;
box-shadow: 0 8rpx 32rpx rgba(20, 24, 32, 0.04);
}
.subject { text-align: center; font-size: 28rpx; color: var(--text-2); line-height: 1.5; word-break: break-all; }
.amount { display: flex; align-items: baseline; justify-content: center; margin: 12rpx 0 40rpx; font-weight: 600; font-variant-numeric: tabular-nums; }
.cur { font-size: 40rpx; margin-right: 6rpx; }
.int { font-size: 88rpx; line-height: 1.1; letter-spacing: -1rpx; }
.dec { font-size: 44rpx; }
.amount--paid { color: var(--brand); }
.rows { border-top: 1rpx solid var(--line); }
.row { display: flex; align-items: center; min-height: 96rpx; font-size: 26rpx; border-bottom: 1rpx solid var(--line); }
.row:last-child { border-bottom: none; }
.row--tap { margin: 0 -40rpx; padding: 0 40rpx; }
.row--hover { background: var(--bg); }
.k { flex: none; color: var(--text-3); }
.v { flex: 1; text-align: right; color: var(--text); margin-left: 24rpx; word-break: break-all; }
.v--urgent { color: var(--danger); font-weight: 600; }
.num, .mono { font-variant-numeric: tabular-nums; }
.mono { font-family: Menlo, Consolas, monospace; font-size: 24rpx; }
.copy { flex: none; margin-left: 16rpx; padding: 4rpx 16rpx; font-size: 22rpx; color: var(--brand); background: var(--brand-soft); border-radius: 999rpx; }
/* 骨架屏 */
.card--skeleton { padding-bottom: 48rpx; }
.bone {
height: 28rpx; margin: 0 auto 28rpx; border-radius: 8rpx;
background: linear-gradient(90deg, var(--bone) 25%, var(--bone-hi) 50%, var(--bone) 75%);
background-size: 400% 100%; animation: shimmer 1.4s ease infinite;
}
.bone--s { width: 40%; }
.bone--l { width: 56%; height: 80rpx; }
.bone--m { width: 100%; margin-bottom: 0; }
.note { margin-top: 32rpx; text-align: center; font-size: 22rpx; color: var(--text-3); }
.meta { margin-top: 12rpx; text-align: center; font-size: 22rpx; color: var(--text-3); }
/* ---------------------------------------------------------------- 底部操作栏 */
.bar {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 10;
padding: 4rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: var(--card); box-shadow: 0 -8rpx 24rpx rgba(20, 24, 32, 0.05);
}
.btn {
width: 100% !important; height: 96rpx; line-height: 96rpx; padding: 0; margin-left: 0; margin-right: 0;
border-radius: 48rpx; font-size: 32rpx; font-weight: 600;
margin-top: 20rpx;
}
.btn::after { border: none; }
.btn--primary { background: var(--brand); color: #fff; }
.btn--primary.btn--hover { background: var(--brand-press); }
.btn--ghost { background: transparent; color: var(--text); border: 2rpx solid var(--line); }
.btn--ghost.btn--hover { background: var(--bg); }
.btn[disabled] { opacity: 0.6; }
.btn--primary[disabled] { background: var(--brand) !important; color: #fff !important; }
.btn--ghost[disabled] { background: transparent !important; color: var(--text) !important; }
.hint { margin-top: 20rpx; text-align: center; font-size: 26rpx; line-height: 1.6; color: var(--text-3); }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pop { 0% { transform: scale(0.6); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
/* ---------------------------------------------------------------- 深色模式 */
@media (prefers-color-scheme: dark) {
page {
--bg: #111214;
--card: #1c1d21;
--text: #e9eaee;
--text-2: #a9adb7;
--text-3: #6f747f;
--line: #2a2c31;
--brand-soft: rgba(7, 193, 96, 0.18);
--wait: #6b95ff;
--wait-soft: rgba(107, 149, 255, 0.18);
--warn-soft: rgba(242, 154, 31, 0.18);
--stop-soft: rgba(139, 145, 156, 0.2);
--danger: #ff6b6f;
--bone: #24262b;
--bone-hi: #2e3036;
}
.card, .bar { box-shadow: none; }
}
pages/cashier/index.json
{
"navigationBarTitleText": "收银台",
"enablePullDownRefresh": true,
"backgroundTextStyle": "@bgTextStyle"
}
pages/cashier/core.js
// 收银台纯逻辑:不依赖 wx,便于在 Node 中测试(examples/xpay-demo/tests/cashier.test.js)。
// 虚拟支付按设备路由:Android / 鸿蒙 / Windows 走微信支付;iOS 走 Apple 支付(不支持沙箱,最低 1 元),本 demo 不支持。
const PAYABLE_PLATFORMS = ['android', 'ohos', 'windows'];
// 支付后轮询订单状态的间隔(毫秒),逐步放慢;用完仍未确认则进入 pending,由用户手动刷新。合计约 46 秒。
const POLL_DELAYS = [1000, 1500, 2000, 3000, 3000, 5000, 5000, 8000, 8000, 10000];
// 页面阶段
// init 首次进入,尚未开始
// loading wx.login + prepare 中
// paying 支付弹窗已调起
// checking 支付完成,轮询网关确认中
// paid 网关确认已支付
// pending 轮询用完仍未确认(可能已付款)
// cancelled 用户取消支付,订单仍可继续支付
// failed 可重试的失败(网络、登录、通道暂不可用、支付失败)
// expired 订单已过期
// closed 订单已关闭 / 渠道拒绝
// blocked 网关拒绝支付但订单仍未支付、未过期(被其他微信号占用或尝试次数过多)
// invalid 令牌缺失 / 链接失效
// unsupported 当前设备不支持
const ACTIVE = { init: true, loading: true, paying: true };
const CAN_PAY = { init: true, cancelled: true, failed: true };
const REFRESHABLE = { pending: true, cancelled: true, failed: true };
// 显示倒计时的阶段(支付已完成的阶段不显示,避免“已付款但显示快过期”的误导)
const COUNTDOWN = { loading: true, paying: true, cancelled: true, failed: true };
const PRESENT = {
init: { icon: 'spin', title: '正在准备支付', desc: '请稍候' },
loading: { icon: 'spin', title: '正在准备支付', desc: '请稍候' },
paying: { icon: 'spin', title: '等待支付', desc: '请在支付窗口中完成付款' },
checking: { icon: 'spin', title: '正在确认支付结果', desc: '通常几秒内完成,请不要关闭页面' },
paid: { icon: 'ok', title: '支付成功', desc: '返回 App 即可查看到账情况' },
pending: { icon: 'wait', title: '支付结果确认中', desc: '如已付款,结果会自动同步到 App;也可以点「刷新结果」', refresh: true },
cancelled: { icon: 'wait', title: '已取消支付', desc: '订单已保留,可以继续支付', pay: '继续支付' },
failed: { icon: 'warn', title: '支付未完成', desc: '请重新支付', pay: '重新支付' },
expired: { icon: 'stop', title: '订单已过期', desc: '请回到 App 重新下单' },
closed: { icon: 'stop', title: '订单已关闭', desc: '请回到 App 重新下单' },
blocked: { icon: 'stop', title: '暂时无法支付', desc: '该订单可能已在其他微信号上打开,或尝试次数过多,请回到 App 重新下单' },
invalid: { icon: 'stop', title: '支付链接无效', desc: '请回到 App 重新发起支付' },
unsupported: { icon: 'stop', title: '当前设备暂不支持', desc: '请在 Android 手机上打开' },
};
function present(phase, extra) {
const base = PRESENT[phase] || PRESENT.failed;
const e = extra || {};
const payText = base.pay || '';
const canRefresh = !!base.refresh;
return {
icon: base.icon,
title: e.title || base.title,
desc: e.desc !== undefined ? e.desc : base.desc,
detail: e.detail || '',
payText,
canRefresh,
canBack: !ACTIVE[phase],
backPrimary: !payText && !canRefresh,
};
}
// launchApp 回传给 App 的参数(App 仍须以自己后台收到的 order.paid 为准)
function appParameter(phase) {
if (phase === 'paid') return 'paid';
if (phase === 'cancelled') return 'cancel';
if (phase === 'checking' || phase === 'pending') return 'pending';
return 'failed';
}
function isToken(t) {
return typeof t === 'string' && /^[0-9a-f]{64}$/.test(t);
}
function isPayablePlatform(p) {
return PAYABLE_PLATFORMS.indexOf(p) >= 0;
}
// 分 → { int: '1,234', dec: '56' }
function splitAmount(cents) {
if (typeof cents !== 'number' || !Number.isFinite(cents) || cents < 0 || Math.floor(cents) !== cents) {
return { int: '--', dec: '--' };
}
const int = String(Math.floor(cents / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const dec = String(cents % 100).padStart(2, '0');
return { int, dec };
}
function parseTime(iso) {
if (!iso) return null;
const t = Date.parse(iso);
return Number.isFinite(t) ? t : null;
}
// 剩余毫秒 → 'mm:ss' 或 'h:mm:ss'(向上取整到秒,避免显示 00:00 时还没过期)
function formatRemain(ms) {
const s = Math.max(0, Math.ceil(ms / 1000));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? h + ':' + pad(m) + ':' + pad(sec) : pad(m) + ':' + pad(sec);
}
// 订单状态 → 终态阶段;仍可支付返回 null。
// paidAttempt=true 时(用户已在支付窗口付款)不因过期判定失败:过期前付款的订单仍会被确认。
function outcome(order, nowMs, paidAttempt) {
if (!order) return null;
if (order.status === 'succeeded') return 'paid';
if (order.status === 'closed') return 'closed';
if (order.status === 'failed') return 'closed';
if (order.status === 'created' && !paidAttempt) {
const exp = parseTime(order.expire_at);
if (exp !== null && exp <= nowMs) return 'expired';
}
return null;
}
// prepare 失败(409 除外,409 由调用方查状态后决定)→ 阶段 + 文案
function prepareError(e) {
const err = e || {};
if (err.login) return { phase: 'failed', title: '微信登录失败', desc: '请检查网络后重新支付' };
if (err.network) return { phase: 'failed', title: '网络不太稳定', desc: '请检查网络后重新支付' };
if (err.status === 404) return { phase: 'invalid' };
if (err.status === 422) return { phase: 'failed', title: '登录已过期', desc: '请点击重新支付' };
if (err.code === 50300) return { phase: 'failed', title: '支付暂不可用', desc: '支付通道维护中,请稍后再试' };
return { phase: 'failed', title: '支付服务繁忙', desc: '请稍后重新支付', detail: errorDetail(err) };
}
// requestVirtualPayment 失败 → 取消 / 失败
function payError(e) {
const err = e || {};
const msg = String(err.errMsg || '');
if (err.errCode === -2 || /cancel/i.test(msg)) return { cancelled: true };
return { cancelled: false, title: '支付未完成', desc: '请重新支付;如已扣款,结果会自动同步', detail: errorDetail(err) };
}
function errorDetail(err) {
if (err.errCode !== undefined && err.errCode !== null) return '错误码 ' + err.errCode;
if (err.code) return '错误码 ' + err.code;
if (err.status) return 'HTTP ' + err.status;
return '';
}
module.exports = {
POLL_DELAYS,
ACTIVE,
CAN_PAY,
REFRESHABLE,
COUNTDOWN,
present,
appParameter,
isToken,
isPayablePlatform,
splitAmount,
parseTime,
formatRemain,
outcome,
prepareError,
payError,
};