feat(web): 工作区侧栏 - 账号容量进度与签到加油站

头像上方展示已用/剩余/总容量,积分开启且未领取时固定显示品牌加油站卡片,当天领取后消失。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-17 23:32:34 +08:00
parent 21e7dbb2b8
commit 67b53b45e4
9 changed files with 346 additions and 34 deletions

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}