-
-
-
- {creditsEnabled ?
setWalletOpen(false)} /> : null}
- {!collapsed ? : }
+
+
+
+
+
+
+
+ {!collapsed ?
:
}
+
);
}
diff --git a/web/src/components/layout/workspace-sidebar-storage-meter.tsx b/web/src/components/layout/workspace-sidebar-storage-meter.tsx
new file mode 100644
index 000000000..a32a2bf9d
--- /dev/null
+++ b/web/src/components/layout/workspace-sidebar-storage-meter.tsx
@@ -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 (
+
+ {collapsed ? (
+
+ );
+ }
+
+ return (
+ preloadWorkspaceRoute("/assets")}
+ onPointerEnter={() => preloadWorkspaceRoute("/assets")}
+ >
+ {collapsed ? null : (
+
+
+ {usedText}
+ {totalText ? {totalText} : null}
+
+ {remainingText ? {remainingText} : null}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/hooks/use-account-file-storage-usage.ts b/web/src/hooks/use-account-file-storage-usage.ts
new file mode 100644
index 000000000..a0a0867c9
--- /dev/null
+++ b/web/src/hooks/use-account-file-storage-usage.ts
@@ -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",
+ });
+}
diff --git a/web/src/lib/account-storage-usage.ts b/web/src/lib/account-storage-usage.ts
new file mode 100644
index 000000000..87fcf6357
--- /dev/null
+++ b/web/src/lib/account-storage-usage.ts
@@ -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,
+ };
+}
diff --git a/web/src/lib/sidebar-checkin.ts b/web/src/lib/sidebar-checkin.ts
new file mode 100644
index 000000000..78d89b692
--- /dev/null
+++ b/web/src/lib/sidebar-checkin.ts
@@ -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}加油站`;
+}
diff --git a/web/src/pages/assets/asset-storage-usage.tsx b/web/src/pages/assets/asset-storage-usage.tsx
index b1694627f..6f949a0da 100644
--- a/web/src/pages/assets/asset-storage-usage.tsx
+++ b/web/src/pages/assets/asset-storage-usage.tsx
@@ -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 (
-
+
@@ -26,7 +19,7 @@ export function AssetStorageUsage() {
{usage ? (
<>
- {storageBytes(usage.usedBytes)} / {storageBytes(usage.totalBytes)}
+ {formatStorageBytes(usage.usedBytes)} / {formatStorageBytes(usage.totalBytes)}
-
+
- {percentLabel}
+ {meter.percentLabel}
>
) : query.isError ? (
@@ -54,7 +47,3 @@ export function AssetStorageUsage() {
);
}
-
-function storageBytes(value: number) {
- return formatBytes(value) || "0 B";
-}
diff --git a/web/test/workspace-sidebar-checkin.test.ts b/web/test/workspace-sidebar-checkin.test.ts
new file mode 100644
index 000000000..e4f245434
--- /dev/null
+++ b/web/test/workspace-sidebar-checkin.test.ts
@@ -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("");
+ const storageIndex = sidebar.indexOf("");
+ 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);
+ });
+});
diff --git a/web/test/workspace-sidebar-storage.test.ts b/web/test/workspace-sidebar-storage.test.ts
new file mode 100644
index 000000000..a89108a43
--- /dev/null
+++ b/web/test/workspace-sidebar-storage.test.ts
@@ -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("");
+ 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");
+ });
+});