feat: add expiring reset card wallet

This commit is contained in:
2026-09-08 23:04:54 +08:00
parent 91ae7912ab
commit 01525834fd
8 changed files with 943 additions and 5 deletions

View File

@@ -13,6 +13,7 @@ import {
LogOut,
Plus,
Settings as SettingsIcon,
Ticket,
UserCircle2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -34,7 +35,14 @@ import {
subscribeCodingProjectCreationRequest,
subscribeCodingProjectOpenRequest,
} from '@/lib/coding-project-entry';
import { fetchWorksTokenPointBalance, type WorksTokenPointBalance } from '@/lib/works-square';
import { AppError } from '@/lib/error-model';
import {
fetchWorksResetCards,
fetchWorksTokenPointBalance,
redeemWorksResetCard,
type WorksResetCard,
type WorksTokenPointBalance,
} from '@/lib/works-square';
import {
formatWorksTokenPointValue,
isWorksTokenPointBalanceExhausted,
@@ -67,6 +75,11 @@ type TokenPointState = {
balance: WorksTokenPointBalance | null;
};
type ResetCardState = {
status: 'idle' | 'loading' | 'loaded' | 'error';
cards: WorksResetCard[];
};
type ProjectEntryError = {
project: CodingProjectSummary;
message: string;
@@ -96,6 +109,34 @@ function getRefreshTimeLabel(value: string | null | undefined): string | null {
return formatted ? `刷新时间 ${formatted}` : null;
}
function formatResetCardTime(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return `${date.getFullYear()}-${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`;
}
function getEffectiveResetCardStatus(card: WorksResetCard): WorksResetCard['status'] {
if (card.status === 'available' && Date.parse(card.expires_at) <= Date.now()) return 'expired';
return card.status;
}
function getResetCardErrorMessage(error: unknown): string {
const details = error instanceof AppError ? error.details : undefined;
const backendCode = typeof details?.backendCode === 'string' ? details.backendCode : null;
const status = typeof details?.status === 'number' ? details.status : null;
const messages: Record<string, string> = {
reset_card_expired: '这张重置卡已过期,卡包已刷新。',
token_point_wallet_owner_required: '当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。',
billing_temporarily_paused: '计费服务正在切换,请稍后再试。',
token_point_transaction_conflict: '重置卡状态刚刚发生变化,请刷新后重试。',
};
if (backendCode && messages[backendCode]) return messages[backendCode];
if (status === 404) return '重置卡不存在或已不可用。';
if (status === 409) return '当前有待处理的计费操作,暂时不能使用重置卡。';
return '重置卡暂时无法使用,请稍后重试。';
}
function isTokenPointBalanceExhausted(state: TokenPointState): boolean {
if (state.status !== 'loaded' || !state.balance) return false;
return isWorksTokenPointBalanceExhausted(state.balance);
@@ -191,7 +232,10 @@ export function Sidebar({
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const [usageDrawerOpen, setUsageDrawerOpen] = useState(false);
const [resetCardDrawerOpen, setResetCardDrawerOpen] = useState(false);
const [tokenPointState, setTokenPointState] = useState<TokenPointState>({ status: 'idle', balance: null });
const [resetCardState, setResetCardState] = useState<ResetCardState>({ status: 'idle', cards: [] });
const [redeemingResetCardId, setRedeemingResetCardId] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
@@ -206,6 +250,7 @@ export function Sidebar({
const accountButtonRef = useRef<HTMLButtonElement | null>(null);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
const tokenPointRequestIdRef = useRef(0);
const resetCardRequestIdRef = useRef(0);
const navigate = useNavigate();
const location = useLocation();
const activeModule = getAiModuleForPath(location.pathname);
@@ -227,6 +272,10 @@ export function Sidebar({
tokenPointBalance && !tokenPointBalance.can_manage_membership,
);
const nextRefreshLabel = getRefreshTimeLabel(tokenPointBalance?.next_refresh_at);
const availableResetCardCount = resetCardState.cards.filter(
(card) => getEffectiveResetCardStatus(card) === 'available',
).length;
const resetCardRedemptionBlocked = tokenPointBalance?.entitlement_source === 'shared_group';
const activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null;
const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => {
@@ -334,6 +383,7 @@ export function Sidebar({
useEffect(() => {
if (!accountMenuOpen) {
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
}
}, [accountMenuOpen]);
@@ -366,19 +416,51 @@ export function Sidebar({
});
}, [authUser, getValidAccessToken]);
const refreshResetCards = useCallback(async (): Promise<void> => {
if (!authUser) {
resetCardRequestIdRef.current += 1;
setResetCardState({ status: 'idle', cards: [] });
return;
}
const requestId = resetCardRequestIdRef.current + 1;
resetCardRequestIdRef.current = requestId;
setResetCardState((current) => ({ status: 'loading', cards: current.cards }));
try {
const accessToken = await getValidAccessToken();
if (!accessToken) throw new Error('请先登录后再查看重置卡');
const cards = await fetchWorksResetCards(accessToken);
if (resetCardRequestIdRef.current === requestId) {
setResetCardState({ status: 'loaded', cards });
}
} catch {
if (resetCardRequestIdRef.current === requestId) {
setResetCardState((current) => ({ status: 'error', cards: current.cards }));
}
}
}, [authUser, getValidAccessToken]);
useEffect(() => {
refreshTokenPoints();
}, [refreshTokenPoints]);
useEffect(() => {
resetCardRequestIdRef.current += 1;
setResetCardState({ status: 'idle', cards: [] });
setRedeemingResetCardId(null);
}, [authUser?.userId]);
useEffect(() => {
if (!authUser) return undefined;
const handleFocus = () => {
refreshTokenPoints();
if (resetCardDrawerOpen) void refreshResetCards();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshTokenPoints();
if (resetCardDrawerOpen) void refreshResetCards();
}
};
@@ -388,7 +470,7 @@ export function Sidebar({
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [authUser, refreshTokenPoints]);
}, [authUser, refreshResetCards, refreshTokenPoints, resetCardDrawerOpen]);
useEffect(() => {
if (authUser && accountMenuOpen) {
@@ -396,6 +478,12 @@ export function Sidebar({
}
}, [accountMenuOpen, authUser, refreshTokenPoints]);
useEffect(() => {
if (authUser && resetCardDrawerOpen) {
void refreshResetCards();
}
}, [authUser, refreshResetCards, resetCardDrawerOpen]);
const openCreateProject = useCallback(() => {
setNewProjectName('');
@@ -512,12 +600,14 @@ export function Sidebar({
const navigateFromAccountMenu = (path: string) => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
navigate(path);
};
const openProfileDialog = () => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
setProfileDialogOpen(true);
void syncProfileNow().catch(() => undefined);
};
@@ -526,9 +616,44 @@ export function Sidebar({
void openWorksSquareSubscriptionUpgrade().catch(() => undefined);
};
const handleRedeemResetCard = async (card: WorksResetCard) => {
if (redeemingResetCardId) return;
if (getEffectiveResetCardStatus(card) !== 'available') {
toast.error('这张重置卡已过期。');
void refreshResetCards();
return;
}
if (resetCardRedemptionBlocked) {
toast.error('当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。');
return;
}
setRedeemingResetCardId(card.id);
try {
const accessToken = await getValidAccessToken();
if (!accessToken) throw new Error('请先登录后再使用重置卡');
const redeemedCard = await redeemWorksResetCard(accessToken, card.id);
setResetCardState((current) => ({
status: 'loaded',
cards: current.cards.map((item) => item.id === redeemedCard.id ? redeemedCard : item),
}));
toast.success('重置卡已使用,本周额度已刷新。');
refreshTokenPoints();
void refreshResetCards();
} catch (error) {
toast.error(getResetCardErrorMessage(error));
void refreshResetCards();
} finally {
setRedeemingResetCardId((current) => current === card.id ? null : current);
}
};
const handleAccountLogout = async () => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
resetCardRequestIdRef.current += 1;
setResetCardState({ status: 'idle', cards: [] });
await logout();
navigate('/login');
};
@@ -1021,7 +1146,10 @@ export function Sidebar({
aria-expanded={usageDrawerOpen}
aria-controls="sidebar-account-usage-drawer"
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors duration-100 hover:bg-surface-subtle"
onClick={() => setUsageDrawerOpen((open) => !open)}
onClick={() => {
setUsageDrawerOpen((open) => !open);
setResetCardDrawerOpen(false);
}}
>
<BarChart3 className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate"></span>
@@ -1113,6 +1241,107 @@ export function Sidebar({
</div>
) : null}
</DisclosureContent>
<button
type="button"
role="menuitem"
data-testid="sidebar-reset-cards-menuitem"
aria-expanded={resetCardDrawerOpen}
aria-controls="sidebar-reset-cards-drawer"
className="motion-press flex min-h-10 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-[background-color,transform] duration-100 hover:bg-surface-subtle"
onClick={() => {
setResetCardDrawerOpen((open) => !open);
setUsageDrawerOpen(false);
}}
>
<Ticket className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate"></span>
{availableResetCardCount > 0 ? (
<span
data-testid="sidebar-reset-card-available-count"
className="min-w-5 rounded-full bg-accent-soft px-1.5 py-0.5 text-center text-[10px] font-semibold tabular-nums text-accent-strong"
>
{availableResetCardCount}
</span>
) : null}
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', resetCardDrawerOpen && 'rotate-90')} />
</button>
<DisclosureContent
open={resetCardDrawerOpen}
id="sidebar-reset-cards-drawer"
data-testid="sidebar-reset-cards-drawer"
className="ml-5 rounded-lg bg-surface-subtle/70 p-1 text-xs font-medium text-muted-foreground shadow-soft"
innerClassName="grid gap-1"
>
{resetCardState.status === 'loading' && resetCardState.cards.length === 0 ? (
<p className="px-1.5 py-2 text-pretty text-[11px]"></p>
) : null}
{resetCardState.status === 'error' ? (
<div data-testid="sidebar-reset-cards-error" className="rounded-md bg-background px-1.5 py-1.5 shadow-soft">
<p className="text-pretty text-[11px] leading-4"></p>
<button
type="button"
className="motion-press mt-1 min-h-10 text-[11px] font-semibold text-foreground underline underline-offset-2 transition-transform duration-100"
onClick={() => void refreshResetCards()}
>
</button>
</div>
) : null}
{resetCardState.status === 'loaded' && resetCardState.cards.length === 0 ? (
<p className="px-1.5 py-2 text-pretty text-[11px]"></p>
) : null}
{resetCardRedemptionBlocked && resetCardState.cards.length > 0 ? (
<p className="rounded-md bg-background px-1.5 py-1.5 text-pretty text-[10px] leading-4 shadow-soft">
使使
</p>
) : null}
{resetCardState.cards.length > 0 ? (
<div className="grid max-h-64 gap-1 overflow-y-auto pr-0.5">
{resetCardState.cards.map((card) => {
const status = getEffectiveResetCardStatus(card);
const expiresAt = formatResetCardTime(card.expires_at) ?? '时间不可用';
const redeemedAt = formatResetCardTime(card.redeemed_at);
const redeeming = redeemingResetCardId === card.id;
return (
<article
key={card.id}
data-testid={`sidebar-reset-card-${card.id}`}
className="rounded-lg bg-background p-2 shadow-soft"
>
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-foreground"></span>
<span className={cn(
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
status === 'available' && 'bg-accent-soft text-accent-strong',
status === 'expired' && 'bg-destructive/10 text-destructive',
status === 'redeemed' && 'bg-surface-subtle text-muted-foreground',
)}>
{status === 'available' ? '可使用' : status === 'expired' ? '已过期' : '已使用'}
</span>
</div>
<p className="mt-1 text-pretty text-[10px] leading-4 tabular-nums">
{expiresAt}
</p>
{status === 'redeemed' && redeemedAt ? (
<p className="text-pretty text-[10px] leading-4 tabular-nums">使 {redeemedAt}</p>
) : null}
{status === 'available' ? (
<button
type="button"
data-testid={`sidebar-reset-card-redeem-${card.id}`}
className="motion-press mt-1.5 min-h-10 w-full rounded-md bg-accent-soft px-2 py-1.5 text-xs font-semibold text-foreground shadow-soft transition-[background-color,transform] duration-100 hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
disabled={Boolean(redeemingResetCardId) || resetCardRedemptionBlocked}
onClick={() => void handleRedeemResetCard(card)}
>
{redeeming ? '使用中…' : resetCardRedemptionBlocked ? '家庭共享中不可用' : '立即使用'}
</button>
) : null}
</article>
);
})}
</div>
) : null}
</DisclosureContent>
<button
type="button"
role="menuitem"

View File

@@ -209,6 +209,15 @@ export type WorksTokenPointBalance = {
shared_available: boolean | null;
};
export type WorksResetCard = {
id: string;
status: 'available' | 'expired' | 'redeemed';
granted_at: string;
expires_at: string;
redeemed_at: string | null;
redeemed_cycle_id: string | null;
};
export type PlazaCard = {
id: string;
title: string;
@@ -427,6 +436,34 @@ export async function fetchWorksTokenPointBalance(accessToken: string): Promise<
return assertSuccess(response, 'points', 'Failed to load Works Square token points');
}
export async function fetchWorksResetCards(accessToken: string): Promise<WorksResetCard[]> {
const response = await hostApiFetch<WorksActionResponse<'cards', WorksResetCard[]>>(
'/api/works/billing/reset-cards',
{
headers: {
'X-NianCode-Access-Token': accessToken,
},
},
);
return assertSuccess(response, 'cards', 'Failed to load Works Square reset cards');
}
export async function redeemWorksResetCard(
accessToken: string,
cardId: string,
): Promise<WorksResetCard> {
const response = await hostApiFetch<WorksActionResponse<'card', WorksResetCard>>(
`/api/works/billing/reset-cards/${encodeURIComponent(cardId)}/redeem`,
{
method: 'POST',
headers: {
'X-NianCode-Access-Token': accessToken,
},
},
);
return assertSuccess(response, 'card', 'Failed to redeem Works Square reset card');
}
export async function fetchMyWorksProjectStatus(
accessToken: string,
appId: string,