You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

15 KiB

Account Package Cards + QR Pay Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 把账户中心流量/SIM 套餐选择改成卡片网格,并在支付成功后弹出可展示 AgPay 二维码、可手动刷新状态的独立支付弹窗。

Architecture: 全部落在 src/views/AccountView/AccountView.vue。保留 t-radio-group/t-radio 的选择语义,用 class + :deep 做成卡片网格;支付结果统一走 openPaymentDialog(payment, meta),把 providerPayload 解析成 <img> src,共用一个 t-dialog。刷新走已有 GET /v1/account/payments/:id

Tech Stack: Vue 3 + TDesign Vue Next (t-radio-group/t-radio/t-dialog/t-button) + axios request + urls.PAYMENTS/urls.PAYMENT

Spec: docs/superpowers/specs/2026-09-01-account-package-cards-qr-pay-design.md


File map

File Responsibility
Modify: src/views/AccountView/AccountView.vue 套餐卡片 DOM/样式;支付弹窗;payload 解析;购买/去支付/SIM 充值接线
No new files 保持与现有 AccountView 模式一致,不拆组件(单文件已承载账户域)

Task 1: 套餐卡片网格(流量 + SIM)

Files:

  • Modify: src/views/AccountView/AccountView.vue(流量 radio 块约 L55–70;SIM radio 块约 L270–278;scoped style 尾部)

  • Step 1: 改流量套餐 markup 为卡片结构

把:

<t-radio-group
  v-model="selectedTrafficPackageId"
  :disabled="!trafficPackages.length || trafficOrdering"
>
  <t-radio
    v-for="pkg in trafficPackages"
    :key="pkg.id"
    :value="pkg.id"
  >
    {{ pkg.name }} · {{ (Number(pkg.amountBytes) / 1024 / 1024 / 1024).toFixed(0) }}GB · {{ money(pkg.price) }}
    <small v-if="pkg.validityDays"> / {{ pkg.validityDays }}</small>
  </t-radio>
</t-radio-group>

换成:

<t-radio-group
  class="package-card-grid"
  v-model="selectedTrafficPackageId"
  :disabled="!trafficPackages.length || trafficOrdering"
>
  <t-radio
    v-for="pkg in trafficPackages"
    :key="pkg.id"
    class="package-card"
    :value="pkg.id"
  >
    <span class="package-card-body">
      <strong>{{ pkg.name }}</strong>
      <b>{{ (Number(pkg.amountBytes) / 1024 / 1024 / 1024).toFixed(0) }} GB</b>
      <em>{{ money(pkg.price) }}</em>
      <small v-if="pkg.validityDays">有效期 {{ pkg.validityDays }} </small>
      <small v-else>长期有效</small>
    </span>
  </t-radio>
</t-radio-group>

可选增强(同一 Step):订单概要显示选中套餐价:

<b v-if="selectedTrafficPackage">{{ money(selectedTrafficPackage.price) }}</b>

并加 computed:

const selectedTrafficPackage = computed(() =>
  trafficPackages.value.find((pkg) => String(pkg.id) === String(selectedTrafficPackageId.value)) || null
)
  • Step 2: 改 SIM 充值弹窗套餐为同一卡片结构
<t-radio-group
  class="package-card-grid"
  v-model="selectedSimPackageId"
  :disabled="!simPackages.length || simRecharging"
>
  <t-radio
    v-for="pkg in simPackages"
    :key="pkg.id"
    class="package-card"
    :value="pkg.id"
  >
    <span class="package-card-body">
      <strong>{{ pkg.name }}</strong>
      <b>{{ pkg.amountGb }} GB</b>
      <em>{{ moneyFromFen(pkg.saleFeeFen) }}</em>
      <small v-if="pkg.validityMonths">有效期 {{ pkg.validityMonths }} 个月</small>
      <small v-else>按运营商规则</small>
    </span>
  </t-radio>
</t-radio-group>
  • Step 3: 加卡片网格样式(scoped + recharge-modal 全局补丁)

<style scoped> 追加:

