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:payPendingSimOrder 与 submitSimRecharge 中 createPayment('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:
- 弹出支付弹窗
- 二维码图可见(或明确错误文案)
- 订单列表出现支付中/待支付
- 点「我已支付,刷新状态」会请求
/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 复测清单
- 套餐卡片样式(网格/选中高亮)
- 购买并支付 → 弹窗出码
- 去支付 → 弹窗出码
- 刷新状态接口 200
- 取消订单可用
- 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 遵守 |