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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user