@@ -152,6 +152,7 @@ def __init__(self):
152152 self ._last_data : Dict [str , Any ] = {}
153153 self ._last_error : Optional [str ] = None
154154 self ._last_usage : Optional [Dict [str , Any ]] = None
155+ self ._jwt_expired_notified : bool = False
155156
156157 # 信息区(只读)
157158 self .info_title = rumps .MenuItem ("状态:未初始化" )
@@ -177,6 +178,10 @@ def __init__(self):
177178 self .info_last = rumps .MenuItem ("上次更新:-" )
178179 self .info_last .set_callback (None )
179180
181+ # Token 到期信息
182+ self .info_token_exp = rumps .MenuItem ("Token:-" )
183+ self .info_token_exp .set_callback (None )
184+
180185 # 账号类型子菜单
181186 self .menu_account = {
182187 "共享(公交车)" : rumps .MenuItem ("共享(公交车)" , callback = self ._set_shared ),
@@ -199,6 +204,7 @@ def __init__(self):
199204 self .info_usage_span ,
200205 self .info_monthly ,
201206 self .info_balance ,
207+ self .info_token_exp ,
202208 self .info_last ,
203209 None ,
204210 rumps .MenuItem ("刷新" , callback = self .refresh_now ),
@@ -255,6 +261,8 @@ def set_token(self, _: Optional[rumps.MenuItem] = None):
255261 with self ._lock :
256262 self ._cfg ["token" ] = token
257263 save_config (self ._cfg )
264+ # 重置过期提醒
265+ self ._jwt_expired_notified = False
258266 self ._refresh (force = True )
259267
260268 def _set_shared (self , _ : Optional [rumps .MenuItem ] = None ):
@@ -343,6 +351,42 @@ def _get_base_and_dashboard(self) -> Tuple[str, str]:
343351 env = ACCOUNT_ENV .get (account , ACCOUNT_ENV ["shared" ]) # type: ignore
344352 return env ["base" ], env ["dashboard" ]
345353
354+ def _update_token_status (self ) -> None :
355+ """更新菜单中的 Token 到期信息,并在过期后提醒一次。"""
356+ try :
357+ token = (self ._cfg .get ("token" ) or "" ).strip ()
358+ if not token or not _is_probable_jwt (token ):
359+ self .info_token_exp .title = "Token:-"
360+ return
361+ exp = _extract_exp_from_jwt (token )
362+ if not exp :
363+ self .info_token_exp .title = "Token:-"
364+ return
365+ # 本地时间展示
366+ dt_local = datetime .datetime .fromtimestamp (exp )
367+ remaining = int (exp - time .time ())
368+ remain_text = _fmt_remaining (remaining )
369+ if remaining <= 0 :
370+ self .info_token_exp .title = f"Token:已过期({ dt_local .strftime ('%Y-%m-%d %H:%M' )} )"
371+ if not self ._jwt_expired_notified :
372+ try :
373+ rumps .notification (
374+ title = "PackyCode" ,
375+ subtitle = "Token 已过期" ,
376+ message = "请在“设置 Token...”中更换 JWT" ,
377+ )
378+ except Exception :
379+ pass
380+ self ._jwt_expired_notified = True
381+ else :
382+ self .info_token_exp .title = (
383+ f"Token:{ dt_local .strftime ('%Y-%m-%d %H:%M' )} ({ remain_text } )"
384+ )
385+ # 未过期时允许再次提醒(比如用户换新 Token 后)
386+ self ._jwt_expired_notified = False
387+ except Exception :
388+ self .info_token_exp .title = "Token:-"
389+
346390 def _refresh (self , force : bool = False ):
347391 # 避免过于频繁的刷新
348392 if not force :
@@ -432,6 +476,7 @@ def _update_ui_from_info(self, info: Optional[Dict[str, Any]], usage: Optional[D
432476 self .info_last .title = f"上次更新:{ now_str ()} "
433477 self .info_requests .title = "请求次数:-"
434478 self .info_usage_span .title = "近30日:-"
479+ self ._update_token_status ()
435480 return
436481
437482 # 解析字段(参考 packycode-cost UserApiResponse 与转换逻辑)
@@ -495,6 +540,8 @@ def _update_ui_from_info(self, info: Optional[Dict[str, Any]], usage: Optional[D
495540 self .info_balance .title = "余额:-"
496541
497542 self .info_last .title = f"上次更新:{ now_str ()} "
543+ # 更新 Token 到期信息与提醒
544+ self ._update_token_status ()
498545
499546 # 状态栏标题(根据设置)
500547 if self ._cfg .get ("hidden" ):
@@ -508,6 +555,7 @@ def _update_ui_error(self, err: str):
508555 self .info_last .title = f"上次更新:{ now_str ()} "
509556 self .info_requests .title = "请求次数:-"
510557 self .info_usage_span .title = "近30日:-"
558+ self ._update_token_status ()
511559 if not self ._cfg .get ("hidden" ):
512560 self .title = "错误"
513561
@@ -608,5 +656,38 @@ def _extract_user_id_from_jwt(token: str) -> Optional[str]:
608656 return None
609657
610658
659+ def _extract_exp_from_jwt (token : str ) -> Optional [int ]:
660+ try :
661+ parts = token .split ("." )
662+ if len (parts ) != 3 :
663+ return None
664+ payload_b64 = parts [1 ]
665+ pad = '=' * ((4 - len (payload_b64 ) % 4 ) % 4 )
666+ payload_json = base64 .urlsafe_b64decode ((payload_b64 + pad ).encode ("utf-8" )).decode ("utf-8" )
667+ payload = json .loads (payload_json )
668+ exp = payload .get ("exp" )
669+ return int (exp ) if exp is not None else None
670+ except Exception :
671+ return None
672+
673+
674+ def _fmt_remaining (sec : int ) -> str :
675+ try :
676+ if sec <= 0 :
677+ return "已过期"
678+ days = sec // 86400
679+ sec %= 86400
680+ hours = sec // 3600
681+ sec %= 3600
682+ minutes = sec // 60
683+ if days > 0 :
684+ return f"剩余{ days } 天{ hours } 小时"
685+ if hours > 0 :
686+ return f"剩余{ hours } 小时{ minutes } 分钟"
687+ return f"剩余{ minutes } 分钟"
688+ except Exception :
689+ return "-"
690+
691+
611692if __name__ == "__main__" :
612693 PackycodeStatusApp ().run ()
0 commit comments