.package-card-grid {
  display: grid !important;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 8px;
  width: 100%;
}
.package-card-grid :deep(.t-radio) {
  margin: 0;
  width: 100%;
  border: 1px solid #cfdae2;
  border-radius: 5px;
  background: #fff;
  transition: border-color .15s ease, background .15s ease, box-shadow .15s ease;
}
.package-card-grid :deep(.t-radio__former) {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.package-card-grid :deep(.t-radio__input) {
  display: none;
}
.package-card-grid :deep(.t-radio__label) {
  width: 100%;
  padding: 0;
  margin: 0;
}
.package-card-body {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 12px 12px 10px;
  min-height: 96px;
}
.package-card-body strong {
  color: #304552;
  font-size: 12px;
  font-weight: 600;
}
.package-card-body b {
  color: #246fae;
  font-size: 20px;
  font-weight: 600;
  line-height: 1.1;
}
.package-card-body em {
  font-style: normal;
  color: #3b4d58;
  font-size: 13px;
  font-weight: 600;
}
.package-card-body small {
  color: #74838d;
  font-size: 10px;
}
.package-card-grid :deep(.t-is-checked) {
  border-color: #3b8fd4;
  background: #f3f8fc;
  box-shadow: inset 0 0 0 1px #3b8fd4;
}
.package-card-grid :deep(.t-radio:hover) {
  border-color: #8fb8da;
}
.package-card-grid :deep(.t-is-disabled) {
  opacity: .6;
}

在底部全局 <style>.recharge-modal 旁)补:

.recharge-modal .package-card-grid {
  margin-top: 4px;
}
  • Step 4: 本地目视验收套餐卡片

Run: 打开 http://127.0.0.1:5173/account → 云媒体流量
Expected: 套餐为卡片网格,选中蓝边;不再是单行默认 radio。

  • Step 5: Commit
git add src/views/AccountView/AccountView.vue
git commit -m "feat(account): render traffic and SIM packages as card grid"

Task 2: 支付二维码弹窗 + payload 解析

Files:

  • Modify: src/views/AccountView/AccountView.vue(script helpers;template 新增 dialog;支付调用点)

  • Step 1: 增加支付弹窗状态与解析 helper

在现有 refs 附近追加:

const payDialogOpen = ref(false)
const payDialogRefreshing = ref(false)
const payDialog = ref({
  title: '微信支付',
  businessType: '',
  businessOrderId: 0,
  transactionId: 0,
  amountText: '--',
  status: '',
  statusText: '',
  qrSrc: '',
  error: ''
})

追加函数(替换/扩展现有 describePayment):

function paymentStatusName(status) {
  return ({
    unpaid: '待支付',
    processing: '支付中',
    paid: '已支付',
    success: '已支付',
    failed: '支付失败',
    cancelled: '已取消',
    closed: '已关闭'
  })[status] || status || '--'
}

function looksLikeBase64(value) {
  const text = String(value || '').replace(/\s+/g, '')
  return text.length > 32 && /^[A-Za-z0-9+/=]+$/.test(text)
}

function resolvePaymentQrSrc(payload) {
  if (payload == null) return ''
  if (typeof payload !== 'string') {
    if (typeof payload === 'object') {
      const nested = payload.qrCode || payload.qrUrl || payload.image || payload.codeUrl || ''
      return resolvePaymentQrSrc(nested)
    }
    return ''
  }
  const text = payload.trim()
  if (!text) return ''
  if (text.startsWith('data:image/')) return text
  if (/^https?:\/\//i.test(text) || text.startsWith('/')) return text
  if (text.startsWith('weixin://')) return ''
  if (looksLikeBase64(text)) return `data:image/png;base64,${text.replace(/\s+/g, '')}`
  // JSON 字符串里混入了 PNG 二进制时,尝试从 latin1 字节转 base64
  try {
    const bytes = Array.from(text, (ch) => ch.charCodeAt(0) & 0xff)
    let binary = ''
    for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i])
    if (bytes[0] === 0x89 && bytes[1] === 0x50) {
      return `data:image/png;base64,${btoa(binary)}`
    }
  } catch (_) {}
  return ''
}

