diff --git a/web/src/components/layout/workspace-sidebar-checkin.tsx b/web/src/components/layout/workspace-sidebar-checkin.tsx new file mode 100644 index 000000000..134d7ca34 --- /dev/null +++ b/web/src/components/layout/workspace-sidebar-checkin.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/web/src/components/layout/workspace-sidebar-nav.tsx b/web/src/components/layout/workspace-sidebar-nav.tsx index 6fb39e199..9d99ded75 100644 --- a/web/src/components/layout/workspace-sidebar-nav.tsx +++ b/web/src/components/layout/workspace-sidebar-nav.tsx @@ -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["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 ? setFailed(true)} /> : ; - const content = setMenuOpen(false)} onWallet={() => { setMenuOpen(false); setWalletOpen(true); }} />; + const content = setMenuOpen(false)} onWallet={() => { setMenuOpen(false); openWorkspaceWallet(); }} />; return ( -
- - - - {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"); + }); +});