mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
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:
1 parent
b4a079430c
commit
0be33a8442
7 files changed
+101
-62
No files matched your search
@@ -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));
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ function planLine(
|
||||
return {
|
||||
key,
|
||||
left: Infinity,
|
||||
tone: "normal",
|
||||
tone: "plain",
|
||||
text: `${t("usage.used")} ${amount(data.used)}`,
|
||||
detail: detail || undefined,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user