feat(web): 工作区侧栏 - 账号容量进度与签到加油站
头像上方展示已用/剩余/总容量,积分开启且未领取时固定显示品牌加油站卡片,当天领取后消失。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
72
web/src/components/layout/workspace-sidebar-checkin.tsx
Normal file
72
web/src/components/layout/workspace-sidebar-checkin.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { App } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { sidebarCheckinTitle, shouldShowSidebarCheckin } from "@/lib/sidebar-checkin";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ApiError } from "@/services/api/request";
|
||||
import { checkinCredits } from "@/services/api/wallet";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
export function WorkspaceSidebarCheckin({ collapsed }: { collapsed: boolean }) {
|
||||
const user = useUserStore((state) => state.user);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const { policy, refresh } = useWalletBalance(user?.id, Boolean(user) && creditsEnabled);
|
||||
const { message } = App.useApp();
|
||||
const [claiming, setClaiming] = useState(false);
|
||||
|
||||
const visible = shouldShowSidebarCheckin({
|
||||
creditsEnabled,
|
||||
checkinBonusMicrocredits: policy?.checkinBonusMicrocredits,
|
||||
checkedInToday: policy?.checkedInToday,
|
||||
});
|
||||
if (!user || !visible || !policy) return null;
|
||||
|
||||
const amount = formatCredits(policy.checkinBonusMicrocredits, 2);
|
||||
const title = sidebarCheckinTitle(brandName);
|
||||
const summary = `${title},今日可领 ${amount} 积分`;
|
||||
|
||||
const claim = async () => {
|
||||
if (claiming) return;
|
||||
setClaiming(true);
|
||||
try {
|
||||
await checkinCredits();
|
||||
window.dispatchEvent(new CustomEvent("wallet:updated"));
|
||||
await refresh();
|
||||
message.success("签到成功,积分已到账");
|
||||
} catch (error) {
|
||||
const alreadyClaimed = error instanceof ApiError && error.status === 409;
|
||||
window.dispatchEvent(new CustomEvent("wallet:updated"));
|
||||
await refresh();
|
||||
if (alreadyClaimed) return;
|
||||
message.error(error instanceof Error ? error.message : "领取失败");
|
||||
} finally {
|
||||
setClaiming(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button type="button" className="app-workspace-sidebar-checkin is-collapsed" title={summary} aria-label={`立即领取今日 ${amount} 积分`} disabled={claiming} onClick={() => void claim()}>
|
||||
{claiming ? "…" : "领"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="app-workspace-sidebar-checkin" aria-label={summary}>
|
||||
<div className="app-workspace-sidebar-checkin-copy">
|
||||
<strong className="app-workspace-sidebar-checkin-title">{title}</strong>
|
||||
<span className="app-workspace-sidebar-checkin-offer">
|
||||
今日可领<em>{amount}</em>积分
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className={cn("app-workspace-sidebar-checkin-claim", claiming && "is-loading")} disabled={claiming} aria-label={`立即领取 ${amount} 积分`} onClick={() => void claim()}>
|
||||
{claiming ? "领取中" : "立即领取"}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
|
||||
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { WorkspaceAccountCard } from "./workspace-account-card";
|
||||
import { WorkspaceWalletModal } from "./workspace-wallet-modal";
|
||||
import { WorkspaceSidebarCheckin } from "./workspace-sidebar-checkin";
|
||||
import { WorkspaceSidebarStorageMeter } from "./workspace-sidebar-storage-meter";
|
||||
import { openWorkspaceWallet } from "@/lib/workspace-wallet";
|
||||
|
||||
export type WorkspaceNavItem = {
|
||||
id: string;
|
||||
@@ -62,8 +64,6 @@ function buildNav(features: FeatureAvailability, isAdmin: boolean): { groups: Wo
|
||||
function WorkspaceSidebarProfile({ collapsed, user }: { collapsed: boolean; user: NonNullable<ReturnType<typeof useUserStore.getState>["user"]> | null }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [walletOpen, setWalletOpen] = useState(false);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const avatarUrl = /^https?:\/\//i.test(user?.avatarUrl || "") ? user?.avatarUrl : "";
|
||||
const profileName = user?.displayName || user?.username || "未登录";
|
||||
|
||||
@@ -74,18 +74,21 @@ function WorkspaceSidebarProfile({ collapsed, user }: { collapsed: boolean; user
|
||||
}
|
||||
|
||||
const avatar = avatarUrl && !failed ? <img src={avatarUrl} alt="" referrerPolicy="no-referrer" onError={() => setFailed(true)} /> : <CircleUserRound aria-hidden />;
|
||||
const content = <WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); setWalletOpen(true); }} />;
|
||||
const content = <WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); openWorkspaceWallet(); }} />;
|
||||
|
||||
return (
|
||||
<div className={cn("app-workspace-sidebar-profile-row", collapsed && "is-collapsed")}>
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" rootClassName="workspace-account-popover" content={content}>
|
||||
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
|
||||
<span className="app-workspace-sidebar-profile-avatar">{avatar}</span>
|
||||
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}
|
||||
</button>
|
||||
</Popover>
|
||||
{creditsEnabled ? <WorkspaceWalletModal open={walletOpen} onClose={() => setWalletOpen(false)} /> : null}
|
||||
{!collapsed ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-sidebar-notification" /> : <span className="app-workspace-sidebar-notification-spacer" aria-hidden />}
|
||||
<div className={cn("app-workspace-sidebar-account", collapsed && "is-collapsed")}>
|
||||
<WorkspaceSidebarCheckin collapsed={collapsed} />
|
||||
<WorkspaceSidebarStorageMeter collapsed={collapsed} />
|
||||
<div className={cn("app-workspace-sidebar-profile-row", collapsed && "is-collapsed")}>
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" rootClassName="workspace-account-popover" content={content}>
|
||||
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
|
||||
<span className="app-workspace-sidebar-profile-avatar">{avatar}</span>
|
||||
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}
|
||||
</button>
|
||||
</Popover>
|
||||
{!collapsed ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-sidebar-notification" /> : <span className="app-workspace-sidebar-notification-spacer" aria-hidden />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { useAccountFileStorageUsage } from "@/hooks/use-account-file-storage-usage";
|
||||
import { accountStorageMeter } from "@/lib/account-storage-usage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
|
||||
|
||||
export function WorkspaceSidebarStorageMeter({ collapsed }: { collapsed: boolean }) {
|
||||
const query = useAccountFileStorageUsage();
|
||||
const meter = accountStorageMeter(query.data);
|
||||
const usedText = query.data ? `已用 ${meter.usedLabel}` : query.isError ? "容量暂不可用" : "正在统计容量";
|
||||
const remainingText = query.data ? (meter.full ? "容量已满" : `剩余 ${meter.remainingLabel}`) : "";
|
||||
const totalText = query.data ? `共 ${meter.totalLabel}` : "";
|
||||
const summary = query.data ? `${usedText},${remainingText},${totalText}` : usedText;
|
||||
|
||||
if (query.isError && !query.data) {
|
||||
return (
|
||||
<div className={cn("app-workspace-sidebar-storage is-error", collapsed && "is-collapsed")}>
|
||||
{collapsed ? (
|
||||
<button type="button" title="容量统计暂时不可用,点击重试" aria-label="重试加载账号容量" onClick={() => void query.refetch()} />
|
||||
) : (
|
||||
<>
|
||||
<span>容量暂不可用</span>
|
||||
<button type="button" onClick={() => void query.refetch()}>
|
||||
重试
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/assets"
|
||||
className={cn(
|
||||
"app-workspace-sidebar-storage",
|
||||
collapsed && "is-collapsed",
|
||||
meter.tone === "warn" && "is-warn",
|
||||
meter.tone === "critical" && "is-critical",
|
||||
query.data?.usedBytes ? "has-usage" : null,
|
||||
query.isPending && !query.data && "is-pending",
|
||||
)}
|
||||
title={`${summary}。包含素材文件和 Agent 会话附件`}
|
||||
aria-label={`账号容量,${summary}`}
|
||||
aria-busy={query.isPending && !query.data}
|
||||
onFocus={() => preloadWorkspaceRoute("/assets")}
|
||||
onPointerEnter={() => preloadWorkspaceRoute("/assets")}
|
||||
>
|
||||
{collapsed ? null : (
|
||||
<span className="app-workspace-sidebar-storage-copy">
|
||||
<span className="app-workspace-sidebar-storage-meta">
|
||||
<span className="app-workspace-sidebar-storage-used">{usedText}</span>
|
||||
{totalText ? <span className="app-workspace-sidebar-storage-total">{totalText}</span> : null}
|
||||
</span>
|
||||
{remainingText ? <span className="app-workspace-sidebar-storage-remain">{remainingText}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="app-workspace-sidebar-storage-track"
|
||||
role="progressbar"
|
||||
aria-label="账号文件容量使用进度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={query.data?.totalBytes ?? 0}
|
||||
aria-valuenow={query.data ? Math.min(query.data.usedBytes, query.data.totalBytes) : 0}
|
||||
aria-valuetext={summary}
|
||||
>
|
||||
<span style={{ width: `${meter.percent}%` }} />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
14
web/src/hooks/use-account-file-storage-usage.ts
Normal file
14
web/src/hooks/use-account-file-storage-usage.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { accountFileStorageUsageQueryKey } from "@/lib/account-storage-usage";
|
||||
import { getAccountFileStorageUsage } from "@/services/api/resources";
|
||||
|
||||
export function useAccountFileStorageUsage(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: accountFileStorageUsageQueryKey,
|
||||
queryFn: getAccountFileStorageUsage,
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
}
|
||||
41
web/src/lib/account-storage-usage.ts
Normal file
41
web/src/lib/account-storage-usage.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
import type { AccountFileStorageUsage } from "@/services/api/resources";
|
||||
|
||||
export const accountFileStorageUsageQueryKey = ["account-file-storage-usage"] as const;
|
||||
|
||||
export type AccountStorageTone = "ok" | "warn" | "critical";
|
||||
|
||||
export type AccountStorageMeter = {
|
||||
percent: number;
|
||||
remainingBytes: number;
|
||||
usedLabel: string;
|
||||
totalLabel: string;
|
||||
remainingLabel: string;
|
||||
percentLabel: string;
|
||||
tone: AccountStorageTone;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
export function formatStorageBytes(value: number) {
|
||||
return formatBytes(value) || "0 B";
|
||||
}
|
||||
|
||||
export function accountStorageMeter(usage?: AccountFileStorageUsage | null): AccountStorageMeter {
|
||||
const usedBytes = usage && Number.isFinite(usage.usedBytes) ? Math.max(0, usage.usedBytes) : 0;
|
||||
const totalBytes = usage && Number.isFinite(usage.totalBytes) ? Math.max(0, usage.totalBytes) : 0;
|
||||
const remainingBytes = Math.max(0, totalBytes - usedBytes);
|
||||
const percent = totalBytes > 0 ? Math.min(100, (usedBytes / totalBytes) * 100) : 0;
|
||||
const full = totalBytes > 0 && usedBytes >= totalBytes;
|
||||
const tone: AccountStorageTone = full || percent >= 90 ? "critical" : percent >= 70 ? "warn" : "ok";
|
||||
|
||||
return {
|
||||
percent,
|
||||
remainingBytes,
|
||||
usedLabel: formatStorageBytes(usedBytes),
|
||||
totalLabel: formatStorageBytes(totalBytes),
|
||||
remainingLabel: formatStorageBytes(remainingBytes),
|
||||
percentLabel: usedBytes && percent < 0.1 ? "<0.1%" : `${Math.round(percent * 10) / 10}%`,
|
||||
tone,
|
||||
full,
|
||||
};
|
||||
}
|
||||
12
web/src/lib/sidebar-checkin.ts
Normal file
12
web/src/lib/sidebar-checkin.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function shouldShowSidebarCheckin(input: {
|
||||
creditsEnabled?: boolean;
|
||||
checkinBonusMicrocredits?: number | null;
|
||||
checkedInToday?: boolean;
|
||||
}) {
|
||||
return Boolean(input.creditsEnabled && (input.checkinBonusMicrocredits || 0) > 0 && !input.checkedInToday);
|
||||
}
|
||||
|
||||
export function sidebarCheckinTitle(brandName: string) {
|
||||
const name = brandName.trim() || "影策";
|
||||
return `${name}加油站`;
|
||||
}
|
||||
@@ -1,24 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HardDrive } from "lucide-react";
|
||||
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
import { getAccountFileStorageUsage } from "@/services/api/resources";
|
||||
import { useAccountFileStorageUsage } from "@/hooks/use-account-file-storage-usage";
|
||||
import { accountFileStorageUsageQueryKey, accountStorageMeter, formatStorageBytes } from "@/lib/account-storage-usage";
|
||||
|
||||
export const assetStorageUsageQueryKey = ["account-file-storage-usage"] as const;
|
||||
export const assetStorageUsageQueryKey = accountFileStorageUsageQueryKey;
|
||||
|
||||
export function AssetStorageUsage() {
|
||||
const query = useQuery({
|
||||
queryKey: assetStorageUsageQueryKey,
|
||||
queryFn: getAccountFileStorageUsage,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
const query = useAccountFileStorageUsage();
|
||||
const usage = query.data;
|
||||
const percent = usage?.totalBytes ? Math.min(100, (usage.usedBytes / usage.totalBytes) * 100) : 0;
|
||||
const percentLabel = usage?.usedBytes && percent < 0.1 ? "<0.1%" : `${Math.round(percent * 10) / 10}%`;
|
||||
const full = Boolean(usage && usage.usedBytes >= usage.totalBytes);
|
||||
const meter = accountStorageMeter(usage);
|
||||
|
||||
return (
|
||||
<section className={`assets-storage-usage${full ? " is-full" : ""}${usage?.usedBytes ? " has-usage" : ""}`} aria-label="账号文件容量" aria-busy={query.isPending} title="包含素材文件和 Agent 会话附件">
|
||||
<section className={`assets-storage-usage${meter.full ? " is-full" : ""}${usage?.usedBytes ? " has-usage" : ""}`} aria-label="账号文件容量" aria-busy={query.isPending} title="包含素材文件和 Agent 会话附件">
|
||||
<span className="assets-storage-usage-icon" aria-hidden="true">
|
||||
<HardDrive />
|
||||
</span>
|
||||
@@ -26,7 +19,7 @@ export function AssetStorageUsage() {
|
||||
{usage ? (
|
||||
<>
|
||||
<span className="assets-storage-usage-value">
|
||||
{storageBytes(usage.usedBytes)} / {storageBytes(usage.totalBytes)}
|
||||
{formatStorageBytes(usage.usedBytes)} / {formatStorageBytes(usage.totalBytes)}
|
||||
</span>
|
||||
<span
|
||||
className="assets-storage-usage-track"
|
||||
@@ -35,11 +28,11 @@ export function AssetStorageUsage() {
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={usage.totalBytes}
|
||||
aria-valuenow={Math.min(usage.usedBytes, usage.totalBytes)}
|
||||
aria-valuetext={`已使用 ${storageBytes(usage.usedBytes)},总容量 ${storageBytes(usage.totalBytes)}`}
|
||||
aria-valuetext={`已使用 ${formatStorageBytes(usage.usedBytes)},总容量 ${formatStorageBytes(usage.totalBytes)}`}
|
||||
>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
<span style={{ width: `${meter.percent}%` }} />
|
||||
</span>
|
||||
<span className="assets-storage-usage-percent">{percentLabel}</span>
|
||||
<span className="assets-storage-usage-percent">{meter.percentLabel}</span>
|
||||
</>
|
||||
) : query.isError ? (
|
||||
<span className="assets-storage-usage-status">
|
||||
@@ -54,7 +47,3 @@ export function AssetStorageUsage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function storageBytes(value: number) {
|
||||
return formatBytes(value) || "0 B";
|
||||
}
|
||||
|
||||
43
web/test/workspace-sidebar-checkin.test.ts
Normal file
43
web/test/workspace-sidebar-checkin.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { sidebarCheckinTitle, shouldShowSidebarCheckin } from "../src/lib/sidebar-checkin";
|
||||
|
||||
describe("sidebar checkin offer", () => {
|
||||
test("shows only when credits and a checkin bonus are enabled and today is unclaimed", () => {
|
||||
expect(shouldShowSidebarCheckin({ creditsEnabled: true, checkinBonusMicrocredits: 100_000_000, checkedInToday: false })).toBe(true);
|
||||
expect(shouldShowSidebarCheckin({ creditsEnabled: false, checkinBonusMicrocredits: 100_000_000, checkedInToday: false })).toBe(false);
|
||||
expect(shouldShowSidebarCheckin({ creditsEnabled: true, checkinBonusMicrocredits: 0, checkedInToday: false })).toBe(false);
|
||||
expect(shouldShowSidebarCheckin({ creditsEnabled: true, checkinBonusMicrocredits: 100_000_000, checkedInToday: true })).toBe(false);
|
||||
});
|
||||
|
||||
test("uses the configured brand name instead of Buddy", () => {
|
||||
expect(sidebarCheckinTitle("影策")).toBe("影策加油站");
|
||||
expect(sidebarCheckinTitle(" 本地工作室 ")).toBe("本地工作室加油站");
|
||||
expect(sidebarCheckinTitle("")).toBe("影策加油站");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace sidebar checkin card", () => {
|
||||
test("sits above account storage and uses the claim card colors without a light-blue stage", () => {
|
||||
const sidebar = readFileSync(resolve(import.meta.dir, "../src/components/layout/workspace-sidebar-nav.tsx"), "utf8");
|
||||
const card = readFileSync(resolve(import.meta.dir, "../src/components/layout/workspace-sidebar-checkin.tsx"), "utf8");
|
||||
const css = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
|
||||
const checkinIndex = sidebar.indexOf("<WorkspaceSidebarCheckin collapsed={collapsed} />");
|
||||
const storageIndex = sidebar.indexOf("<WorkspaceSidebarStorageMeter collapsed={collapsed} />");
|
||||
const start = css.indexOf(".app-user-workspace .app-workspace-sidebar-checkin {");
|
||||
const end = css.indexOf(".app-user-workspace .app-workspace-sidebar-storage {", start);
|
||||
const block = css.slice(start, end);
|
||||
|
||||
expect(checkinIndex).toBeGreaterThan(-1);
|
||||
expect(storageIndex).toBeGreaterThan(checkinIndex);
|
||||
expect(card).toContain("立即领取");
|
||||
expect(card).toContain("今日可领");
|
||||
expect(card).toContain("checkinCredits()");
|
||||
expect(block).toContain("background: #fff;");
|
||||
expect(block).toContain("background: #171717;");
|
||||
expect(block).toContain("color: #12c8a0;");
|
||||
expect(block).not.toMatch(/#(?:dbeafe|e8f1ff|eef4ff|e6f0ff|d6e8ff)/i);
|
||||
});
|
||||
});
|
||||
66
web/test/workspace-sidebar-storage.test.ts
Normal file
66
web/test/workspace-sidebar-storage.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { accountStorageMeter } from "../src/lib/account-storage-usage";
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
describe("account storage meter", () => {
|
||||
test("exposes remaining capacity and total capacity", () => {
|
||||
const meter = accountStorageMeter({ usedBytes: 2 * GB, totalBytes: 10 * GB });
|
||||
|
||||
expect(meter.usedLabel).toBe("2.0 GB");
|
||||
expect(meter.remainingLabel).toBe("8.0 GB");
|
||||
expect(meter.totalLabel).toBe("10 GB");
|
||||
expect(meter.percent).toBe(20);
|
||||
expect(meter.tone).toBe("ok");
|
||||
expect(meter.full).toBe(false);
|
||||
});
|
||||
|
||||
test("warns when usage reaches seventy percent", () => {
|
||||
const meter = accountStorageMeter({ usedBytes: 7 * GB, totalBytes: 10 * GB });
|
||||
|
||||
expect(meter.percent).toBe(70);
|
||||
expect(meter.tone).toBe("warn");
|
||||
expect(meter.remainingLabel).toBe("3.0 GB");
|
||||
});
|
||||
|
||||
test("marks full accounts as critical with zero remaining", () => {
|
||||
const meter = accountStorageMeter({ usedBytes: 10 * GB, totalBytes: 10 * GB });
|
||||
|
||||
expect(meter.full).toBe(true);
|
||||
expect(meter.tone).toBe("critical");
|
||||
expect(meter.remainingBytes).toBe(0);
|
||||
expect(meter.remainingLabel).toBe("0 B");
|
||||
expect(meter.percent).toBe(100);
|
||||
});
|
||||
|
||||
test("treats missing usage as empty instead of overflowing", () => {
|
||||
const meter = accountStorageMeter();
|
||||
|
||||
expect(meter.percent).toBe(0);
|
||||
expect(meter.remainingLabel).toBe("0 B");
|
||||
expect(meter.totalLabel).toBe("0 B");
|
||||
expect(meter.tone).toBe("ok");
|
||||
expect(meter.full).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace sidebar storage meter", () => {
|
||||
test("sits above the signed-in profile and shows used, remaining and total", () => {
|
||||
const sidebar = readFileSync(resolve(import.meta.dir, "../src/components/layout/workspace-sidebar-nav.tsx"), "utf8");
|
||||
const meter = readFileSync(resolve(import.meta.dir, "../src/components/layout/workspace-sidebar-storage-meter.tsx"), "utf8");
|
||||
const css = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
|
||||
|
||||
expect(sidebar).toContain("<WorkspaceSidebarStorageMeter collapsed={collapsed} />");
|
||||
expect(sidebar.indexOf("WorkspaceSidebarStorageMeter")).toBeLessThan(sidebar.indexOf("app-workspace-sidebar-profile-row"));
|
||||
expect(meter).toContain("已用 ${meter.usedLabel}");
|
||||
expect(meter).toContain("剩余 ${meter.remainingLabel}");
|
||||
expect(meter).toContain("共 ${meter.totalLabel}");
|
||||
expect(meter).toContain('to="/assets"');
|
||||
expect(css).toMatch(/\.app-workspace-sidebar-storage-used\s*\{[^}]*font-variant-numeric:\s*tabular-nums/s);
|
||||
expect(css).toContain(".app-workspace-sidebar-storage.is-warn");
|
||||
expect(css).toContain(".app-workspace-sidebar-storage.is-critical");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user