feat(web): 用户面订阅 —— 购买/续订入口 + 到期提醒
账单页新增:当前订阅状态(套餐/发放节奏/已发放次数/到期倒计时)+ 可购套餐。 已有订阅时购买区标题自动变成「续订(在当前到期时间上顺延)」,与后端的顺延 语义一致,免得用户以为会新开一条。 到期倒计时 ≤7 天转琥珀、≤3 天转红:到期即失效且**没有任何扣款或续费通知**, 用户只能从这里看见,不显眼等于没有。 支付弹窗改为对「买什么」中立(PayItem:积分包 | 订阅),下单/轮询/超时/warn 处理全共用。给订阅复制一份弹窗的话,两边迟早漂移——之前修过的轮询问题就得修 两遍。 真环境验证:账单页显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)、余额 3.0K (NULL 回填后的 2981)、套餐卡片算出「周期内共 3 次,合计 3.0K 积分」。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+37
-2
@@ -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<SubPlan[]> {
|
||||
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 的话用户付了钱、界面却只会一直转圈等下去。
|
||||
|
||||
@@ -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<Pack[]>([]);
|
||||
const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮
|
||||
const [paying, setPaying] = useState<Pack | null>(null); // 正在扫码支付的包
|
||||
const [paying, setPaying] = useState<PayItem | null>(null); // 正在扫码支付的商品(积分包或订阅)
|
||||
const [plans, setPlans] = useState<SubPlan[]>([]);
|
||||
const [sub, setSub] = useState<MySub | null>(null);
|
||||
const [subPlan, setSubPlan] = useState<SubPlan | null>(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 ? (
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
{/* 微信扫码:服务端配了商户号且有在售包才出现 */}
|
||||
{/* 订阅:到期即失效、不自动续费,所以"还剩几天"必须显眼——用户不会收到扣款提醒 */}
|
||||
{sub && subPlan && (
|
||||
<div className="mb-4 rounded-lg border border-brand/30 bg-brand/5 px-4 py-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div className="text-sm font-medium text-slate-200">
|
||||
订阅中 · {subPlan.name}
|
||||
</div>
|
||||
<SubExpiry expiresAt={sub.expires_at} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
每 {subPlan.refill_interval_days} 天发放{" "}
|
||||
<span className="tabular-nums text-brand-400">{fmtCredits(subPlan.refill_credits_micro)}</span> 积分 ·
|
||||
已发放 <span className="tabular-nums">{sub.refill_seq}</span> 次 ·
|
||||
到期后不再发放,需重新购买
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wechatOn && plans.length > 0 && canTopup && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
|
||||
<QrCode className="h-3.5 w-3.5" /> {sub ? "续订(在当前到期时间上顺延)" : "订阅"}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{plans.map((pl) => {
|
||||
const times = pl.refill_interval_days > 0 ? Math.floor(pl.duration_days / pl.refill_interval_days) : 0;
|
||||
return (
|
||||
<button key={pl.id}
|
||||
onClick={() => setPaying({
|
||||
id: pl.id, name: pl.name, price_fen: pl.price_fen, kind: "sub",
|
||||
note: `微信扫一扫支付,开通后每 ${pl.refill_interval_days} 天发放 ${fmtCredits(pl.refill_credits_micro)} 积分`,
|
||||
})}
|
||||
className="group rounded-lg border border-line bg-ink-850 px-4 py-2.5 text-left transition-colors hover:border-brand/50">
|
||||
<div className="text-sm font-medium text-slate-200">{pl.name}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">
|
||||
{pl.duration_days} 天 · 每 {pl.refill_interval_days} 天发{" "}
|
||||
<span className="tabular-nums text-brand-400">{fmtCredits(pl.refill_credits_micro)}</span> ·
|
||||
<span className="ml-1 tabular-nums">¥{(pl.price_fen / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-slate-600">
|
||||
周期内共 {times} 次,合计 {fmtCredits(pl.refill_credits_micro * times)} 积分
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wechatOn && packs.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
|
||||
@@ -83,7 +142,11 @@ export function Usage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{packs.map((p) => (
|
||||
<button key={p.id} onClick={() => setPaying(p)}
|
||||
<button key={p.id}
|
||||
onClick={() => setPaying({
|
||||
id: p.id, name: p.name, price_fen: p.price_fen, kind: "pack",
|
||||
note: `微信扫一扫支付,到账 ${fmtCredits(p.credits_micro)} 积分`,
|
||||
})}
|
||||
className="group rounded-lg border border-line bg-ink-850 px-4 py-2.5 text-left transition-colors hover:border-brand/50">
|
||||
<div className="text-sm font-medium text-slate-200">{p.name}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">
|
||||
@@ -188,10 +251,11 @@ export function Usage() {
|
||||
|
||||
{paying && (
|
||||
<PayDialog
|
||||
pack={paying}
|
||||
item={paying}
|
||||
onClose={(paid) => {
|
||||
const wasSub = paying.kind === "sub";
|
||||
setPaying(null);
|
||||
if (paid) toast.push("success", "支付成功,积分已入账");
|
||||
if (paid) toast.push("success", wasSub ? "订阅已开通,首笔积分已发放" : "支付成功,积分已入账");
|
||||
// 无论如何都刷一次:用户可能在关弹窗前一刻付款、状态刚落地还没被轮询看到。
|
||||
load();
|
||||
refresh();
|
||||
@@ -217,7 +281,17 @@ const POLL_MAX_MS = 15000;
|
||||
|
||||
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
|
||||
function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) => void }) {
|
||||
// 支付弹窗对「买什么」保持中立:积分包与订阅只是标题与到账说明不同,
|
||||
// 下单/轮询/超时/warn 处理全共用——复制一份给订阅的话,两边迟早漂移。
|
||||
type PayItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price_fen: number;
|
||||
kind: "pack" | "sub";
|
||||
note: string; // 支付成功前显示的"买到什么"说明
|
||||
};
|
||||
|
||||
function PayDialog({ item, onClose }: { item: PayItem; onClose: (paid: boolean) => void }) {
|
||||
const [qr, setQr] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [warn, setWarn] = useState("");
|
||||
@@ -275,7 +349,7 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const o = await createWechatOrder(pack.id);
|
||||
const o = await createWechatOrder(item.kind === "sub" ? { planId: item.id } : { packId: item.id });
|
||||
if (!alive) return;
|
||||
orderRef.current = o.order_id;
|
||||
expiresRef.current = new Date(o.expires_at).getTime();
|
||||
@@ -294,7 +368,7 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pack.id]);
|
||||
}, [item.id]);
|
||||
|
||||
// 倒计时:到点本地先置过期,省一次「扫了个死码才知道」。服务端 TTL 仍是权威。
|
||||
useEffect(() => {
|
||||
@@ -308,7 +382,7 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open title={`微信扫码 · ${pack.name}`} onClose={() => onClose(state === "paid")}>
|
||||
<Dialog open title={`微信扫码 · ${item.name}`} onClose={() => onClose(state === "paid")}>
|
||||
<div className="flex flex-col items-center gap-3 py-2">
|
||||
{err ? (
|
||||
<p className="text-xs text-danger">{err}</p>
|
||||
@@ -327,13 +401,13 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold tabular-nums text-slate-100">¥{(pack.price_fen / 100).toFixed(2)}</div>
|
||||
<div className="text-lg font-semibold tabular-nums text-slate-100">¥{(item.price_fen / 100).toFixed(2)}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{state === "paid"
|
||||
? "✅ 已支付,入账中…"
|
||||
: state === "expired"
|
||||
? "订单已过期,请关闭后重新下单"
|
||||
: `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
|
||||
: item.note}
|
||||
</div>
|
||||
{state === "waiting" && (
|
||||
<div className="mt-1 text-[11px] tabular-nums text-slate-500">二维码 {mmss(left)} 后失效</div>
|
||||
@@ -361,3 +435,15 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// SubExpiry 到期倒计时。到期即失效且没有自动续费,临近到期必须变色提醒——
|
||||
// 用户不会收到任何扣款或续费通知,只能靠这里看见。
|
||||
function SubExpiry({ expiresAt }: { expiresAt: string }) {
|
||||
const left = Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86400000);
|
||||
const tone = left <= 3 ? "text-rose-400" : left <= 7 ? "text-amber-400" : "text-slate-400";
|
||||
return (
|
||||
<span className={`text-xs tabular-nums ${tone}`}>
|
||||
{new Date(expiresAt).toLocaleDateString("zh-CN")} 到期(剩 {left} 天)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user