fix(quota): bring back colored quota numbers and warn below 20%

The all-gray quota text made it hard to tell a healthy limit from one
that is running low. Restore the v3 green / orange / red, but color only
the number ("88%", "¥82.10", "$2500", the reset count) so tier names and
"left" stay gray. Lines with no number (used up, expired, query failed)
are still colored as a whole.

- Warn once a tier has less than 20% left (was 10%), in the card and in
  the tray's "almost out".
- The "used $X" line has no total, so it stays uncolored.
- The expanded bars and the auth center keep neutral numbers while
  normal, since the bar already carries the color.
This commit is contained in:
Jason committed 2026-10-09 16:51:08 +08:00
1 parent b4a079430c
commit 0be33a8442
7 files changed
+101 -62

No files matched your search

+4 -4
View File
@@ -65,7 +65,7 @@ const MAX_NAME_CHARS: usize = 32;
/// 问题区里切换失败的原因最多几个字。
const MAX_REASON_CHARS: usize = 60;
/// 额度剩余不到这个百分比算「快用完」(和前端 `quotaRules.WARN_BELOW_PERCENT` 一致)。
const WARN_BELOW_PERCENT: f64 = 10.0;
const WARN_BELOW_PERCENT: f64 = 20.0;
/// 每个应用行的子菜单句柄,额度更新时就地改标题而不是整菜单重建(整建会关掉 macOS 上
/// 正开着的菜单)。`create_tray_menu` 每次重建都整表覆盖写入。
@@ -3040,7 +3040,7 @@ mod tests {
}
#[test]
fn almost_out_only_below_ten_percent_left_and_not_when_used_up() {
fn almost_out_only_below_twenty_percent_left_and_not_when_used_up() {
let at = |used: f64| {
title(format_subscription_quota(
&zh(),
@@ -3048,8 +3048,8 @@ mod tests {
))
.unwrap()
};
assert_eq!(at(85.0), ("5 小时剩余 15%".to_string(), false));
assert_eq!(at(91.0), ("5 小时剩余 9%".to_string(), true));
assert_eq!(at(80.0), ("5 小时剩余 20%".to_string(), false));
assert_eq!(at(85.0), ("5 小时剩余 15%".to_string(), true));
// 用完时额度本身写「已用完」,不再加「快用完」。
assert_eq!(at(100.0), ("5 小时已用完".to_string(), false));
}
+1 -1
View File
@@ -88,7 +88,7 @@ function planLine(
return {
key,
left: Infinity,
tone: "normal",
tone: "plain",
text: `${t("usage.used")} ${amount(data.used)}`,
detail: detail || undefined,
};
+51 -25
View File
@@ -15,15 +15,13 @@ import {
} from "./quotaRules";
import { QuotaBreakdownChevron, QuotaBreakdownRow } from "./QuotaBreakdown";
/**
* 快用完只加深加粗、不用橙色:浅色模式的警告文字和可点击文字(主题橙)几乎同色,
* 额度列本身又能点,橙色会被读成「这里可以点」。展开的额度条仍用琥珀色填充
*/
/** 沿用 v3 的绿 / 橙 / 红:全灰时几档额度看不出差别(用户反馈) */
export const TONE_TEXT: Record<QuotaTone, string> = {
normal: "text-fg-2",
normal: "text-green-600 dark:text-green-400",
muted: "text-fg-3",
warning: "font-medium text-fg-1",
danger: "font-medium text-danger-text",
warning: "text-orange-500 dark:text-orange-400",
danger: "text-red-500 dark:text-red-400",
plain: "",
};
export const TONE_FILL: Record<QuotaTone, string> = {
@@ -31,8 +29,35 @@ export const TONE_FILL: Record<QuotaTone, string> = {
muted: "bg-chart-1",
warning: "bg-warning",
danger: "bg-danger",
plain: "bg-chart-1",
};
/**
* 只给数值上色(「5 小时剩余 88%」里的「88%」),档名和「剩余」跟外层的灰字;
* 句子里没有单独的数值(用完、过期、没查到)时整句上色。className 不传时不上色
*/
export function TonedText({
text,
line,
className = TONE_TEXT[line.tone],
}: {
text: string;
line: Pick<QuotaLine, "tone" | "emphasis">;
className?: string;
}) {
if (!className) return <>{text}</>;
const at = line.emphasis ? text.lastIndexOf(line.emphasis) : -1;
if (at < 0) return <span className={className}>{text}</span>;
const end = at + line.emphasis!.length;
return (
<>
{text.slice(0, at)}
<span className={className}>{line.emphasis}</span>
{text.slice(end)}
</>
);
}
/** 每 30 秒刷新一次「x 分钟前」和重置倒计时 */
export function useNow(active: boolean) {
const [now, setNow] = useState(Date.now());
@@ -221,15 +246,15 @@ export function QuotaLines({
className="flex max-w-full items-center justify-end"
>
{row.length === 1 ? (
<span className={cn("min-w-0 truncate", TONE_TEXT[row[0].tone])}>
{row[0].text}
<span className="min-w-0 truncate text-fg-2">
<TonedText text={row[0].text} line={row[0]} />
</span>
) : (
<span className="min-w-0 truncate text-fg-2">
{row.map((line, index) => (
<span key={line.key}>
{index > 0 && " · "}
<span className={TONE_TEXT[line.tone]}>{line.short}</span>
<TonedText text={line.short ?? line.text} line={line} />
</span>
))}
</span>
@@ -398,9 +423,10 @@ function SplitQuotaColumn({
const node = (
<span key={line.key}>
{segment > 0 && " · "}
<span className={TONE_TEXT[line.tone]}>
{merged ? line.short : line.text}
</span>
<TonedText
text={(merged && line.short) || line.text}
line={line}
/>
</span>
);
segment += 1;
@@ -424,12 +450,12 @@ function SplitQuotaColumn({
line={line}
breakdown={line.breakdown!}
align="end"
className={cn(
"flex items-center gap-0.5",
TONE_TEXT[line.tone],
)}
className="flex items-center gap-0.5"
>
{merged ? line.short : line.text}
<TonedText
text={(merged && line.short) || line.text}
line={line}
/>
{chevronInSlot ? (
<span className="ms-1 flex">
<QuotaBreakdownChevron />
@@ -582,13 +608,13 @@ export function QuotaBars({
/>
</span>
)}
<span
className={cn(
"shrink-0 text-end tabular-nums whitespace-nowrap",
line.tone === "normal" ? "text-fg-1" : TONE_TEXT[line.tone],
)}
>
{line.value ?? line.text}
{/* 旁边已有额度条,正常时数值不上色 */}
<span className="shrink-0 text-end tabular-nums whitespace-nowrap text-fg-1">
<TonedText
text={line.value ?? line.text}
line={line}
className={line.tone === "normal" ? "" : TONE_TEXT[line.tone]}
/>
</span>
{trailing && (
<span className="min-w-0 truncate text-fg-3">{trailing}</span>
+11 -4
View File
@@ -2,11 +2,12 @@ import type { TFunction } from "i18next";
import type { QuotaTier, ResetCredits } from "@/types/subscription";
/**
* 额度的文字和颜色(v7 QuotaSpec):一律写「剩余」,平时灰色;任一档剩余不到 10% 加深加粗
* (不用橙色,见 TONE_TEXT;余额不算,见 balanceLine);用完 / 过期 / 没查到红色。卡片最多两行:
* 额度的文字和颜色(v7 QuotaSpec):一律写「剩余」,只给数值上色(见 emphasis):平时绿色;
* 任一档剩余不到 20% 橙色(余额不算,见 balanceLine);用完 / 过期 / 没查到整句红色。卡片最多两行:
* 档数更多时,第一行固定写窗口最短的那档,其余并成一行(见 cardRows)。
*/
export type QuotaTone = "normal" | "warning" | "danger" | "muted";
/** plain:数值不表示额度好坏(「已用 $3.20」),不上色 */
export type QuotaTone = "normal" | "warning" | "danger" | "muted" | "plain";
export interface QuotaLine {
key: string;
@@ -14,6 +15,8 @@ export interface QuotaLine {
/** 不带档名的值(「剩余 62%」),额度条里档名单独一列 */
value?: string;
tone: QuotaTone;
/** 句子里按 tone 上色的那段数值(「62%」「¥82.10」),其余字保持灰色;没有时整句上色 */
emphasis?: string;
/** 剩余百分比;余额没有总额时是 Infinity,失败 / 过期是负数(排在最前) */
left: number;
/** 悬停时补充的一句(套餐名、失败原因);重置时间不写这里,见 resetsAt */
@@ -52,7 +55,7 @@ export interface QuotaBreakdownItem {
tone: QuotaTone;
}
export const WARN_BELOW_PERCENT = 10;
export const WARN_BELOW_PERCENT = 20;
export function toneForLeft(left: number): QuotaTone {
if (left <= 0) return "danger";
@@ -114,6 +117,7 @@ export function tierLine(
key: tier.name,
left,
tone: toneForLeft(left),
emphasis: left <= 0 ? undefined : `${left}%`,
text:
left <= 0
? t("quota.tierUsedUp", params)
@@ -203,6 +207,7 @@ export function resetCreditsLine(
key: "reset_credits",
left: Infinity,
tone: expiringSoon ? "warning" : "normal",
emphasis: String(count),
text: t("quota.resetCredits.left", { count }),
value: t("quota.resetCredits.value", { count }),
short: t("quota.resetCredits.short", { count }),
@@ -262,6 +267,7 @@ export function creditsLine(
key: "credits_balance",
left: Infinity,
tone: "normal",
emphasis: dollars,
text: t("quota.credits.text", { usd: dollars }),
value: t("quota.credits.usd", { usd: dollars }),
short: dollars,
@@ -346,6 +352,7 @@ export function balanceLine(
key,
left,
tone: remaining <= 0 ? "danger" : "normal",
emphasis: remaining <= 0 ? undefined : value,
text:
remaining <= 0 ? t("quota.balanceUsedUp") : t("quota.balance", { value }),
detail,
@@ -8,6 +8,7 @@ import {
ResetSlot,
TONE_FILL,
TONE_TEXT,
TonedText,
useNow,
} from "@/components/quota/QuotaLines";
import {
@@ -53,7 +54,7 @@ interface AccountQuotaColumnProps {
/**
* 授权中心账号行右侧的额度(v7 Auth 画板):212 宽的额度条(条和数字都按「剩余」画,
* 平时 --chart-1;不到 10% 换 warning,用完换 danger)+ 84 宽的「x 分钟前 ↻」。
* 平时 --chart-1;不到 20% 换 warning,用完换 danger)+ 84 宽的「x 分钟前 ↻」。
* 打开页面时查一次,不轮询;↻ 手动重查。
*/
export function AccountQuotaColumn({
@@ -217,13 +218,13 @@ function QuotaRowCells({
/>
</span>
)}
<span
className={cn(
"w-14 shrink-0 whitespace-nowrap text-end tabular-nums",
line.tone === "normal" ? "text-fg-1" : TONE_TEXT[line.tone],
)}
>
{value}
{/* 旁边已有额度条,正常时数值不上色 */}
<span className="w-14 shrink-0 whitespace-nowrap text-end tabular-nums text-fg-1">
<TonedText
text={value}
line={line}
className={line.tone === "normal" ? "" : TONE_TEXT[line.tone]}
/>
</span>
</>
);
@@ -75,6 +75,11 @@ function renderQuota(
);
}
/** 额度句子里数值单独包了一层 span(只给数值上色),按整句文字找最内层的元素 */
const sentence = (text: string) => (_: string, node: Element | null) =>
node?.textContent === text &&
!Array.from(node.children).some((child) => child.textContent === text);
describe("Claude Fable subscription quota", () => {
it("pins the shortest window first and merges the rest into one line", () => {
renderQuota([
@@ -85,15 +90,15 @@ describe("Claude Fable subscription quota", () => {
resetsAt: "2026-09-12T00:00:00Z",
},
]);
// 第一行固定是 5 小时,哪怕它剩得最多;其余两档并成一行,快用完的那段单独加深
// 第一行固定是 5 小时,哪怕它剩得最多;其余两档并成一行,快用完的那段单独标橙
const lines = screen.getByRole("button").children;
expect(lines[0]).toHaveTextContent("5 小时剩余 88%");
expect(lines[1]).toHaveTextContent("每周 75% · Fable 5%");
expect(screen.getByText("每周 75%")).toHaveClass("text-fg-2");
expect(screen.getByText("Fable 5%")).toHaveClass(
"font-medium",
"text-fg-1",
);
// 只有数值上色,档名跟着外层灰字
expect(screen.getByText("75%")).toHaveClass("text-green-600");
expect(screen.getByText("5%")).toHaveClass("text-orange-500");
expect(screen.getByText("88%")).toHaveClass("text-green-600");
expect(screen.getByText("88%").parentElement).toHaveClass("text-fg-2");
// 重置倒计时直接写在每行后面:5 小时那行没有重置时间,留空占位;合并行写最近的那次
expect(screen.getByText("2d12h")).toBeInTheDocument();
expect(
@@ -137,20 +142,20 @@ describe("Claude Fable subscription quota", () => {
expect(
screen.getByRole("meter", { name: "5 小时: 剩余 88%" }),
).toHaveAttribute("aria-valuenow", "88");
expect(screen.getByText("已用完")).toHaveClass("text-danger-text");
expect(screen.getByText("已用完")).toHaveClass("text-red-500");
// 展开时重置时间直接写在数值后面
expect(screen.getByText("2d12h后重置")).toBeInTheDocument();
});
it("shows an unused Fable limit in the quiet color", () => {
it("shows an unused Fable limit in the normal color", () => {
renderQuota([{ name: "seven_day_fable", utilization: 0, resetsAt: null }]);
expect(screen.getByText("Fable 剩余 100%")).toHaveClass("text-fg-2");
expect(screen.getByText("100%")).toHaveClass("text-green-600");
});
it("keeps legacy quotas visible without inventing a Fable limit", () => {
renderQuota(baseTiers);
expect(screen.getByText("5 小时剩余 88%")).toBeInTheDocument();
expect(screen.getByText("每周剩余 75%")).toBeInTheDocument();
expect(screen.getByText(sentence("5 小时剩余 88%"))).toBeInTheDocument();
expect(screen.getByText(sentence("每周剩余 75%"))).toBeInTheDocument();
expect(screen.queryByText(/Fable/)).not.toBeInTheDocument();
});
@@ -161,7 +166,7 @@ describe("Claude Fable subscription quota", () => {
])("localizes the Fable line in %s", async (language, text) => {
await i18n.changeLanguage(language);
renderQuota([{ name: "seven_day_fable", utilization: 37, resetsAt: null }]);
expect(screen.getByText(text)).toBeInTheDocument();
expect(screen.getByText(sentence(text))).toBeInTheDocument();
});
});
@@ -203,8 +208,8 @@ describe("ChatGPT saved limit resets", () => {
it("rides along with the weekly tier on the card", () => {
codex(true);
expect(screen.getByText("5 小时剩余 88%")).toBeInTheDocument();
expect(screen.getByText("重置 2 次")).toBeInTheDocument();
expect(screen.getByText(sentence("5 小时剩余 88%"))).toBeInTheDocument();
expect(screen.getByText(sentence("重置 2 次"))).toBeInTheDocument();
expect(
screen
.getByRole("button", { name: /点击重新查询/ })
@@ -271,15 +276,15 @@ describe("ChatGPT Credits balance", () => {
it("stays off the card while no tier is used up", () => {
renderQuota(baseTiers, true, credits);
expect(screen.queryByText(/\$2500/)).not.toBeInTheDocument();
expect(screen.getByText("重置 2 次")).toBeInTheDocument();
expect(screen.getByText(sentence("重置 2 次"))).toBeInTheDocument();
});
it("shows up on the card in dollars once a tier is used up, ahead of the resets", () => {
renderQuota(usedUp, true, credits);
expect(screen.getByText("$2500")).toBeInTheDocument();
expect(screen.getByText("每周 75%")).toBeInTheDocument();
expect(screen.getByText(sentence("每周 75%"))).toBeInTheDocument();
// 合并行只放得下两段:余额排在重置次数前面
expect(screen.queryByText("重置 2 次")).not.toBeInTheDocument();
expect(screen.queryByText(sentence("重置 2 次"))).not.toBeInTheDocument();
// 没有下拉段时整列是一个按钮,悬停说明里写全余额
expect(
screen.getByRole("button", { name: /\$2500/ }).getAttribute("title"),
@@ -293,7 +298,7 @@ describe("ChatGPT Credits balance", () => {
credits,
);
expect(screen.getByText("$2500")).toBeInTheDocument();
expect(screen.getByText("重置 2 次")).toBeInTheDocument();
expect(screen.getByText(sentence("重置 2 次"))).toBeInTheDocument();
});
it("is listed under the resets when they drop down on the card", () => {
+2 -2
View File
@@ -73,8 +73,8 @@ describe("quota lines", () => {
"每周 Opus",
).text,
).toBe("每周 Opus 剩余 50%");
expect(toneForLeft(10)).toBe("normal");
expect(toneForLeft(9)).toBe("warning");
expect(toneForLeft(20)).toBe("normal");
expect(toneForLeft(19)).toBe("warning");
});
it("only colors a balance once it runs out, even when nearly gone", () => {