feat(desktop): 用量页显示订阅状态与到期倒计时(购买仍归 Web 面)

桌面端只读展示:套餐、发放节奏、已发放次数、到期倒计时(≤7 天转橙、≤3 天转红),
「去续订」跳系统浏览器到 Web 面账单页。

刻意**不在桌面端做购买流程**。购买入口已在 Web 面,再复制一套扫码弹窗就违背了
「一个功能一个入口、一份数据一个查法」——也正是我在积分包/订阅之间特意合并弹窗
所避免的那种重复:真要两套,之前修过的轮询问题就得修两遍。

但"还剩几天到期"必须放在桌面端:这是用户实际干活的地方,而订阅到期即失效、
没有任何扣款或续费通知,看不见就等于没有。

真环境验证:桌面端用量页显示真实订阅(已发放 3 次)、余额 2,981.34 与账本一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-20 16:47:05 +08:00
parent caa2602131
commit a34d7045f1
2 changed files with 68 additions and 1 deletions
+25
View File
@@ -150,6 +150,31 @@ export interface MyTenant {
members: number;
}
// ---- 订阅(只读)----
// 桌面端**不做**购买流程:购买入口在 Web 面(一个功能一个入口)。这里只读状态,
// 因为"还剩几天到期"必须在用户干活的地方看得见——到期即失效且没有任何续费通知。
export interface MySubscription {
id: string;
plan_id: string;
status: string;
expires_at: string;
refill_seq: number;
}
export interface MySubPlan {
id: string;
name: string;
refill_credits_micro: number;
refill_interval_days: number;
}
export async function mySubscription(): Promise<{ subscription: MySubscription | null; plan: MySubPlan | null }> {
const res = await fetch(`${GATEWAY}/api/v1/billing/subscription`, { headers: bearer() });
if (!res.ok) return { subscription: null, plan: null };
const d = (await res.json()) as { subscription?: MySubscription | null; plan?: MySubPlan | null };
return { subscription: d.subscription ?? null, plan: d.plan ?? null };
}
export async function myTenants(): Promise<{ tenants: MyTenant[]; active: string }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() }));
if (!res.ok) return { tenants: [], active: "" };
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Coins, RefreshCw, Wallet, Cpu, Receipt, Activity } from "lucide-react";
import { myUsage, type MyUsage, type UsageDay } from "../lib/api";
import { myUsage, GATEWAY, type MyUsage, type UsageDay, mySubscription, type MySubscription, type MySubPlan } from "../lib/api";
import { openExternal } from "../lib/version";
import { Card, Panel, EmptyState, cn } from "../ui";
// 桌面端「用量」= 用户自己租户的计量观测:积分余额 + 消耗趋势 + 最近消耗。
@@ -34,11 +35,20 @@ export function UsageView() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [err, setErr] = useState("");
const [sub, setSub] = useState<MySubscription | null>(null);
const [subPlan, setSubPlan] = useState<MySubPlan | null>(null);
const load = async () => {
setRefreshing(true);
try {
setData(await myUsage(days));
// 订阅只读展示;失败不影响用量页主体(没订阅也是常态)
mySubscription()
.then((r) => {
setSub(r.subscription);
setSubPlan(r.plan);
})
.catch(() => {});
setErr("");
} catch (e) {
setErr((e as Error).message);
@@ -114,6 +124,10 @@ export function UsageView() {
<Metric icon={Receipt} label="估算成本" value={money(t.cost_micros)} sub="按定价折算(配置币种)" accent="text-slate-100" />
</div>
{/* 订阅状态:到期即失效、无自动续费、无扣款通知 —— 必须在用户干活的地方看得见。
购买/续订不在这里做(入口在 Web 面账单页),避免同一功能两个入口。 */}
{sub && subPlan && <SubBar sub={sub} plan={subPlan} />}
{/* 消耗趋势 */}
<Panel title="积分消耗趋势" icon={Activity} className="min-h-[200px]">
<TrendBars series={series} />
@@ -182,3 +196,31 @@ function TrendBars({ series }: { series: UsageDay[] }) {
</div>
);
}
// SubBar 订阅状态条。只读:桌面端不做购买流程,续订跳 Web 面账单页。
function SubBar({ sub, plan }: { sub: MySubscription; plan: MySubPlan }) {
const left = Math.ceil((new Date(sub.expires_at).getTime() - Date.now()) / 86400000);
const tone = left <= 3 ? "text-danger" : left <= 7 ? "text-warn" : "text-slate-400";
return (
<Card className={cn("flex flex-wrap items-center justify-between gap-3 p-4", left <= 7 ? "border-warn/40" : "")}>
<div>
<div className="text-sm font-medium text-slate-200"> · {plan.name}</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{plan.refill_interval_days} {credits(plan.refill_credits_micro)} · {sub.refill_seq} ·
</div>
</div>
<div className="flex items-center gap-3">
<span className={cn("text-xs tabular-nums", tone)}>
{new Date(sub.expires_at).toLocaleDateString("zh-CN")} {left}
</span>
<button
onClick={() => openExternal(`${GATEWAY}/usage`)}
className="rounded-lg border border-line px-3 py-1.5 text-xs text-slate-300 transition-colors hover:border-brand/50 hover:text-brand"
>
</button>
</div>
</Card>
);
}