feat: integrate token point usage v2
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-09-06 14:24:50 +08:00
parent 9fed0cc7c4
commit 83ad10d95e
14 changed files with 907 additions and 201 deletions

View File

@@ -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"