feat(client): surface knowledge-space upload quota in the account menu

Users only learned about the per-role knowledge-space file-upload cap
after hitting it. Surface it proactively as a "已使用 X GB / 上限 Y GB"
meter inside the user account popup (UserPopMenu), driven by the existing
GET /api/v1/quota/effective (knowledge_space_file — values already in GB,
effective === -1 = unlimited).

- New StorageQuotaBar: thin Progress bar + usage text; turns orange at
  80% and red at 100%; hides the bar when unlimited; renders nothing until
  the first fetch resolves.
- useEffectiveQuota now backed by react-query (same return shape) so all
  consumers share one cached fetch and the meter refreshes on window focus.
- i18n keys (en / zh-Hans / ja) for the meter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
LineWalker
2026-07-09 00:08:27 +08:00
parent 98b48c93a7
commit 5d23e36a80
6 changed files with 121 additions and 21 deletions
@@ -0,0 +1,74 @@
import { useMemo } from "react";
import { Progress } from "~/components/ui/Progress";
import { useEffectiveQuota } from "~/hooks/useEffectiveQuota";
import { useLocalize } from "~/hooks";
import { cn } from "~/utils";
/** Bar turns orange at 80% of the cap and red once the cap is reached. */
const WARN_PCT = 80;
// Trim a GB value for display: 2 decimals, then drop trailing zeros
// (0.09 → "0.09", 82.4 → "82.4", 100 → "100", 2.5 → "2.5").
function trimGb(gb: number): string {
if (!Number.isFinite(gb)) return "0";
return gb.toFixed(2).replace(/\.?0+$/, "");
}
interface StorageQuotaBarProps {
className?: string;
}
/**
* Knowledge-space file-upload quota meter (已使用 X GB / 上限 Y GB), rendered
* inside the user account popup so users can see their storage budget before
* they hit the upload cap instead of only learning about it via an error toast.
*
* Data comes from /api/v1/quota/effective via useEffectiveQuota — for
* `knowledge_space_file` both `user_used` and `effective` are already in GB, and
* `effective === -1` means unlimited (no progress bar shown).
*/
export function StorageQuotaBar({ className }: StorageQuotaBarProps) {
const localize = useLocalize();
const { quotas, loading } = useEffectiveQuota();
const item = quotas["knowledge_space_file"];
const used = Number(item?.user_used) || 0;
const total = Number(item?.effective);
const unlimited = total === -1;
const percent = useMemo(() => {
if (unlimited) return 0;
if (total > 0) return Math.min(100, Math.max(0, (used / total) * 100));
// total === 0 = prohibited: full bar once anything is used.
return used > 0 ? 100 : 0;
}, [used, total, unlimited]);
// Don't flash an empty bar before the first fetch resolves.
if (loading || !item) return null;
// Static class strings (not interpolated) so Tailwind's JIT keeps them.
// `[&>div]` targets the Radix Progress indicator, overriding its bg-primary.
const indicatorClass =
percent >= 100
? "[&>div]:bg-[#f53f3f]"
: percent >= WARN_PCT
? "[&>div]:bg-[#ff7d00]"
: "[&>div]:bg-blue-500";
const usedText = trimGb(used);
const label = unlimited
? localize("com_knowledge.storage_quota_unlimited", { used: usedText })
: localize("com_knowledge.storage_quota_used", { used: usedText, total: trimGb(total) });
return (
<div className={cn("px-3 py-2", className)}>
<div className="mb-1.5 text-[12px] text-[#86909c]">
{localize("com_knowledge.storage_quota_title")}
</div>
{!unlimited && (
<Progress value={percent} className={cn("h-1.5 bg-[#f2f3f5]", indicatorClass)} />
)}
<div className={cn("text-[12px] text-[#4e5969]", !unlimited && "mt-1.5")}>{label}</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { EffectiveQuotaItem, getEffectiveQuotaApi } from "~/api/quota";
export type QuotaResource =
@@ -7,34 +8,45 @@ export type QuotaResource =
| "knowledge_space_subscribe"
| "knowledge_space_file";
/**
* Shared query key so every consumer reads one cached fetch and a post-upload
* `queryClient.invalidateQueries(EFFECTIVE_QUOTA_QUERY_KEY)` refreshes them all.
*/
export const EFFECTIVE_QUOTA_QUERY_KEY = ["quota", "effective"] as const;
/**
* Reads the current user's effective quota (role + tenant) from
* /api/v1/quota/effective so callers stop hard-coding limits. `effective === -1`
* means unlimited. The backend stays the authoritative enforcer; this hook only
* powers upfront UX checks, so an unknown / not-yet-loaded quota never blocks.
*
* Backed by react-query: all consumers share one cached request, and the default
* refetch-on-window-focus keeps the storage bar fresh after uploads.
*/
export function useEffectiveQuota() {
const [quotas, setQuotas] = useState<Record<string, EffectiveQuotaItem>>({});
const [loading, setLoading] = useState(true);
const {
data: items = [],
isLoading,
refetch,
} = useQuery({
queryKey: EFFECTIVE_QUOTA_QUERY_KEY,
queryFn: getEffectiveQuotaApi,
// A stale-but-usable quota is fine for upfront UX; avoid refetch storms.
staleTime: 30_000,
});
const quotas = useMemo(() => {
const map: Record<string, EffectiveQuotaItem> = {};
items.forEach((item) => {
map[item.resource_type] = item;
});
return map;
}, [items]);
// Preserve the previous `refresh()` contract: fire a refetch, resolve to void.
const refresh = useCallback(async () => {
try {
const items = await getEffectiveQuotaApi();
const map: Record<string, EffectiveQuotaItem> = {};
items.forEach((item) => {
map[item.resource_type] = item;
});
setQuotas(map);
} catch (error) {
console.error("Failed to fetch effective quota:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
await refetch();
}, [refetch]);
// -1 (unlimited) when the quota is unknown, so an unloaded quota never blocks.
const getEffective = useCallback(
@@ -51,5 +63,5 @@ export function useEffectiveQuota() {
[quotas],
);
return { quotas, loading, refresh, getEffective, isOverQuota };
return { quotas, loading: isLoading, refresh, getEffective, isOverQuota };
}
@@ -4,6 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent } from "r
import { useRecoilState } from "recoil";
import { AccountInfoDialog } from "~/components/AccountInfoDialog";
import { NotificationsDialog } from "~/components/NotificationsDialog";
import { StorageQuotaBar } from "~/components/StorageQuotaBar";
import { ApprovalCenterDialog } from "~/components/approval/ApprovalCenterDialog";
import { Avatar, AvatarImage, AvatarName } from "~/components/ui/Avatar";
import {
@@ -174,6 +175,8 @@ function UserPopMenuDrawer() {
</button>
</div>
<StorageQuotaBar />
<div className="mx-3 my-1 h-px bg-gray-100" />
<button
@@ -443,6 +446,8 @@ function UserPopMenuRail() {
<span className={cn(actionMenuLabelClassName, "font-medium")}>{displayName}</span>
</div>
<StorageQuotaBar />
<ActionMenuDivider />
{/* 审批中心:合并原「我的待办 / 我的申请」入口,默认进「我的审批」子 tab */}
@@ -1632,6 +1632,9 @@
"get_download_link_failed": "Failed to get download link",
"go_to_square": "Go to square",
"go_to_knowledge_square": "Go to knowledge square",
"storage_quota_title": "Storage",
"storage_quota_used": "{{used}} GB / {{total}} GB used",
"storage_quota_unlimited": "{{used}} GB used / Unlimited",
"history_chat": "History session",
"html_preview": "HTML Preview",
"image_preview": "Image Preview",
@@ -1556,6 +1556,9 @@
"get_download_link_failed": "ダウンロードリンクの取得に失敗しました",
"go_to_square": "広場へ行く",
"go_to_knowledge_square": "ナレッジ広場へ行く",
"storage_quota_title": "ストレージ容量",
"storage_quota_used": "{{used}} GB / {{total}} GB 使用中",
"storage_quota_unlimited": "{{used}} GB 使用中 / 無制限",
"history_chat": "履歴セッション",
"html_preview": "HTMLプレビュー",
"image_preview": "画像プレビュー",
@@ -1559,6 +1559,9 @@
"get_download_link_failed": "下载链接获取失败",
"go_to_square": "前往广场",
"go_to_knowledge_square": "前往知识广场",
"storage_quota_title": "知识空间容量",
"storage_quota_used": "已使用 {{used}} GB / {{total}} GB",
"storage_quota_unlimited": "已使用 {{used}} GB / 无限制",
"history_chat": "历史会话",
"html_preview": "HTML 预览",
"image_preview": "图片预览",