1 changed files with 490 additions and 0 deletions
@ -0,0 +1,490 @@ |
|||
# 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 为卡片结构** |
|||
|
|||
把: |
|||
|
|||
```vue |
|||
<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> |
|||
``` |
|||
|
|||
换成: |
|||
|
|||
```vue |
|||
<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):订单概要显示选中套餐价: |
|||
|
|||
```vue |
|||
<b v-if="selectedTrafficPackage">{{ money(selectedTrafficPackage.price) }}</b> |
|||
``` |
|||
|
|||
并加 computed: |
|||
|
|||
```js |
|||
const selectedTrafficPackage = computed(() => |
|||
trafficPackages.value.find((pkg) => String(pkg.id) === String(selectedTrafficPackageId.value)) || null |
|||
) |
|||
``` |
|||
|
|||
- [ ] **Step 2: 改 SIM 充值弹窗套餐为同一卡片结构** |
|||
|
|||
```vue |
|||
<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>` 追加: |
|||
|
|||
```css |
|||
.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` 旁)补: |
|||
|
|||
```css |
|||
.recharge-modal .package-card-grid { |
|||
margin-top: 4px; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: 本地目视验收套餐卡片** |
|||
|
|||
Run: 打开 `http://127.0.0.1:5173/account` → 云媒体流量 |
|||
Expected: 套餐为卡片网格,选中蓝边;不再是单行默认 radio。 |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
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 附近追加: |
|||
|
|||
```js |
|||
const payDialogOpen = ref(false) |
|||
const payDialogRefreshing = ref(false) |
|||
const payDialog = ref({ |
|||
title: '微信支付', |
|||
businessType: '', |
|||
businessOrderId: 0, |
|||
transactionId: 0, |
|||
amountText: '--', |
|||
status: '', |
|||
statusText: '', |
|||
qrSrc: '', |
|||
error: '' |
|||
}) |
|||
``` |
|||
|
|||
追加函数(替换/扩展现有 `describePayment`): |
|||
|
|||
```js |
|||
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` 成功分支改为: |
|||
|
|||
```js |
|||
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`: |
|||
|
|||
```js |
|||
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` 后: |
|||
|
|||
```vue |
|||
<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): |
|||
|
|||
```css |
|||
.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** |
|||
|
|||
```bash |
|||
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: 构建部署(用户若要求上线)** |
|||
|
|||
```bash |
|||
npm run build |
|||
# 按既有流程打 tar 同步到 jg-serv1:/usr/share/nginx/laic-frontend/dist 并 nginx reload |
|||
``` |
|||
|
|||
- [ ] **Step 4: 最终 commit 推送(若有未提交文档/计划)** |
|||
|
|||
```bash |
|||
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 遵守 | |
|||
Loading…
Reference in new issue