function openPaymentDialog(payment, meta = {}) {
  const qrSrc = resolvePaymentQrSrc(payment?.providerPayload)
  payDialog.value = {
    title: meta.title || '微信支付',
    businessType: meta.businessType || payment?.businessType || '',
    businessOrderId: meta.businessOrderId || payment?.businessOrderId || 0,
    transactionId: payment?.transactionId || 0,
    amountText: moneyFromFen(payment?.totalFeeFen),
    status: payment?.status || '',
    statusText: paymentStatusName(payment?.status),
    qrSrc,
    error: qrSrc ? '' : '支付已创建,但二维码无法展示;可关闭后点击「去支付」重试'
  }
  payDialogOpen.value = true
  return payment
}

async function refreshPaymentStatus() {
  const id = payDialog.value.transactionId
  if (!id) {
    ui.toast('缺少支付单号')
    return
  }
  payDialogRefreshing.value = true
  try {
    const payment = await request.get(urls.PAYMENT(id))
    const qrSrc = resolvePaymentQrSrc(payment?.providerPayload) || payDialog.value.qrSrc
    payDialog.value = {
      ...payDialog.value,
      status: payment?.status || payDialog.value.status,
      statusText: paymentStatusName(payment?.status || payDialog.value.status),
      amountText: moneyFromFen(payment?.totalFeeFen) || payDialog.value.amountText,
      qrSrc,
      error: qrSrc ? '' : payDialog.value.error
    }
    const paid = payment?.status === 'paid' || payment?.status === 'success'
    await Promise.all([
      loadOrders(),
      request.get(urls.TRAFFIC_BALANCE).then((data) => {
        balance.value = data?.balanceGb == null ? '--' : Number(data.balanceGb).toFixed(1)
      }).catch(() => {}),
      loadSim().catch(() => {})
    ])
    if (paid) {
      ui.toast('支付成功,流量/订单已刷新')
      payDialogOpen.value = false
    } else {
      ui.toast(`当前状态:${paymentStatusName(payment?.status)},如已扫码请稍后再试`)
    }
  } catch (error) {
    ui.toast(error.message || '刷新支付状态失败')
  } finally {
    payDialogRefreshing.value = false
  }
}
  • Step 2: 接线购买/去支付/SIM 支付到弹窗

createTrafficOrderAndPay 成功分支改为:

const payment = await createPayment('traffic_order', order.id)
openPaymentDialog(payment, {
  title: '云媒体流量支付',
  businessType: 'traffic_order',
  businessOrderId: order.id
})
await Promise.all([loadOrders(), request.get(urls.TRAFFIC_BALANCE).then((data) => {
  balance.value = data?.balanceGb == null ? '--' : Number(data.balanceGb).toFixed(1)
})])

payTrafficOrder

const payment = await createPayment('traffic_order', row.id)
openPaymentDialog(payment, {
  title: '云媒体流量支付',
  businessType: 'traffic_order',
  businessOrderId: row.id
})
await loadOrders()

SIM:payPendingSimOrdersubmitSimRechargecreatePayment('sim_recharge_order', ...) 成功后同样 openPaymentDialog(...);可把 simRechargeOpen = false 放在打开支付弹窗之后。

删除或降级仅 toast 的 describePayment 成功路径(失败仍 toast)。

  • Step 3: 模板增加支付弹窗

放在 SIM 充值 t-dialog 后:

<t-dialog
  v-model:visible="payDialogOpen"
  :header="payDialog.title"
  width="420px"
  destroy-on-close
  dialog-class-name="recharge-modal pay-qr-modal"
  placement="center"
