mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-30 17:58:00 +08:00
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:
@@ -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": "图片预览",
|
||||
|
||||
Reference in New Issue
Block a user