@@ -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 TokenUsage State = {
type TokenPoint State = {
status : 'idle' | 'loading' | 'loaded' | 'error' ;
usag e : WorksTokenUsag e | null ;
error? : string ;
balanc e : WorksTokenPointBalanc e | null ;
} ;
type ProjectEntryError = {
@@ -81,12 +82,6 @@ function getFolderName(pathValue: string): string {
return withoutTrailingSeparators . split ( / [ \ \ / ] / u ) . f i l t e r ( B o o l e a n ) . a t ( - 1 ) ? ? t r i m m e d ;
}
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 isTokenUsag eExhausted ( state : TokenUsage State ) : boolean {
if ( state . status !== 'loaded' || ! state . usag e) return false ;
return isWorksTokenUsag eExhausted ( state . usag e) ;
function isTokenPointBalanc eExhausted ( state : TokenPoint State ) : boolean {
if ( state . status !== 'loaded' || ! state . balanc e) return false ;
return isWorksTokenPointBalanc eExhausted ( state . balanc e) ;
}
function getSubscriptionPlanLabel ( user : AuthUser | null , state : TokenUsage State ) : string {
function getSubscriptionPlanLabel ( user : AuthUser | null , state : TokenPoint State ) : string {
if ( ! user ) return '未登录' ;
const planName = state . usage ? . pl an_name ? . trim ( ) ;
if ( state . balance && ! state . balance . c an_manage_membership ) {
return state . balance . family_shared ? '共享会员' : '青少年账户' ;
}
const planName = state . balance ? . plan_name ? . trim ( ) ;
if ( planName ) return planName ;
const planCode = state . usag e? . plan_code ? . trim ( ) ;
const planCode = state . balanc e? . plan_code ? . trim ( ) ;
if ( planCode ) return planCode ;
return '会员' ;
}
function isFre eSubscriptionPlan ( state : TokenUsage State ) : boolean {
const planCode = state . usag e? . plan_code ? . trim ( ) . toLowerCase ( ) ;
const planName = state . usag e? . plan_name ? . trim ( ) . toLowerCase ( ) ;
return planCode === 'fre e' || planName === 'free ' ;
function isExperienc eSubscriptionPlan ( state : TokenPoint State ) : boolean {
const planCode = state . balanc e? . plan_code ? . trim ( ) . toLowerCase ( ) ;
const planName = state . balanc e? . plan_name ? . trim ( ) . toLowerCase ( ) ;
return planCode === 'experienc e' || planName === '体验 ' ;
}
function getSubscriptionMembershipLabel ( user : AuthUser | null , state : TokenUsage State ) : string {
function getSubscriptionMembershipLabel ( user : AuthUser | null , state : TokenPoint State ) : 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 [ tokenUsage State , setTokenUsage State ] = useState < TokenUsage State > ( { status : 'idle' , usag e : null } ) ;
const [ tokenPoint State , setTokenPoint State ] = useState < TokenPoint State > ( { status : 'idle' , balanc e : 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 tokenUsage RequestIdRef = useRef ( 0 ) ;
const tokenPoint RequestIdRef = 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 , tokenUsage State ) ;
const membershipLabel = getSubscriptionMembershipLabel ( authUser , tokenUsage State ) ;
const tokenUsag eExhausted = isTokenUsag eExhausted ( tokenUsage State ) ;
const fiveHourRefreshLabel = getRefreshTimeLabel ( tokenUsageState . usage ? . five_hour_refresh_at ) ;
const weeklyRefreshLabel = getRefreshTimeLabel ( tokenUsageState . usage ? . weekly_refresh_at ) ;
const memberLabel = getSubscriptionPlanLabel ( authUser , tokenPoint State ) ;
const membershipLabel = getSubscriptionMembershipLabel ( authUser , tokenPoint State ) ;
const tokenPointBalanc eExhausted = isTokenPointBalanc eExhausted ( tokenPoint State ) ;
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 ) {
tokenUsage RequestIdRef . current += 1 ;
setTokenUsage State ( { status : 'idle' , usag e : null } ) ;
tokenPoint RequestIdRef . current += 1 ;
setTokenPoint State ( { status : 'idle' , balanc e : null } ) ;
return ;
}
const requestId = tokenUsage RequestIdRef . current + 1 ;
tokenUsage RequestIdRef . current = requestId ;
setTokenUsage State ( ( current ) = > ( { status : 'loading' , usag e : current.usag e } ) ) ;
const requestId = tokenPoint RequestIdRef . current + 1 ;
tokenPoint RequestIdRef . current = requestId ;
setTokenPoint State ( ( current ) = > ( { status : 'loading' , balanc e : current.balanc e } ) ) ;
void getValidAccessToken ( )
. then ( ( accessToken ) = > {
if ( ! accessToken ) {
throw new Error ( '请先登录后再查看用量' ) ;
}
return fetchWorksTokenUsag e ( accessToken ) ;
return fetchWorksTokenPointBalanc e ( accessToken ) ;
} )
. then ( ( usag e) = > {
if ( tokenUsage RequestIdRef . current === requestId ) {
setTokenUsage State ( { status : 'loaded' , usag e } ) ;
. then ( ( balanc e) = > {
if ( tokenPoint RequestIdRef . current === requestId ) {
setTokenPoint State ( { status : 'loaded' , balanc e } ) ;
}
} )
. catch ( ( error ) = > {
if ( tokenUsage RequestIdRef . current === requestId ) {
setTokenUsage State ( {
status : 'error' ,
usage : null ,
error : error instanceof Error ? error . message : '暂时无法获取套餐额度' ,
} ) ;
. catch ( ( ) = > {
if ( tokenPoint RequestIdRef . current === requestId ) {
setTokenPoint State ( { 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"