feat: replace desktop membership with permanent point wallet
This commit is contained in:
@@ -3,18 +3,14 @@ import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
Cpu,
|
||||
ChevronRight,
|
||||
Crown,
|
||||
ExternalLink,
|
||||
FolderKanban,
|
||||
LogOut,
|
||||
Plus,
|
||||
RadioTower,
|
||||
Settings as SettingsIcon,
|
||||
Ticket,
|
||||
UserCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -36,19 +32,7 @@ import {
|
||||
subscribeCodingProjectCreationRequest,
|
||||
subscribeCodingProjectOpenRequest,
|
||||
} from '@/lib/coding-project-entry';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import {
|
||||
fetchWorksResetCards,
|
||||
fetchWorksTokenPointBalance,
|
||||
redeemWorksResetCard,
|
||||
type WorksResetCard,
|
||||
type WorksTokenPointBalance,
|
||||
} from '@/lib/works-square';
|
||||
import {
|
||||
formatWorksTokenPointValue,
|
||||
isWorksTokenPointBalanceExhausted,
|
||||
} from '@/lib/works-square-token-points';
|
||||
import { openWorksSquareSubscriptionUpgrade } from '@/lib/subscription-upgrade';
|
||||
import { PointWallet } from '@/components/account/PointWallet';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCurrentUserProfile } from '@/hooks/use-current-user-profile';
|
||||
import type { SidebarPeekSource } from './sidebar-peek';
|
||||
@@ -59,7 +43,7 @@ import { UserAvatar } from '@/components/profile/UserAvatar';
|
||||
import { getAccountInitial } from '@/components/profile/user-avatar-utils';
|
||||
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useAuthStore, type AuthUser } from '@/stores/auth';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import type { CodingProjectSummary } from '@/types/coding-project';
|
||||
@@ -71,16 +55,6 @@ type OpenDialogResult = {
|
||||
filePaths: string[];
|
||||
};
|
||||
|
||||
type TokenPointState = {
|
||||
status: 'idle' | 'loading' | 'loaded' | 'error';
|
||||
balance: WorksTokenPointBalance | null;
|
||||
};
|
||||
|
||||
type ResetCardState = {
|
||||
status: 'idle' | 'loading' | 'loaded' | 'error';
|
||||
cards: WorksResetCard[];
|
||||
};
|
||||
|
||||
type ProjectEntryError = {
|
||||
project: CodingProjectSummary;
|
||||
message: string;
|
||||
@@ -94,103 +68,6 @@ function getFolderName(pathValue: string): string {
|
||||
return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed;
|
||||
}
|
||||
|
||||
function padTimePart(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatRefreshTime(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return `${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function getRefreshTimeLabel(value: string | null | undefined): string | null {
|
||||
const formatted = formatRefreshTime(value);
|
||||
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);
|
||||
}
|
||||
|
||||
function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenPointState): string {
|
||||
if (!user) return '未登录';
|
||||
if (state.balance && !state.balance.can_manage_membership) {
|
||||
return state.balance.family_shared ? '共享会员' : '青少年账户';
|
||||
}
|
||||
const planName = state.balance?.plan_name?.trim();
|
||||
if (planName) return planName;
|
||||
const planCode = state.balance?.plan_code?.trim();
|
||||
if (planCode) return planCode;
|
||||
return '会员';
|
||||
}
|
||||
|
||||
function isExperienceSubscriptionPlan(state: TokenPointState): boolean {
|
||||
const planCode = state.balance?.plan_code?.trim().toLowerCase();
|
||||
const planName = state.balance?.plan_name?.trim().toLowerCase();
|
||||
return planCode === 'experience' || planName === '体验';
|
||||
}
|
||||
|
||||
function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenPointState): string {
|
||||
const levelLabel = getSubscriptionPlanLabel(user, state);
|
||||
if (
|
||||
!user
|
||||
|| isExperienceSubscriptionPlan(state)
|
||||
|| levelLabel === '会员'
|
||||
|| levelLabel === '共享会员'
|
||||
|| levelLabel === '青少年账户'
|
||||
) return levelLabel;
|
||||
return `${levelLabel} 会员`;
|
||||
}
|
||||
|
||||
function getWeeklyPointLabel(balance: WorksTokenPointBalance | null): string {
|
||||
const remaining = formatWorksTokenPointValue(balance?.weekly_remaining);
|
||||
const allowance = formatWorksTokenPointValue(balance?.weekly_allowance);
|
||||
if (remaining && allowance) return `${remaining} / ${allowance} 点`;
|
||||
return remaining ? `${remaining} 点` : '—';
|
||||
}
|
||||
|
||||
function getTotalPointLabel(balance: WorksTokenPointBalance | null): string {
|
||||
const remaining = formatWorksTokenPointValue(balance?.total_remaining);
|
||||
return remaining ? `${remaining} 点` : '—';
|
||||
}
|
||||
|
||||
function getCoarsePointLabel(balance: WorksTokenPointBalance | null): string {
|
||||
if (balance?.shared_available === true) return '可用';
|
||||
if (balance?.shared_available === false) return '已用尽';
|
||||
return balance?.family_shared ? '由家庭管理员管理' : '由家长管理';
|
||||
}
|
||||
|
||||
type SidebarProps = {
|
||||
workspaceLayout?: boolean;
|
||||
hideOnCompact?: boolean;
|
||||
@@ -220,7 +97,6 @@ export function Sidebar({
|
||||
const loadProjectConfig = useProjectConfigStore((state) => state.load);
|
||||
const removeProjectConfig = useProjectConfigStore((state) => state.remove);
|
||||
const authUser = useAuthStore((state) => state.user);
|
||||
const getValidAccessToken = useAuthStore((state) => state.getValidAccessToken);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const {
|
||||
userProfile,
|
||||
@@ -232,11 +108,6 @@ export function Sidebar({
|
||||
const [projectsOpen, setProjectsOpen] = useState(true);
|
||||
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('');
|
||||
@@ -250,8 +121,6 @@ export function Sidebar({
|
||||
const [robotSelectedAgentId, setRobotSelectedAgentId] = useState<string | null>(() => readAiHardwareAgentId());
|
||||
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);
|
||||
@@ -265,19 +134,6 @@ export function Sidebar({
|
||||
const accountInitial = getAccountInitial(accountName);
|
||||
const accountAvatarUrl = userProfile?.avatarUrl ?? null;
|
||||
const settingsLabel = t('sidebar.settings', '设置');
|
||||
const memberLabel = getSubscriptionPlanLabel(authUser, tokenPointState);
|
||||
const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenPointState);
|
||||
const tokenPointBalanceExhausted = isTokenPointBalanceExhausted(tokenPointState);
|
||||
const tokenPointBalance = tokenPointState.balance;
|
||||
const tokenPointsCoarse = Boolean(
|
||||
tokenPointBalance && !tokenPointBalance.can_manage_membership,
|
||||
);
|
||||
const nextRefreshLabel = getRefreshTimeLabel(tokenPointBalance?.next_refresh_at);
|
||||
const visibleResetCards = resetCardState.cards.filter((card) => card.status !== 'redeemed');
|
||||
const availableResetCardCount = visibleResetCards.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) => {
|
||||
@@ -382,111 +238,6 @@ export function Sidebar({
|
||||
};
|
||||
}, [accountMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountMenuOpen) {
|
||||
setUsageDrawerOpen(false);
|
||||
setResetCardDrawerOpen(false);
|
||||
}
|
||||
}, [accountMenuOpen]);
|
||||
|
||||
const refreshTokenPoints = useCallback(() => {
|
||||
if (!authUser) {
|
||||
tokenPointRequestIdRef.current += 1;
|
||||
setTokenPointState({ status: 'idle', balance: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = tokenPointRequestIdRef.current + 1;
|
||||
tokenPointRequestIdRef.current = requestId;
|
||||
setTokenPointState((current) => ({ status: 'loading', balance: current.balance }));
|
||||
void getValidAccessToken()
|
||||
.then((accessToken) => {
|
||||
if (!accessToken) {
|
||||
throw new Error('请先登录后再查看用量');
|
||||
}
|
||||
return fetchWorksTokenPointBalance(accessToken);
|
||||
})
|
||||
.then((balance) => {
|
||||
if (tokenPointRequestIdRef.current === requestId) {
|
||||
setTokenPointState({ status: 'loaded', balance });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (tokenPointRequestIdRef.current === requestId) {
|
||||
setTokenPointState({ status: 'error', balance: null });
|
||||
}
|
||||
});
|
||||
}, [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();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [authUser, refreshResetCards, refreshTokenPoints, resetCardDrawerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser && accountMenuOpen) {
|
||||
refreshTokenPoints();
|
||||
}
|
||||
}, [accountMenuOpen, authUser, refreshTokenPoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser && resetCardDrawerOpen) {
|
||||
void refreshResetCards();
|
||||
}
|
||||
}, [authUser, refreshResetCards, resetCardDrawerOpen]);
|
||||
|
||||
|
||||
const openCreateProject = useCallback(() => {
|
||||
setNewProjectName('');
|
||||
setNewProjectSelectedPath('');
|
||||
@@ -601,61 +352,17 @@ 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);
|
||||
};
|
||||
|
||||
const openSubscriptionUpgrade = () => {
|
||||
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');
|
||||
};
|
||||
@@ -1097,7 +804,7 @@ export function Sidebar({
|
||||
ref={accountButtonRef}
|
||||
type="button"
|
||||
data-testid="sidebar-member-menu-trigger"
|
||||
aria-label={`打开会员菜单:${accountName}`}
|
||||
aria-label={`打开账户菜单:${accountName}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountMenuOpen}
|
||||
onClick={() => setAccountMenuOpen((open) => !open)}
|
||||
@@ -1148,210 +855,13 @@ export function Sidebar({
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{accountFullName}</p>
|
||||
<p className="mt-0.5 text-[10px] font-medium text-muted-foreground">{membershipLabel}</p>
|
||||
<p className="mt-0.5 text-[10px] font-medium text-muted-foreground">词元点数账户</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 grid gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="sidebar-account-usage-menuitem"
|
||||
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);
|
||||
setResetCardDrawerOpen(false);
|
||||
}}
|
||||
>
|
||||
<BarChart3 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">剩余用量</span>
|
||||
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', usageDrawerOpen && 'rotate-90')} />
|
||||
</button>
|
||||
<DisclosureContent
|
||||
open={usageDrawerOpen}
|
||||
id="sidebar-account-usage-drawer"
|
||||
data-testid="sidebar-account-usage-drawer"
|
||||
className="ml-5 rounded-md bg-surface-subtle/70 px-1 py-0.5 text-xs font-medium text-muted-foreground"
|
||||
innerClassName="grid gap-0.5"
|
||||
>
|
||||
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
|
||||
<span>订阅等级</span>
|
||||
<span className="text-foreground">{memberLabel}</span>
|
||||
</div>
|
||||
{tokenPointState.status === 'error' ? (
|
||||
<div data-testid="sidebar-token-points-error" className="rounded-md bg-background px-1.5 py-1.5">
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">词元点数暂时无法获取。</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 text-[11px] font-semibold text-foreground underline underline-offset-2"
|
||||
onClick={refreshTokenPoints}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{(tokenPointState.status === 'idle' || tokenPointState.status === 'loading') && !tokenPointBalance ? (
|
||||
<div className="px-1 py-1 text-[11px] text-muted-foreground">
|
||||
{authUser ? '正在加载词元点数…' : '登录后查看词元点数'}
|
||||
</div>
|
||||
) : null}
|
||||
{tokenPointBalance && tokenPointsCoarse ? (
|
||||
<>
|
||||
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
|
||||
<span>{tokenPointBalance.family_shared ? '共享额度' : '词元点数'}</span>
|
||||
<span className="text-foreground">{getCoarsePointLabel(tokenPointBalance)}</span>
|
||||
</div>
|
||||
<div className="px-1 text-[10px] leading-4 text-muted-foreground">
|
||||
{tokenPointBalance.family_shared
|
||||
? '具体词元点数由家庭管理员管理'
|
||||
: '具体词元点数由家长管理'}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{tokenPointBalance && !tokenPointsCoarse ? (
|
||||
<>
|
||||
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
|
||||
<span>本周剩余</span>
|
||||
<span data-testid="sidebar-weekly-token-points" className="text-foreground">
|
||||
{getWeeklyPointLabel(tokenPointBalance)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
|
||||
<span>总可用</span>
|
||||
<span data-testid="sidebar-total-token-points" className="text-foreground">
|
||||
{getTotalPointLabel(tokenPointBalance)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{tokenPointBalance && !tokenPointsCoarse && nextRefreshLabel ? (
|
||||
<div data-testid="sidebar-token-points-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
|
||||
{nextRefreshLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{tokenPointBalanceExhausted ? (
|
||||
<div className="mt-1 rounded-md border border-accent/50 bg-background p-1.5 text-foreground">
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||
{tokenPointsCoarse
|
||||
? tokenPointBalance?.family_shared
|
||||
? '家庭共享额度已用尽,请联系家庭管理员。'
|
||||
: '词元点数已用尽,请联系家长。'
|
||||
: '当前词元点数已用尽,升级订阅后可继续使用。'}
|
||||
</p>
|
||||
{!tokenPointsCoarse && tokenPointBalance?.upgrade_action === 'self_service' ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-account-upgrade-button"
|
||||
className="mt-1.5 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-foreground/15 bg-accent-soft px-2 py-1.5 text-xs font-semibold text-foreground shadow-soft transition-colors duration-100 hover:bg-background"
|
||||
onClick={openSubscriptionUpgrade}
|
||||
>
|
||||
<Crown className="h-3.5 w-3.5" />
|
||||
<span>升级订阅</span>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</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' && visibleResetCards.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' && visibleResetCards.length === 0 ? (
|
||||
<p className="px-1.5 py-2 text-pretty text-[11px]">暂无未使用的重置卡。</p>
|
||||
) : null}
|
||||
{resetCardRedemptionBlocked && visibleResetCards.length > 0 ? (
|
||||
<p className="rounded-md bg-background px-1.5 py-1.5 text-pretty text-[10px] leading-4 shadow-soft">
|
||||
当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。
|
||||
</p>
|
||||
) : null}
|
||||
{visibleResetCards.length > 0 ? (
|
||||
<div className="grid max-h-64 gap-1 overflow-y-auto pr-0.5">
|
||||
{visibleResetCards.map((card) => {
|
||||
const status = getEffectiveResetCardStatus(card);
|
||||
const expiresAt = formatResetCardTime(card.expires_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 === 'available' ? '可使用' : '已过期'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-pretty text-[10px] leading-4 tabular-nums">
|
||||
有效期至 {expiresAt}
|
||||
</p>
|
||||
{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>
|
||||
{authUser && <PointWallet key={authUser.userId ?? authUser.username} active={accountMenuOpen} />}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
Reference in New Issue
Block a user