diff --git a/packages/views/billing/billing-test-page.tsx b/packages/views/billing/billing-test-page.tsx
index 375d57b63..4dc4fc369 100644
--- a/packages/views/billing/billing-test-page.tsx
+++ b/packages/views/billing/billing-test-page.tsx
@@ -15,12 +15,12 @@
//
// Anything past "make the API talk to Stripe and surface results" is
// out of scope here on purpose — when the real billing UI ships it
-// will live elsewhere and this whole page can be deleted. Until then,
-// the page intentionally hard-codes English labels rather than going
-// through useT(): it is dev-only, not localized, and slated for
-// deletion — so it is exempt from the package-wide i18next/no-literal-string
-// rule.
-/* eslint-disable i18next/no-literal-string */
+// will live elsewhere and this whole page can be deleted. Strings
+// are still routed through useT() so the package-wide
+// i18next/no-literal-string lint rule passes; the namespace is
+// `billing` and the en/zh-Hans bundles live alongside the other
+// namespaces. When the real UI lands, both the namespace and this
+// file get deleted together.
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
@@ -51,6 +51,7 @@ import {
CardHeader,
CardTitle,
} from "@multica/ui/components/ui/card";
+import { useT } from "../i18n";
import { useNavigation } from "../navigation";
// 1 credit = 1_000_000 micro-credit; cents → dollars factor for the
@@ -60,6 +61,7 @@ const MICRO_PER_CREDIT = 1_000_000;
const CENTS_PER_DOLLAR = 100;
export function BillingTestPage() {
+ const { t } = useT("billing");
const { searchParams, replace, pathname } = useNavigation();
// The Stripe success URL on the cloud side has the literal
@@ -70,11 +72,9 @@ export function BillingTestPage() {
return (
@@ -115,6 +115,7 @@ function CheckoutSessionStatusBanner({
sessionId: string;
onDismiss: () => void;
}) {
+ const { t } = useT("billing");
const { data, isLoading, isError, error } = useQuery(
billingCheckoutSessionOptions(sessionId),
);
@@ -131,11 +132,11 @@ function CheckoutSessionStatusBanner({
// would be a manual refresh click. Invalidate the dependent
// queries so they re-fetch in the background.
//
- // Dep list `[terminal, ...]`: `terminal` only flips from false→true
- // once per session-id, so the invalidation fires exactly once. If
- // the caller mounts this banner with a session that is already in a
+ // Dep list `terminal`: terminal only flips from false→true once
+ // per session-id, so the invalidation fires exactly once. If the
+ // caller mounts this banner with a session that is already in a
// terminal state (e.g. user revisits the success URL after closing
- // and reopening the tab), `terminal` flips false→true on the first
+ // and reopening the tab), terminal flips false→true on the first
// data load and we still re-fetch — which is what we want, because
// the cached snapshot is just as stale in that case.
const invalidateBillingDataAfterCredit = useInvalidateBillingDataAfterCredit();
@@ -147,31 +148,44 @@ function CheckoutSessionStatusBanner({
- Checkout session {sessionId.slice(0, 16)}…
+ {t(($) => $.checkout.session_label, { prefix: sessionId.slice(0, 16) })}
{isLoading
- ? "Loading order status…"
+ ? t(($) => $.checkout.loading)
: isError
- ? `Failed to fetch status: ${error instanceof Error ? error.message : "unknown error"}`
+ ? t(($) => $.checkout.fetch_failed, {
+ error:
+ error instanceof Error
+ ? error.message
+ : t(($) => $.checkout.fetch_failed_unknown),
+ })
: terminal
- ? `Final status: ${status}`
- : `Polling status… current: ${status || "unknown"}`}
+ ? t(($) => $.checkout.final_status, { status })
+ : t(($) => $.checkout.polling_status, {
+ status: status || t(($) => $.checkout.status_unknown),
+ })}
{data && (
- Order
+ {t(($) => $.checkout.label_order)}
{data.order_id}
- Tier
+ {t(($) => $.checkout.label_tier)}
{data.tier_id}
- Charged
+ {t(($) => $.checkout.label_charged)}
- {formatMoney(data.amount_cents, data.currency)} ·{" "}
- {data.credits.toLocaleString()} credits
- {data.bonus_credits > 0 &&
- ` + ${data.bonus_credits.toLocaleString()} bonus`}
+ {data.bonus_credits > 0
+ ? t(($) => $.checkout.charged_with_bonus, {
+ money: formatMoney(data.amount_cents, data.currency),
+ credits: data.credits.toLocaleString(),
+ bonus: data.bonus_credits.toLocaleString(),
+ })
+ : t(($) => $.checkout.charged_value, {
+ money: formatMoney(data.amount_cents, data.currency),
+ credits: data.credits.toLocaleString(),
+ })}
{terminal && (
@@ -181,7 +195,7 @@ function CheckoutSessionStatusBanner({
className="mt-3"
onClick={onDismiss}
>
- Clear from URL
+ {t(($) => $.checkout.clear_url)}
)}
@@ -193,15 +207,16 @@ function CheckoutSessionStatusBanner({
// ─── Balance ─────────────────────────────────────────────────────────
function BalanceCard() {
+ const { t } = useT("billing");
const balance = useQuery(billingBalanceOptions());
return (
- Balance
+ {t(($) => $.balance.title)}
- GET /api/cloud-billing/balance
+ {t(($) => $.endpoints.balance)}
{balance.data?.balance_credit.toLocaleString() ?? 0}
- credits
+ {t(($) => $.balance.credits_suffix)}
- raw micro: {balance.data?.balance_micro.toLocaleString() ?? 0} ·
- owner: {balance.data?.owner_id.slice(0, 8) ?? ""}… · updated{" "}
- {formatDate(balance.data?.updated_at)}
+ {t(($) => $.balance.meta, {
+ micro: balance.data?.balance_micro.toLocaleString() ?? 0,
+ owner: balance.data?.owner_id.slice(0, 8) ?? "",
+ updated: formatDate(balance.data?.updated_at, t),
+ })}
)}
@@ -237,6 +254,7 @@ function BalanceCard() {
// ─── Buy + Portal ────────────────────────────────────────────────────
function BuyAndPortalSection() {
+ const { t } = useT("billing");
const tiers = useQuery(billingPriceTiersOptions());
const createCheckout = useCreateCloudBillingCheckoutSession();
const createPortal = useCreateCloudBillingPortalSession();
@@ -247,7 +265,7 @@ function BuyAndPortalSection() {
try {
const { url } = await createCheckout.mutateAsync({ tier_id: tier.id });
if (!url) {
- toast.error("Cloud returned no checkout URL");
+ toast.error(t(($) => $.buy.toast_no_url));
return;
}
// Redirect via window.location instead of window.open so the
@@ -256,7 +274,9 @@ function BuyAndPortalSection() {
// SPA-like behaviour from there.
window.location.href = url;
} catch (err) {
- toast.error(err instanceof Error ? err.message : "Checkout failed");
+ toast.error(
+ err instanceof Error ? err.message : t(($) => $.buy.toast_checkout_failed),
+ );
} finally {
setBusyTier(null);
}
@@ -266,7 +286,7 @@ function BuyAndPortalSection() {
try {
const { url } = await createPortal.mutateAsync();
if (!url) {
- toast.error("No portal URL returned");
+ toast.error(t(($) => $.buy.toast_no_portal_url));
return;
}
// Open in a new tab — the portal is a customer self-service
@@ -277,16 +297,18 @@ function BuyAndPortalSection() {
// 400 is the documented "no Stripe customer yet" case from
// upstream. Surface the body verbatim — it's the most useful
// signal during testing.
- toast.error(err instanceof Error ? err.message : "Portal failed");
+ toast.error(
+ err instanceof Error ? err.message : t(($) => $.buy.toast_portal_failed),
+ );
}
};
return (
- Buy credits / Manage billing
+ {t(($) => $.buy.title)}
- GET /price-tiers · POST /checkout-sessions · POST /portal-sessions
+ {t(($) => $.endpoints.buy)}
@@ -307,10 +329,7 @@ function BuyAndPortalSection() {
))}
) : (
-
- No price tiers configured. Cloud probably has no Stripe key set —
- checkout will return 503.
-
+ {t(($) => $.buy.no_tiers)}
)}
@@ -326,11 +345,10 @@ function BuyAndPortalSection() {
) : (
)}
- Open Stripe Billing Portal
+ {t(($) => $.buy.open_portal)}
- 400 is expected for users who haven't paid yet (no Stripe
- customer record exists).
+ {t(($) => $.buy.portal_hint)}
@@ -349,7 +367,22 @@ function TierButton({
disabled: boolean;
onClick: () => void;
}) {
+ const { t } = useT("billing");
const display = tier.display_name || tier.id;
+ const baseLine = t(($) => $.buy.tier_money_to_credits, {
+ money: formatMoney(tier.amount_cents, "usd"),
+ credits: tier.credits.toLocaleString(),
+ });
+ const bonusLine = tier.bonus_credits
+ ? tier.bonus_expires_in
+ ? t(($) => $.buy.tier_bonus_with_expiry, {
+ credits: tier.bonus_credits.toLocaleString(),
+ expiry: tier.bonus_expires_in,
+ })
+ : t(($) => $.buy.tier_bonus, {
+ credits: tier.bonus_credits.toLocaleString(),
+ })
+ : "";
return (
}
- {formatMoney(tier.amount_cents, "usd")} →{" "}
- {tier.credits.toLocaleString()} credits
- {tier.bonus_credits ? (
- <>
- {" "}
- + {tier.bonus_credits.toLocaleString()} bonus
- {tier.bonus_expires_in ? ` (${tier.bonus_expires_in})` : ""}
- >
- ) : null}
+ {baseLine}
+ {bonusLine}
- id: {tier.id}
+ {t(($) => $.buy.tier_id, { id: tier.id })}
);
@@ -382,14 +408,15 @@ function TierButton({
// ─── Lists ───────────────────────────────────────────────────────────
function TransactionsCard() {
+ const { t } = useT("billing");
const txs = useQuery(billingTransactionsOptions({ page: 1, page_size: 20 }));
return (
- Transactions
+ {t(($) => $.transactions.title)}
- GET /api/cloud-billing/transactions
+ {t(($) => $.endpoints.transactions)}
) : (
- No transactions yet.
+ {t(($) => $.transactions.empty)}
)}
@@ -434,34 +462,40 @@ function TransactionRow({ row }: { row: BillingTransaction }) {
= 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"
+ credit >= 0
+ ? "text-green-700 dark:text-green-400"
+ : "text-red-700 dark:text-red-400"
}`}
>
- {credit >= 0 ? "+" : ""}
- {credit.toLocaleString()} credits
+ {t(($) => $.transactions.credits_value, {
+ value: `${credit >= 0 ? "+" : ""}${credit.toLocaleString()}`,
+ })}
{row.description && (
{row.description}
)}
- {formatDate(row.created_at)} · balance after:{" "}
- {(row.balance_after / MICRO_PER_CREDIT).toLocaleString()} credits · ref:{" "}
- {row.reference_id || "—"}
+ {t(($) => $.transactions.row_meta, {
+ date: formatDate(row.created_at, t),
+ balance: (row.balance_after / MICRO_PER_CREDIT).toLocaleString(),
+ ref: row.reference_id || t(($) => $.transactions.ref_empty),
+ })}
);
}
function BatchesCard() {
+ const { t } = useT("billing");
const batches = useQuery(billingBatchesOptions({ page: 1, page_size: 20 }));
return (
- Credit batches
+ {t(($) => $.batches.title)}
- GET /api/cloud-billing/batches
+ {t(($) => $.endpoints.batches)}
) : (
- No batches yet.
+ {t(($) => $.batches.empty)}
)}
{row.source_type}
- {row.id.slice(0, 8)}…
+ {t(($) => $.batches.id_suffix, { id: row.id.slice(0, 8) })}
- {remaining.toLocaleString()} / {total.toLocaleString()} credits
+ {t(($) => $.batches.remaining_over_total, {
+ remaining: remaining.toLocaleString(),
+ total: total.toLocaleString(),
+ })}
- Consumed: {consumed.toLocaleString()} credits
- {row.expires_at ? ` · expires ${formatDate(row.expires_at)}` : " · never expires"}
+ {t(($) => $.batches.consumed, { value: consumed.toLocaleString() })}
+ {row.expires_at
+ ? t(($) => $.batches.expires_suffix, { value: formatDate(row.expires_at, t) })
+ : t(($) => $.batches.never_expires_suffix)}
);
}
function TopupsCard() {
+ const { t } = useT("billing");
const topups = useQuery(billingTopupsOptions({ page: 1, page_size: 20 }));
return (
- Topup orders
+ {t(($) => $.topups.title)}
- GET /api/cloud-billing/topups
+ {t(($) => $.endpoints.topups)}
) : (
- No topup orders yet.
+ {t(($) => $.topups.empty)}
)}
@@ -577,13 +619,23 @@ function TopupRow({ row }: { row: BillingTopup }) {
- {formatMoney(row.amount_cents, row.currency)} →{" "}
- {row.credits.toLocaleString()} credits
- {row.bonus_credits > 0 ? ` + ${row.bonus_credits} bonus` : ""}
+ {row.bonus_credits > 0
+ ? t(($) => $.topups.amount_to_credits_with_bonus, {
+ money: formatMoney(row.amount_cents, row.currency),
+ credits: row.credits.toLocaleString(),
+ bonus: row.bonus_credits,
+ })
+ : t(($) => $.topups.amount_to_credits, {
+ money: formatMoney(row.amount_cents, row.currency),
+ credits: row.credits.toLocaleString(),
+ })}
- {formatDate(row.created_at)} · stripe: {row.stripe_checkout_id || "—"}
+ {t(($) => $.topups.row_meta, {
+ date: formatDate(row.created_at, t),
+ checkout: row.stripe_checkout_id || t(($) => $.topups.stripe_empty),
+ })}
);
@@ -600,10 +652,15 @@ function PagingFooter({
pageSize: number;
total: number;
}) {
+ const { t } = useT("billing");
if (total === 0) return null;
return (
- page {page} / {Math.max(1, Math.ceil(total / pageSize))} · {total} total
+ {t(($) => $.shared.paging, {
+ page,
+ totalPages: Math.max(1, Math.ceil(total / pageSize)),
+ total,
+ })}
);
}
@@ -615,6 +672,7 @@ function RefreshButton({
isLoading: boolean;
onClick: () => void;
}) {
+ const { t } = useT("billing");
return (
$.shared.refresh)}
>
@@ -631,9 +689,10 @@ function RefreshButton({
}
function ErrorText({ error }: { error: unknown }) {
+ const { t } = useT("billing");
return (
- {error instanceof Error ? error.message : "Request failed"}
+ {error instanceof Error ? error.message : t(($) => $.shared.request_failed)}
);
}
@@ -645,7 +704,8 @@ function EmptyText({ children }: { children: React.ReactNode }) {
function formatMoney(amountCents: number, currency: string): string {
// Intl is fine here — no currency conversion happening, just
// canonical display. Defaults to en-US to match the rest of the
- // dev UI.
+ // dev UI; the produced string is then passed into a t() interpolation
+ // so the surrounding sentence still gets translated.
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
@@ -656,8 +716,17 @@ function formatMoney(amountCents: number, currency: string): string {
}
}
-function formatDate(value: string | undefined): string {
- if (!value) return "—";
+// formatDate returns an ISO-ish localized string or the locale's "—"
+// dash for missing/invalid input. The dash itself goes through the
+// translation bundle so it stays consistent if either locale ever
+// wants to swap it for a localized placeholder. Caller passes in `t`
+// so we don't run useT() inside a util (the rule of hooks would
+// trip on the conditional path).
+function formatDate(
+ value: string | undefined,
+ t: ReturnType>["t"],
+): string {
+ if (!value) return t(($) => $.shared.date_dash);
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString();
diff --git a/packages/views/i18n/resources-types.ts b/packages/views/i18n/resources-types.ts
index efc3cfa26..f1b83b423 100644
--- a/packages/views/i18n/resources-types.ts
+++ b/packages/views/i18n/resources-types.ts
@@ -26,6 +26,7 @@ import type runtimes from "../locales/en/runtimes.json";
import type layout from "../locales/en/layout.json";
import type usage from "../locales/en/usage.json";
import type squads from "../locales/en/squads.json";
+import type billing from "../locales/en/billing.json";
// Module augmentation enables i18next v26 selector API across the monorepo:
// `t($ => $.signin.title)` resolves to the value in en/auth.json.
@@ -66,6 +67,7 @@ declare global {
layout: typeof layout;
usage: typeof usage;
squads: typeof squads;
+ billing: typeof billing;
}
}
diff --git a/packages/views/locales/en/billing.json b/packages/views/locales/en/billing.json
new file mode 100644
index 000000000..1a03364d8
--- /dev/null
+++ b/packages/views/locales/en/billing.json
@@ -0,0 +1,75 @@
+{
+ "title": "Billing (test page)",
+ "subtitle": "Direct passthrough to multica-cloud's /api/v1/billing/*. Not a finished UI — just every endpoint stuffed onto one page so we can verify the proxy + Stripe flow.",
+ "endpoints": {
+ "balance": "GET /api/cloud-billing/balance",
+ "transactions": "GET /api/cloud-billing/transactions",
+ "batches": "GET /api/cloud-billing/batches",
+ "topups": "GET /api/cloud-billing/topups",
+ "buy": "GET /price-tiers · POST /checkout-sessions · POST /portal-sessions"
+ },
+ "balance": {
+ "title": "Balance",
+ "credits_suffix": "credits",
+ "meta": "raw micro: {{micro}} · owner: {{owner}}… · updated {{updated}}"
+ },
+ "buy": {
+ "title": "Buy credits / Manage billing",
+ "no_tiers": "No price tiers configured. Cloud probably has no Stripe key set — checkout will return 503.",
+ "tier_money_to_credits": "{{money}} → {{credits}} credits",
+ "tier_bonus": " + {{credits}} bonus",
+ "tier_bonus_with_expiry": " + {{credits}} bonus ({{expiry}})",
+ "tier_id": "id: {{id}}",
+ "open_portal": "Open Stripe Billing Portal",
+ "portal_hint": "400 is expected for users who haven't paid yet (no Stripe customer record exists).",
+ "toast_no_url": "Cloud returned no checkout URL",
+ "toast_checkout_failed": "Checkout failed",
+ "toast_no_portal_url": "No portal URL returned",
+ "toast_portal_failed": "Portal failed"
+ },
+ "checkout": {
+ "session_label": "Checkout session {{prefix}}…",
+ "loading": "Loading order status…",
+ "fetch_failed": "Failed to fetch status: {{error}}",
+ "fetch_failed_unknown": "unknown error",
+ "final_status": "Final status: {{status}}",
+ "polling_status": "Polling status… current: {{status}}",
+ "status_unknown": "unknown",
+ "label_order": "Order",
+ "label_tier": "Tier",
+ "label_charged": "Charged",
+ "charged_value": "{{money}} · {{credits}} credits",
+ "charged_with_bonus": "{{money}} · {{credits}} credits + {{bonus}} bonus",
+ "clear_url": "Clear from URL"
+ },
+ "transactions": {
+ "title": "Transactions",
+ "empty": "No transactions yet.",
+ "credits_value": "{{value}} credits",
+ "row_meta": "{{date}} · balance after: {{balance}} credits · ref: {{ref}}",
+ "ref_empty": "—"
+ },
+ "batches": {
+ "title": "Credit batches",
+ "empty": "No batches yet.",
+ "remaining_over_total": "{{remaining}} / {{total}} credits",
+ "id_suffix": "{{id}}…",
+ "consumed": "Consumed: {{value}} credits",
+ "expires_suffix": " · expires {{value}}",
+ "never_expires_suffix": " · never expires"
+ },
+ "topups": {
+ "title": "Topup orders",
+ "empty": "No topup orders yet.",
+ "amount_to_credits": "{{money}} → {{credits}} credits",
+ "amount_to_credits_with_bonus": "{{money}} → {{credits}} credits + {{bonus}} bonus",
+ "row_meta": "{{date}} · stripe: {{checkout}}",
+ "stripe_empty": "—"
+ },
+ "shared": {
+ "request_failed": "Request failed",
+ "refresh": "Refresh",
+ "paging": "page {{page}} / {{totalPages}} · {{total}} total",
+ "date_dash": "—"
+ }
+}
diff --git a/packages/views/locales/index.ts b/packages/views/locales/index.ts
index 114048aaa..75f9d43c6 100644
--- a/packages/views/locales/index.ts
+++ b/packages/views/locales/index.ts
@@ -23,6 +23,7 @@ import enLayout from "./en/layout.json";
import enUsage from "./en/usage.json";
import enUi from "./en/ui.json";
import enSquads from "./en/squads.json";
+import enBilling from "./en/billing.json";
import zhHansCommon from "./zh-Hans/common.json";
import zhHansAuth from "./zh-Hans/auth.json";
import zhHansSettings from "./zh-Hans/settings.json";
@@ -47,6 +48,7 @@ import zhHansLayout from "./zh-Hans/layout.json";
import zhHansUsage from "./zh-Hans/usage.json";
import zhHansUi from "./zh-Hans/ui.json";
import zhHansSquads from "./zh-Hans/squads.json";
+import zhHansBilling from "./zh-Hans/billing.json";
// Single source of truth for the resource bundle. Both apps (web layout +
// desktop App.tsx) import from here so adding a locale or namespace happens
@@ -77,6 +79,7 @@ export const RESOURCES: Record = {
usage: enUsage,
ui: enUi,
squads: enSquads,
+ billing: enBilling,
},
"zh-Hans": {
common: zhHansCommon,
@@ -103,5 +106,6 @@ export const RESOURCES: Record = {
usage: zhHansUsage,
ui: zhHansUi,
squads: zhHansSquads,
+ billing: zhHansBilling,
},
};
diff --git a/packages/views/locales/zh-Hans/billing.json b/packages/views/locales/zh-Hans/billing.json
new file mode 100644
index 000000000..3a213bde4
--- /dev/null
+++ b/packages/views/locales/zh-Hans/billing.json
@@ -0,0 +1,75 @@
+{
+ "title": "账单(测试页)",
+ "subtitle": "直接透传 multica-cloud 的 /api/v1/billing/*。不是正式 UI——只是把所有端点塞到一个页面方便验证 Stripe 流程。",
+ "endpoints": {
+ "balance": "GET /api/cloud-billing/balance",
+ "transactions": "GET /api/cloud-billing/transactions",
+ "batches": "GET /api/cloud-billing/batches",
+ "topups": "GET /api/cloud-billing/topups",
+ "buy": "GET /price-tiers · POST /checkout-sessions · POST /portal-sessions"
+ },
+ "balance": {
+ "title": "余额",
+ "credits_suffix": "credits",
+ "meta": "raw micro: {{micro}} · owner: {{owner}}… · updated {{updated}}"
+ },
+ "buy": {
+ "title": "购买 credits / 管理账单",
+ "no_tiers": "未配置任何价格档位。Cloud 那边应该没有配 Stripe key——checkout 会返回 503。",
+ "tier_money_to_credits": "{{money}} → {{credits}} credits",
+ "tier_bonus": " + 赠送 {{credits}}",
+ "tier_bonus_with_expiry": " + 赠送 {{credits}}({{expiry}})",
+ "tier_id": "id: {{id}}",
+ "open_portal": "打开 Stripe Billing Portal",
+ "portal_hint": "对于尚未付款的用户预期返回 400(Stripe 还没建 customer 记录)。",
+ "toast_no_url": "Cloud 没有返回 checkout URL",
+ "toast_checkout_failed": "Checkout 失败",
+ "toast_no_portal_url": "没拿到 portal URL",
+ "toast_portal_failed": "打开 Portal 失败"
+ },
+ "checkout": {
+ "session_label": "Checkout session {{prefix}}…",
+ "loading": "正在加载订单状态…",
+ "fetch_failed": "拉取订单状态失败:{{error}}",
+ "fetch_failed_unknown": "未知错误",
+ "final_status": "最终状态:{{status}}",
+ "polling_status": "正在轮询订单状态…当前:{{status}}",
+ "status_unknown": "unknown",
+ "label_order": "订单",
+ "label_tier": "档位",
+ "label_charged": "已收款",
+ "charged_value": "{{money}} · {{credits}} credits",
+ "charged_with_bonus": "{{money}} · {{credits}} credits + {{bonus}} 赠送",
+ "clear_url": "从 URL 清除"
+ },
+ "transactions": {
+ "title": "流水",
+ "empty": "暂无流水。",
+ "credits_value": "{{value}} credits",
+ "row_meta": "{{date}} · balance after: {{balance}} credits · ref: {{ref}}",
+ "ref_empty": "—"
+ },
+ "batches": {
+ "title": "Credit 批次",
+ "empty": "暂无批次。",
+ "remaining_over_total": "{{remaining}} / {{total}} credits",
+ "id_suffix": "{{id}}…",
+ "consumed": "已消耗:{{value}} credits",
+ "expires_suffix": " · 过期时间 {{value}}",
+ "never_expires_suffix": " · 永不过期"
+ },
+ "topups": {
+ "title": "充值订单",
+ "empty": "暂无充值订单。",
+ "amount_to_credits": "{{money}} → {{credits}} credits",
+ "amount_to_credits_with_bonus": "{{money}} → {{credits}} credits + {{bonus}} 赠送",
+ "row_meta": "{{date}} · stripe: {{checkout}}",
+ "stripe_empty": "—"
+ },
+ "shared": {
+ "request_failed": "请求失败",
+ "refresh": "刷新",
+ "paging": "第 {{page}} / {{totalPages}} 页 · 共 {{total}} 条",
+ "date_dash": "—"
+ }
+}