更新于 2026-11-10 22:49 · 源文件 docs/aggpay-cashier-miniprogram/README.md

收银台小程序示例(道具直购)

可直接使用的收银台页面,配合网关产品 wechat_xpay_goods 使用。接口说明见 商户接入文档 第 7 节。

放进小程序

  1. 复制整个 pages/cashier/ 目录(index.js、index.wxml、index.wxss、index.json、core.js)到小程序项目,并在 app.json 的 pages 中加入 "pages/cashier/index"。

  2. 把 index.js 中的 API 改为网关的公网 https 地址,并加入小程序后台的 request 合法域名。

  3. 小程序后台 → 开发管理 → 消息推送:URL 填 https://<网关域名>/api/aggpay/notify/wechat_xpay/<渠道账号ID>,Token / EncodingAESKey 与渠道账号凭证一致,数据格式选 JSON,建议选安全模式。保存时微信会发 GET 校验,网关会自动应答。

  4. 可选:深色模式。页面样式已适配,需要在 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,
};