@@ -284,17 +284,43 @@ async function querySub2Api(station) {
284284
285285// ---- 用量明细(分模型 / 分时间)--------------------------------------------
286286
287- function localDateStr ( ms ) {
288- const d = new Date ( ms ) ;
289- return ` ${ d . getFullYear ( ) } - ${ String ( d . getMonth ( ) + 1 ) . padStart ( 2 , "0" ) } - ${ String ( d . getDate ( ) ) . padStart ( 2 , "0" ) } ` ;
287+ // 指定时区下某时刻的 YYYY-MM-DD(en-CA 的日期格式正好是这个)
288+ function dateStrInTz ( ms , tz ) {
289+ return new Intl . DateTimeFormat ( "en-CA" , { timeZone : tz } ) . format ( new Date ( ms ) ) ;
290290}
291291
292- // 尝试把提供商返回的时间标签解析成时间戳;解析失败返回 null,前端按原始标签展示
293- function parseDateLabel ( s ) {
292+ // 某时刻 tz 相对 UTC 的偏移毫秒(东八区为 +8h)
293+ function tzOffsetMs ( tz , at ) {
294+ const p = new Intl . DateTimeFormat ( "en-US" , {
295+ timeZone : tz , hour12 : false ,
296+ year : "numeric" , month : "2-digit" , day : "2-digit" ,
297+ hour : "2-digit" , minute : "2-digit" , second : "2-digit" ,
298+ } ) . formatToParts ( new Date ( at ) ) ;
299+ const get = ( k ) => Number ( p . find ( ( x ) => x . type === k ) . value ) ;
300+ const asUtc = Date . UTC ( get ( "year" ) , get ( "month" ) - 1 , get ( "day" ) , get ( "hour" ) % 24 , get ( "minute" ) , get ( "second" ) ) ;
301+ return asUtc - Math . floor ( at / 1000 ) * 1000 ;
302+ }
303+
304+ // 把「2026-07-13 05:00」这类无时区标签按指定时区解析成时间戳;失败返回 null
305+ function parseDateLabel ( s , tz ) {
294306 if ( ! s ) return null ;
295- let t = Date . parse ( s ) ;
296- if ( Number . isNaN ( t ) ) t = Date . parse ( String ( s ) . replace ( " " , "T" ) ) ;
297- return Number . isNaN ( t ) ? null : t ;
307+ const utc = Date . parse ( String ( s ) . replace ( " " , "T" ) + ( String ( s ) . length <= 10 ? "T00:00:00Z" : "Z" ) ) ;
308+ if ( Number . isNaN ( utc ) ) return null ;
309+ return utc - tzOffsetMs ( tz , utc ) ;
310+ }
311+
312+ // 小时级趋势的时区推断:站点返回的小时标签不带时区,且不同部署对 timezone
313+ // 参数的处理不一致。把「最后一个小时桶 = 当前小时」作为锚点反推偏移,
314+ // 得到真实时间戳(偏移取整到 15 分钟,容忍半点时区)。
315+ function inferHourlyTimes ( trend ) {
316+ let lastNaive = null ;
317+ for ( let i = trend . length - 1 ; i >= 0 ; i -- ) {
318+ if ( trend [ i ] . _naiveUtc != null ) { lastNaive = trend [ i ] . _naiveUtc ; break ; }
319+ }
320+ if ( lastNaive == null ) return trend . map ( ( { _naiveUtc, ...p } ) => ( { ...p , t : null } ) ) ;
321+ const nowHour = Math . floor ( Date . now ( ) / 3600000 ) * 3600000 ;
322+ const offset = Math . round ( ( lastNaive - nowHour ) / 900000 ) * 900000 ;
323+ return trend . map ( ( { _naiveUtc, ...p } ) => ( { ...p , t : _naiveUtc != null ? _naiveUtc - offset : null } ) ) ;
298324}
299325
300326// 取可用的 Sub2API bearer;密码模式沿用 s2Tokens 缓存并自动登录/刷新
@@ -326,25 +352,54 @@ function normSub2Models(models) {
326352 } ) ) . sort ( ( a , b ) => b . tokens - a . tokens ) ;
327353}
328354
329- function normSub2Trend ( trend ) {
330- return ( trend || [ ] ) . map ( ( p ) => ( {
331- t : parseDateLabel ( p . date ) ,
355+ function normSub2Trend ( trend , tz , granularity ) {
356+ const rows = ( trend || [ ] ) . map ( ( p ) => ( {
357+ t : granularity === "hour" ? null : parseDateLabel ( p . date , tz ) ,
358+ _naiveUtc : granularity === "hour"
359+ ? ( Number . isNaN ( Date . parse ( String ( p . date ?? "" ) . replace ( " " , "T" ) + "Z" ) ) ? null : Date . parse ( String ( p . date ) . replace ( " " , "T" ) + "Z" ) )
360+ : null ,
332361 label : String ( p . date ?? "" ) ,
333362 tokens : num ( p . total_tokens ) ,
334363 cost : num ( p . actual_cost ?? p . cost ) ,
335364 requests : num ( p . requests ) ,
336365 } ) ) ;
366+ return granularity === "hour" ? inferHourlyTimes ( rows ) : rows . map ( ( { _naiveUtc, ...p } ) => p ) ;
337367}
338368
339- async function sub2apiUsage ( station , { startMs, endMs, granularity, tz } ) {
369+ async function sub2apiUsage ( station , { startMs, endMs, granularity, tz, exactWindow , wantToday } ) {
340370 const base = trimBase ( station . baseUrl ) ;
341371 if ( ! base ) throw new Error ( "缺少站点地址" ) ;
342372 const params = {
343- start_date : localDateStr ( startMs ) ,
344- end_date : localDateStr ( endMs ) ,
373+ start_date : dateStrInTz ( startMs , tz ) ,
374+ end_date : dateStrInTz ( endMs , tz ) ,
345375 granularity,
346376 timezone : tz ,
347377 } ;
378+ // Sub2API 的用量接口只支持自然日参数:滚动窗口(近 24 小时)时查两天、
379+ // 小时趋势截取到窗口内;模型明细无法按小时截取,标记后由前端注明口径
380+ const finish = async ( models , trend ) => {
381+ const out = {
382+ models,
383+ trend : exactWindow ? trend . filter ( ( p ) => p . t == null || p . t >= startMs - 3600000 ) : trend ,
384+ modelsWindow : exactWindow ? "date" : "exact" ,
385+ } ;
386+ // 「今天」的合计直接取站点仪表盘同款数字(today_actual_cost 等),
387+ // 各部署对 timezone 参数处理不一,这是唯一和站点页面显示逐字一致的口径
388+ if ( wantToday ) {
389+ try {
390+ const st = await get ( "/api/v1/usage/dashboard/stats" , { } ) ;
391+ if ( st . status < 300 && st . body ?. code === 0 && st . body . data ) {
392+ const d = st . body . data ;
393+ out . summary = {
394+ cost : num ( d . today_actual_cost ) ,
395+ tokens : num ( d . today_tokens ) ,
396+ requests : num ( d . today_requests ) ,
397+ } ;
398+ }
399+ } catch { /* 拿不到就用 models 求和兜底 */ }
400+ }
401+ return out ;
402+ } ;
348403 const get = async ( path , extra ) => {
349404 let bearer = await sub2apiBearer ( station , base ) ;
350405 let r = await request ( `${ base } ${ path } ?${ new URLSearchParams ( { ...params , ...extra } ) } ` , {
@@ -365,17 +420,17 @@ async function sub2apiUsage(station, { startMs, endMs, granularity, tz }) {
365420 } ) ;
366421 if ( snap . status < 300 && snap . body ?. code === 0 && snap . body . data ) {
367422 const d = snap . body . data ;
368- return { models : normSub2Models ( d . models ) , trend : normSub2Trend ( d . trend ) } ;
423+ return finish ( normSub2Models ( d . models ) , normSub2Trend ( d . trend , tz , granularity ) ) ;
369424 }
370425 const [ tr , mo ] = await Promise . all ( [
371426 get ( "/api/v1/usage/dashboard/trend" ) ,
372427 get ( "/api/v1/usage/dashboard/models" ) ,
373428 ] ) ;
374429 if ( tr . status >= 300 && mo . status >= 300 ) throw new Error ( httpErrorMessage ( tr ) ) ;
375- return {
376- models : mo . status < 300 && mo . body ?. code === 0 ? normSub2Models ( mo . body . data ?. models ) : [ ] ,
377- trend : tr . status < 300 && tr . body ?. code === 0 ? normSub2Trend ( tr . body . data ?. trend ) : [ ] ,
378- } ;
430+ return finish (
431+ mo . status < 300 && mo . body ?. code === 0 ? normSub2Models ( mo . body . data ?. models ) : [ ] ,
432+ tr . status < 300 && tr . body ?. code === 0 ? normSub2Trend ( tr . body . data ?. trend , tz , granularity ) : [ ]
433+ ) ;
379434}
380435
381436async function newApiUsage ( station , { startMs, endMs } ) {
@@ -407,9 +462,16 @@ async function newApiUsage(station, { startMs, endMs }) {
407462 tr . tokens += tokens ; tr . cost += cost ; tr . requests += reqs ;
408463 trend . set ( t , tr ) ;
409464 }
465+ const modelList = [ ...models . values ( ) ] . sort ( ( a , b ) => b . tokens - a . tokens ) ;
410466 return {
411- models : [ ... models . values ( ) ] . sort ( ( a , b ) => b . tokens - a . tokens ) ,
467+ models : modelList ,
412468 trend : [ ...trend . values ( ) ] . sort ( ( a , b ) => a . t - b . t ) ,
469+ modelsWindow : "exact" , // data/self 按精确时间戳过滤,模型明细与窗口完全一致
470+ summary : {
471+ cost : modelList . reduce ( ( a , m ) => a + m . cost , 0 ) ,
472+ tokens : modelList . reduce ( ( a , m ) => a + m . tokens , 0 ) ,
473+ requests : modelList . reduce ( ( a , m ) => a + m . requests , 0 ) ,
474+ } ,
413475 } ;
414476}
415477
0 commit comments