>
  <div class="pay-qr-panel">
    <div class="pay-qr-amount">
      <span>应付金额</span>
      <strong>{{ payDialog.amountText }}</strong>
    </div>
    <dl class="pay-qr-meta">
      <div><dt>业务</dt><dd>{{ payDialog.businessType === 'sim_recharge_order' ? '通信卡充值' : '云媒体流量' }}</dd></div>
      <div><dt>订单号</dt><dd>{{ payDialog.businessOrderId || '--' }}</dd></div>
      <div><dt>支付单</dt><dd>{{ payDialog.transactionId || '--' }}</dd></div>
      <div><dt>状态</dt><dd>{{ payDialog.statusText || '--' }}</dd></div>
    </dl>
    <div class="pay-qr-code" v-if="payDialog.qrSrc">
      <img :src="payDialog.qrSrc" alt="支付二维码" />
      <small>请使用微信扫码支付</small>
    </div>
    <p class="pay-qr-error" v-else>{{ payDialog.error || '暂无二维码' }}</p>
  </div>
  <template #footer>
    <t-button variant="outline" @click="payDialogOpen = false">关闭</t-button>
    <t-button
      theme="primary"
      :loading="payDialogRefreshing"
      :disabled="!payDialog.transactionId"
      @click="refreshPaymentStatus"
    >我已支付刷新状态</t-button>
  </template>
</t-dialog>

样式(scoped):

.pay-qr-panel { display: flex; flex-direction: column; gap: 14px; }
.pay-qr-amount span { display: block; color: var(--muted); font-size: 10px; }
.pay-qr-amount strong { display: block; margin-top: 4px; color: #246fae; font-size: 28px; font-weight: 600; }
.pay-qr-meta { margin: 0; display: grid; gap: 6px; }
.pay-qr-meta > div { display: flex; gap: 10px; font-size: 12px; }
.pay-qr-meta dt { width: 48px; color: #74838d; }
.pay-qr-meta dd { margin: 0; color: #304552; word-break: break-all; }
.pay-qr-code { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 12px; border: 1px solid #d7e4ee; border-radius: 5px; background: #f5f9fc; }
.pay-qr-code img { width: 220px; height: 220px; object-fit: contain; background: #fff; }
.pay-qr-code small { color: #74838d; font-size: 11px; }
.pay-qr-error { margin: 0; padding: 12px; border-radius: 5px; background: #fff5f5; color: #bd4046; font-size: 12px; }
  • Step 4: 浏览器验收支付弹窗

前提:若上次取消单留下空 payment_idempotency_key,先清 NULL 或取消残留 unpaid。
Run: 账户 → 选套餐 → 购买并支付
Expected:

  1. 弹出支付弹窗
  2. 二维码图可见(或明确错误文案)
  3. 订单列表出现支付中/待支付
  4. 点「我已支付,刷新状态」会请求 /api/v1/account/payments/:id
  • Step 5: Commit
git add src/views/AccountView/AccountView.vue
git commit -m "feat(account): show WeChat QR payment dialog with status refresh"

Task 3: 流量购买端到端复测 + 部署(如需)

Files: none(验证/运维)

  • Step 1: API 预检

确认:

  • GET /v1/account/traffic-packages 有套餐

  • 无卡住的 unpaid(必要时 DELETE 取消 + 空 idem key → NULL)

  • Step 2: UI 复测清单

  1. 套餐卡片样式(网格/选中高亮)
  2. 购买并支付 → 弹窗出码
  3. 去支付 → 弹窗出码
  4. 刷新状态接口 200
  5. 取消订单可用
  • Step 3: 构建部署(用户若要求上线)
npm run build
# 按既有流程打 tar 同步到 jg-serv1:/usr/share/nginx/laic-frontend/dist 并 nginx reload
  • Step 4: 最终 commit 推送(若有未提交文档/计划)
git add docs/superpowers/plans/2026-09-01-account-package-cards-qr-pay.md
git commit -m "docs: add account package cards and QR pay plan"

Spec coverage check

Spec 项 Task
流量套餐卡片网格 Task 1
SIM 套餐卡片网格 Task 1
独立支付弹窗 + QR Task 2
刷新支付状态 Task 2
providerPayload 多形态解析 Task 2
流量购买验收 Task 3
不引入微信 SDK / 不自动轮询 Task 2 遵守