feat: integrate token point usage v2
This commit is contained in:
@@ -31,9 +31,11 @@ import {
|
||||
import { getAiModuleForPath } from '@/lib/ai-modules';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
|
||||
import { WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT } from '@/lib/works-square-usage-events';
|
||||
import { fetchWorksTokenUsage, type WorksTokenUsage } from '@/lib/works-square';
|
||||
import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage';
|
||||
import { fetchWorksTokenPointBalance, type WorksTokenPointBalance } from '@/lib/works-square';
|
||||
import {
|
||||
formatWorksTokenPointValue,
|
||||
isWorksTokenPointBalanceExhausted,
|
||||
} from '@/lib/works-square-token-points';
|
||||
import { openWorksSquareSubscriptionUpgrade } from '@/lib/subscription-upgrade';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCurrentUserProfile } from '@/hooks/use-current-user-profile';
|
||||
@@ -61,10 +63,9 @@ type OpenDialogResult = {
|
||||
filePaths: string[];
|
||||
};
|
||||
|
||||
type TokenUsageState = {
|
||||
type TokenPointState = {
|
||||
status: 'idle' | 'loading' | 'loaded' | 'error';
|
||||
usage: WorksTokenUsage | null;
|
||||
error?: string;
|
||||
balance: WorksTokenPointBalance | null;
|
||||
};
|
||||
|
||||
type ProjectEntryError = {
|
||||
@@ -81,12 +82,6 @@ function getFolderName(pathValue: string): string {
|
||||
return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed;
|
||||
}
|
||||
|
||||
function formatRemainingPercent(value: number | null | undefined): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '不限';
|
||||
const clamped = Math.min(100, Math.max(0, value));
|
||||
return `${Math.round(clamped)}%`;
|
||||
}
|
||||
|
||||
function padTimePart(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
@@ -103,32 +98,59 @@ function getRefreshTimeLabel(value: string | null | undefined): string | null {
|
||||
return formatted ? `刷新时间 ${formatted}` : null;
|
||||
}
|
||||
|
||||
function isTokenUsageExhausted(state: TokenUsageState): boolean {
|
||||
if (state.status !== 'loaded' || !state.usage) return false;
|
||||
return isWorksTokenUsageExhausted(state.usage);
|
||||
function isTokenPointBalanceExhausted(state: TokenPointState): boolean {
|
||||
if (state.status !== 'loaded' || !state.balance) return false;
|
||||
return isWorksTokenPointBalanceExhausted(state.balance);
|
||||
}
|
||||
|
||||
function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenUsageState): string {
|
||||
function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenPointState): string {
|
||||
if (!user) return '未登录';
|
||||
const planName = state.usage?.plan_name?.trim();
|
||||
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.usage?.plan_code?.trim();
|
||||
const planCode = state.balance?.plan_code?.trim();
|
||||
if (planCode) return planCode;
|
||||
return '会员';
|
||||
}
|
||||
|
||||
function isFreeSubscriptionPlan(state: TokenUsageState): boolean {
|
||||
const planCode = state.usage?.plan_code?.trim().toLowerCase();
|
||||
const planName = state.usage?.plan_name?.trim().toLowerCase();
|
||||
return planCode === 'free' || planName === 'free';
|
||||
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: TokenUsageState): string {
|
||||
function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenPointState): string {
|
||||
const levelLabel = getSubscriptionPlanLabel(user, state);
|
||||
if (!user || isFreeSubscriptionPlan(state) || levelLabel === '会员') return levelLabel;
|
||||
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;
|
||||
sidebarPeekOpen?: boolean;
|
||||
@@ -162,7 +184,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
const [usageDrawerOpen, setUsageDrawerOpen] = useState(false);
|
||||
const [tokenUsageState, setTokenUsageState] = useState<TokenUsageState>({ status: 'idle', usage: null });
|
||||
const [tokenPointState, setTokenPointState] = useState<TokenPointState>({ status: 'idle', balance: null });
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
|
||||
@@ -179,7 +201,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const [robotSelectedAgentId, setRobotSelectedAgentId] = useState<string | null>(() => readAiHardwareAgentId());
|
||||
const accountButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const accountMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const tokenUsageRequestIdRef = useRef(0);
|
||||
const tokenPointRequestIdRef = useRef(0);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const activeModule = getAiModuleForPath(location.pathname);
|
||||
@@ -196,11 +218,14 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const accountInitial = getAccountInitial(accountName);
|
||||
const accountAvatarUrl = userProfile?.avatarUrl ?? null;
|
||||
const settingsLabel = t('sidebar.settings', '设置');
|
||||
const memberLabel = getSubscriptionPlanLabel(authUser, tokenUsageState);
|
||||
const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenUsageState);
|
||||
const tokenUsageExhausted = isTokenUsageExhausted(tokenUsageState);
|
||||
const fiveHourRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.five_hour_refresh_at);
|
||||
const weeklyRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.weekly_refresh_at);
|
||||
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 activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null;
|
||||
|
||||
const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => {
|
||||
@@ -311,52 +336,48 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
}
|
||||
}, [accountMenuOpen]);
|
||||
|
||||
const refreshTokenUsage = useCallback(() => {
|
||||
const refreshTokenPoints = useCallback(() => {
|
||||
if (!authUser) {
|
||||
tokenUsageRequestIdRef.current += 1;
|
||||
setTokenUsageState({ status: 'idle', usage: null });
|
||||
tokenPointRequestIdRef.current += 1;
|
||||
setTokenPointState({ status: 'idle', balance: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = tokenUsageRequestIdRef.current + 1;
|
||||
tokenUsageRequestIdRef.current = requestId;
|
||||
setTokenUsageState((current) => ({ status: 'loading', usage: current.usage }));
|
||||
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 fetchWorksTokenUsage(accessToken);
|
||||
return fetchWorksTokenPointBalance(accessToken);
|
||||
})
|
||||
.then((usage) => {
|
||||
if (tokenUsageRequestIdRef.current === requestId) {
|
||||
setTokenUsageState({ status: 'loaded', usage });
|
||||
.then((balance) => {
|
||||
if (tokenPointRequestIdRef.current === requestId) {
|
||||
setTokenPointState({ status: 'loaded', balance });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (tokenUsageRequestIdRef.current === requestId) {
|
||||
setTokenUsageState({
|
||||
status: 'error',
|
||||
usage: null,
|
||||
error: error instanceof Error ? error.message : '暂时无法获取套餐额度',
|
||||
});
|
||||
.catch(() => {
|
||||
if (tokenPointRequestIdRef.current === requestId) {
|
||||
setTokenPointState({ status: 'error', balance: null });
|
||||
}
|
||||
});
|
||||
}, [authUser, getValidAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshTokenUsage();
|
||||
}, [refreshTokenUsage]);
|
||||
refreshTokenPoints();
|
||||
}, [refreshTokenPoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authUser) return undefined;
|
||||
|
||||
const handleFocus = () => {
|
||||
refreshTokenUsage();
|
||||
refreshTokenPoints();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshTokenUsage();
|
||||
refreshTokenPoints();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -366,33 +387,13 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [authUser, refreshTokenUsage]);
|
||||
}, [authUser, refreshTokenPoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser && accountMenuOpen) {
|
||||
refreshTokenUsage();
|
||||
refreshTokenPoints();
|
||||
}
|
||||
}, [accountMenuOpen, authUser, refreshTokenUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authUser) return undefined;
|
||||
const retryTimers = new Set<number>();
|
||||
const handleTokenUsageStale = () => {
|
||||
refreshTokenUsage();
|
||||
const retryTimer = window.setTimeout(() => {
|
||||
retryTimers.delete(retryTimer);
|
||||
refreshTokenUsage();
|
||||
}, 1500);
|
||||
retryTimers.add(retryTimer);
|
||||
};
|
||||
window.addEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale);
|
||||
return () => {
|
||||
window.removeEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale);
|
||||
for (const retryTimer of retryTimers) {
|
||||
window.clearTimeout(retryTimer);
|
||||
}
|
||||
};
|
||||
}, [authUser, refreshTokenUsage]);
|
||||
}, [accountMenuOpen, authUser, refreshTokenPoints]);
|
||||
|
||||
|
||||
const openCreateProject = () => {
|
||||
@@ -1171,43 +1172,80 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
<span>订阅等级</span>
|
||||
<span className="text-foreground">{memberLabel}</span>
|
||||
</div>
|
||||
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
|
||||
<span>5 小时</span>
|
||||
<span className="text-foreground">
|
||||
{formatRemainingPercent(tokenUsageState.usage?.five_hour_remaining_percent)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
|
||||
<span>1 周</span>
|
||||
<span className="text-foreground">
|
||||
{formatRemainingPercent(tokenUsageState.usage?.weekly_remaining_percent)}
|
||||
</span>
|
||||
</div>
|
||||
{fiveHourRefreshLabel ? (
|
||||
<div data-testid="sidebar-five-hour-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
|
||||
{fiveHourRefreshLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{weeklyRefreshLabel ? (
|
||||
<div data-testid="sidebar-weekly-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
|
||||
{weeklyRefreshLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{tokenUsageExhausted ? (
|
||||
<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">当前额度已用尽,升级订阅后可继续使用。</p>
|
||||
{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"
|
||||
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}
|
||||
className="mt-1 text-[11px] font-semibold text-foreground underline underline-offset-2"
|
||||
onClick={refreshTokenPoints}
|
||||
>
|
||||
<Crown className="h-3.5 w-3.5" />
|
||||
<span>升级订阅</span>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
重试
|
||||
</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"
|
||||
|
||||
Reference in New Issue
Block a user