diff --git a/sundynix-web/src/api.ts b/sundynix-web/src/api.ts index 7b50adb..32d6039 100644 --- a/sundynix-web/src/api.ts +++ b/sundynix-web/src/api.ts @@ -274,18 +274,53 @@ export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[ // createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付) // + expires_at(二维码有效期,由服务端 orderTTL 决定,前端只负责倒计时展示)。 export async function createWechatOrder( - packId: string, + target: { packId: string } | { planId: string }, ): Promise<{ order_id: string; code_url: string; amount_fen: number; expires_at: string }> { + // 积分包与订阅走同一条支付链路(下单/回调/查单/掉单补偿全复用),只是订单内容不同。 + const body = "packId" in target ? { pack_id: target.packId } : { plan_id: target.planId }; const res = guard401( await fetch(`${GATEWAY}/api/v1/billing/orders`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, - body: JSON.stringify({ pack_id: packId }), + body: JSON.stringify(body), }), ); return jsonOrThrow(res, "下单失败"); } +// ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)---- +export interface SubPlan { + id: string; + name: string; + price_fen: number; + duration_days: number; + refill_credits_micro: number; + refill_interval_days: number; +} + +export interface MySub { + id: string; + plan_id: string; + status: string; + started_at: string; + expires_at: string; + refill_seq: number; +} + +export async function subPlans(): Promise { + const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/sub-plans`, { headers: bearer() })); + if (!res.ok) return []; + return ((await res.json()) as { plans?: SubPlan[] }).plans ?? []; +} + +// 我的订阅(无则 null)。到期即失效、不自动续费,所以到期时间要显眼地告诉用户。 +export async function mySubscription(): Promise<{ subscription: MySub | null; plan: SubPlan | null }> { + const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/subscription`, { headers: bearer() })); + if (!res.ok) return { subscription: null, plan: null }; + const d = (await res.json()) as { subscription?: MySub | null; plan?: SubPlan | null }; + return { subscription: d.subscription ?? null, plan: d.plan ?? null }; +} + // orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。 // warn 必须一并返回:服务端在「已付但金额与订单不符」时不入账、挂起人工核对, // 订单会一直停在 pending —— 丢掉 warn 的话用户付了钱、界面却只会一直转圈等下去。 diff --git a/sundynix-web/src/pages/Usage.tsx b/sundynix-web/src/pages/Usage.tsx index a3db9d8..61ac463 100644 --- a/sundynix-web/src/pages/Usage.tsx +++ b/sundynix-web/src/pages/Usage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import QRCode from "qrcode"; import { Coins, ReceiptText, Ticket, QrCode } from "lucide-react"; import { useTenant } from "../shell/AppShell"; -import { myUsage, redeemCode, billingOrders, billingPacks, createWechatOrder, orderStatus, fmtCredits, type MyUsage, type TopupOrder, type Pack } from "../api"; +import { myUsage, redeemCode, billingOrders, billingPacks, createWechatOrder, orderStatus, fmtCredits, subPlans, mySubscription, type MyUsage, type TopupOrder, type Pack, type SubPlan, type MySub } from "../api"; import { Badge, Button, Dialog, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui"; // 用量与账单:余额 + 兑换码充值(P5.1) + 消耗趋势 + 最近消耗/充值。 @@ -17,7 +17,10 @@ export function Usage() { const [busy, setBusy] = useState(false); const [packs, setPacks] = useState([]); const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮 - const [paying, setPaying] = useState(null); // 正在扫码支付的包 + const [paying, setPaying] = useState(null); // 正在扫码支付的商品(积分包或订阅) + const [plans, setPlans] = useState([]); + const [sub, setSub] = useState(null); + const [subPlan, setSubPlan] = useState(null); const load = useCallback(() => { myUsage(days).then(setU).catch(() => {}); @@ -28,6 +31,13 @@ export function Usage() { setWechatOn(r.channels.includes("wechat")); }) .catch(() => {}); + subPlans().then(setPlans).catch(() => {}); + mySubscription() + .then((r) => { + setSub(r.subscription); + setSubPlan(r.plan); + }) + .catch(() => {}); }, [days]); useEffect(load, [load, ctx?.tenant?.id]); @@ -76,6 +86,55 @@ export function Usage() { {canTopup ? (
{/* 微信扫码:服务端配了商户号且有在售包才出现 */} + {/* 订阅:到期即失效、不自动续费,所以"还剩几天"必须显眼——用户不会收到扣款提醒 */} + {sub && subPlan && ( +
+
+
+ 订阅中 · {subPlan.name} +
+ +
+
+ 每 {subPlan.refill_interval_days} 天发放{" "} + {fmtCredits(subPlan.refill_credits_micro)} 积分 · + 已发放 {sub.refill_seq} 次 · + 到期后不再发放,需重新购买 +
+
+ )} + + {wechatOn && plans.length > 0 && canTopup && ( +
+
+ {sub ? "续订(在当前到期时间上顺延)" : "订阅"} +
+
+ {plans.map((pl) => { + const times = pl.refill_interval_days > 0 ? Math.floor(pl.duration_days / pl.refill_interval_days) : 0; + return ( + + ); + })} +
+
+ )} + {wechatOn && packs.length > 0 && (
@@ -83,7 +142,11 @@ export function Usage() {
{packs.map((p) => ( -