diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 71038dbd0..f17655388 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -11,7 +11,9 @@ import { creditsLine, failedLines, resetCreditsLine, + resetText, tierLine, + type QuotaBreakdown, type QuotaDisplay, type QuotaLine, } from "@/components/quota/quotaRules"; @@ -93,6 +95,34 @@ function collapseInlineGroups(tiers: QuotaTier[]): QuotaTier[] { ); } +/** + * 卡片按组合并过档位时(Antigravity),整列做成一个下拉逐档列出: + * 「Gemini 5 小时 4h59m后重置 剩余 100%」。没合并时不需要(null) + */ +export function tierBreakdown( + t: TFunction, + tiers: QuotaTier[], + display: QuotaDisplay = "left", +): QuotaBreakdown | null { + const known = tiers.filter((tier) => tier.name in TIER_I18N_KEYS); + if (collapseInlineGroups(known).length === known.length) return null; + return { + title: t("quota.windows.title"), + openLabel: t("quota.windows.showAll"), + items: known.map((tier) => { + const label = tierLabel(t, tier.name); + const line = tierLine(t, tier, label, { display }); + return { + key: tier.name, + label, + hint: resetText(t, line) ?? undefined, + value: line.value ?? line.text, + tone: line.tone, + }; + }), + }; +} + export function tierLabel(t: TFunction, name: string): string { return TIER_I18N_KEYS[name] ? t(TIER_I18N_KEYS[name]) : name; } @@ -254,6 +284,7 @@ export const SubscriptionQuotaView: React.FC = ({ queriedAt={quota.queriedAt} loading={loading} onRefresh={refetch} + breakdown={tierBreakdown(t, quota.tiers || [], display) ?? undefined} /> ); } diff --git a/src/components/quota/QuotaBreakdown.tsx b/src/components/quota/QuotaBreakdown.tsx index bdb649248..e7f026cbc 100644 --- a/src/components/quota/QuotaBreakdown.tsx +++ b/src/components/quota/QuotaBreakdown.tsx @@ -16,6 +16,7 @@ import type { /** * 一行写不下的额度(存下了好几次重置):整行(卡片上是那一段)是按钮,点开逐条列出明细 * (每一次的到期日,同一天到期的并成一条)。授权中心、卡片额度列和展开的额度条共用。 + * 不给 `line` 时标题右边不写值(卡片整列做成一个下拉时,列出的是好几档,没有一个总值)。 * * `className` 给按钮,用调用方原来那一行的排版;左右各外扩 4px 留出悬停底色,文字不挪位。 */ @@ -26,7 +27,7 @@ export function QuotaBreakdownRow({ align = "start", children, }: { - line: QuotaLine; + line?: QuotaLine; breakdown: QuotaBreakdown; className?: string; /** 弹窗和按钮哪边对齐:卡片额度列靠右,用 end */ @@ -57,9 +58,11 @@ export function QuotaBreakdownRow({ >
{breakdown.title} - - {line.value ?? line.text} - + {line && ( + + {line.value ?? line.text} + + )}
{breakdown.footer && ( diff --git a/src/components/quota/QuotaLines.tsx b/src/components/quota/QuotaLines.tsx index 87520a87d..b62883896 100644 --- a/src/components/quota/QuotaLines.tsx +++ b/src/components/quota/QuotaLines.tsx @@ -11,6 +11,7 @@ import { formatRelativeTime, lineHint, resetText, + type QuotaBreakdown, type QuotaLine, type QuotaTone, } from "./quotaRules"; @@ -197,12 +198,15 @@ interface QuotaLinesProps { loading?: boolean; /** 返回 refetch() 的 Promise 时,点击后的 ✓ / ✗ 也认请求本身的成败 */ onRefresh?: () => unknown; + /** 卡片上放不下的各档:整列做成一个下拉(Antigravity 四档,见 tierBreakdown) */ + breakdown?: QuotaBreakdown; } /** * 卡片右侧的额度列(v7):最多两行、右对齐、平时灰色;档数多时第一行写窗口最短的那档, * 其余并成一行、每段各自上色(cardRows)。点一下重查,悬停说明每档、更新时间和重置时间。 * 能重查时,悬停 / 键盘聚焦在第一行左边露出 ↻,点了之后它转到查完,再按结果换成 ✓ / ✗ 停一秒。 + * 给了 `breakdown`(卡片只写得下其中几档)时整列是一个下拉,点开逐档列出;重查改点 ↻。 */ export function QuotaLines({ lines, @@ -210,6 +214,7 @@ export function QuotaLines({ queriedAt, loading = false, onRefresh, + breakdown, }: QuotaLinesProps) { const { t } = useTranslation(); const now = useNow(Boolean(queriedAt) || lines.some((line) => line.resetsAt)); @@ -266,8 +271,9 @@ export function QuotaLines({ const className = cn( "flex shrink-0 flex-col items-end text-caption leading-[18px] tabular-nums whitespace-nowrap", - // 文字仍是 136 宽,倒计时那格(52 + 间距)另加 + // 文字仍是 136 宽,倒计时那格(52 + 间距)另加;整列下拉的 ⌄ 那格(14)也另加 showReset ? "w-[194px]" : "w-[136px]", + breakdown && (showReset ? "w-[208px]" : "w-[150px]"), loading && !spinning && "opacity-60", ); @@ -330,6 +336,20 @@ export function QuotaLines({ /> ); + if (breakdown) { + return ( + + ); + } + if (split) { return ( void; } +/** + * 整列一个下拉:点文字打开各档明细,⌄ 跟在第一行文字后面(其余行留同宽的空位,数字仍竖着对齐)。 + * 下拉里不能再套按钮,↻ 单独做成下拉左边的小按钮(不占宽度),悬停整列时露出来。 + */ +function DropdownQuotaColumn({ + rows, + breakdown, + className, + title, + icon, + resetSlot, + refreshProps, +}: { + rows: QuotaLine[][]; + breakdown: QuotaBreakdown; + className: string; + title: string; + icon: ReactNode; + resetSlot: (row: QuotaLine[]) => ReactNode; + refreshProps: RefreshProps; +}) { + const { t } = useTranslation(); + return ( +
+ + {/* 绝对定位贴在下拉左边、不占宽度:↻ 出现 / 消失时文字不被挤动 */} + + + {rows.map((row, rowIndex) => ( + line.key).join("+")} + className="flex max-w-full items-center justify-end" + > + + {row.map((line, index) => ( + + {index > 0 && " · "} + 1 && line.short) || line.text} + line={line} + /> + + ))} + + + {rowIndex === 0 && } + + {resetSlot(row)} + + ))} + + +
+ ); +} + /** * 有一段能点开明细时(「每周 64% · 重置 3 次 ⌄」):那一段单独做成下拉,其余照旧点了重查。 * 按钮里不能再套按钮,所以整列拆成几块:第一块刷新是真按钮(键盘、读屏都认它), diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 45b4a16ea..d7b799ffc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -4462,6 +4462,10 @@ "text": "Credits ≈ {{usd}}", "usd": "≈ {{usd}}", "detail": "Credits balance {{balance}} · ≈ {{usd}} at API prices · used only after your limits run out" + }, + "windows": { + "title": "All limits", + "showAll": "Show all limits" } }, "appMatrix": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3142a3609..59dc1f7da 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -4462,6 +4462,10 @@ "text": "Credits 約 {{usd}}", "usd": "約 {{usd}}", "detail": "Credits 残高 {{balance}} · API 価格換算で約 {{usd}} · 上限に達した後にのみ消費" + }, + "windows": { + "title": "各枠の残量", + "showAll": "各枠の残量を表示" } }, "appMatrix": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 8bc07e2d8..4678b631e 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -4463,6 +4463,10 @@ "text": "Credits 約 {{usd}}", "usd": "約 {{usd}}", "detail": "Credits 餘額 {{balance}} · 按 API 價折算約 {{usd}} · 額度用完後才扣" + }, + "windows": { + "title": "各檔額度", + "showAll": "查看各檔額度" } }, "appMatrix": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index bc337ce19..31804a7b7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -4462,6 +4462,10 @@ "text": "Credits 约 {{usd}}", "usd": "约 {{usd}}", "detail": "Credits 余额 {{balance}} · 按 API 价折算约 {{usd}} · 额度用完后才扣" + }, + "windows": { + "title": "各档额度", + "showAll": "查看各档额度" } }, "appMatrix": { diff --git a/tests/components/SubscriptionQuotaFooter.test.tsx b/tests/components/SubscriptionQuotaFooter.test.tsx index 9ee0e3dd2..862645ff0 100644 --- a/tests/components/SubscriptionQuotaFooter.test.tsx +++ b/tests/components/SubscriptionQuotaFooter.test.tsx @@ -208,7 +208,7 @@ describe("Antigravity quota groups", () => { it("keeps one line per group on the card: the window with the least left", () => { renderQuota(agyTiers); - const lines = screen.getByRole("button").children; + const lines = screen.getByRole("button", { name: "查看各档额度" }).children; expect(lines).toHaveLength(2); expect(lines[0]).toHaveTextContent("Gemini 剩余 60%"); expect(lines[1]).toHaveTextContent("Claude/GPT 剩余 5%"); @@ -216,6 +216,34 @@ describe("Antigravity quota groups", () => { expect(screen.getByText("2d12h")).toBeInTheDocument(); }); + it("opens one dropdown listing all four windows; ↻ still refreshes", async () => { + const refetch = vi.fn(); + renderQuota(agyTiers, true, {}, refetch); + fireEvent.click(screen.getByRole("button", { name: "查看各档额度" })); + expect(refetch).not.toHaveBeenCalled(); + const dialog = await screen.findByRole("dialog", { name: "各档额度" }); + expect( + within(dialog) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual([ + "Gemini 5 小时剩余 90%", + "Gemini 每周2d12h后重置剩余 60%", + "Claude/GPT 5 小时剩余 5%", + "Claude/GPT 每周剩余 80%", + ]); + + fireEvent.click(screen.getByRole("button", { name: "点击重新查询" })); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it("keeps other cards' quota column clickable to refresh", () => { + renderQuota([{ name: "five_hour", utilization: 10, resetsAt: null }]); + expect( + screen.queryByRole("button", { name: "查看各档额度" }), + ).not.toBeInTheDocument(); + }); + it("lists every group and window when expanded", () => { renderQuota(agyTiers, false); for (const name of [