@@ -282,6 +282,151 @@ async function querySub2Api(station) {
282282 return { ...parseSub2ApiMe ( data ) , todayUsed, todayRequests, todayTokens, tokensChanged } ;
283283}
284284
285+ // ---- 用量明细(分模型 / 分时间)--------------------------------------------
286+
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" ) } ` ;
290+ }
291+
292+ // 尝试把提供商返回的时间标签解析成时间戳;解析失败返回 null,前端按原始标签展示
293+ function parseDateLabel ( s ) {
294+ 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 ;
298+ }
299+
300+ // 取可用的 Sub2API bearer;密码模式沿用 s2Tokens 缓存并自动登录/刷新
301+ async function sub2apiBearer ( station , base , force = false ) {
302+ const passwordMode = station . type === "sub2api-password" || station . authMode === "password" ;
303+ if ( ! passwordMode ) {
304+ const t = String ( station . accessToken || station . apiKey || "" ) . trim ( ) ;
305+ if ( ! t ) throw new Error ( "缺少登录令牌 (Sub2API JWT)" ) ;
306+ return t ;
307+ }
308+ const tokens = ( station . s2Tokens ||= { } ) ;
309+ if ( ! force && tokens . accessToken && tokens . expiresAt &&
310+ tokens . expiresAt - Date . now ( ) > TOKEN_REFRESH_BUFFER_MS ) {
311+ return tokens . accessToken ;
312+ }
313+ if ( force ) tokens . accessToken = "" ;
314+ if ( ! ( await sub2apiRefresh ( base , tokens ) ) ) await sub2apiLogin ( base , station , tokens ) ;
315+ return tokens . accessToken ;
316+ }
317+
318+ function normSub2Models ( models ) {
319+ return ( models || [ ] ) . map ( ( m ) => ( {
320+ model : m . model || "unknown" ,
321+ tokens : num ( m . total_tokens ) ,
322+ cost : num ( m . actual_cost ?? m . cost ) ,
323+ requests : num ( m . requests ) ,
324+ inputTokens : num ( m . input_tokens ) ,
325+ outputTokens : num ( m . output_tokens ) ,
326+ } ) ) . sort ( ( a , b ) => b . tokens - a . tokens ) ;
327+ }
328+
329+ function normSub2Trend ( trend ) {
330+ return ( trend || [ ] ) . map ( ( p ) => ( {
331+ t : parseDateLabel ( p . date ) ,
332+ label : String ( p . date ?? "" ) ,
333+ tokens : num ( p . total_tokens ) ,
334+ cost : num ( p . actual_cost ?? p . cost ) ,
335+ requests : num ( p . requests ) ,
336+ } ) ) ;
337+ }
338+
339+ async function sub2apiUsage ( station , { startMs, endMs, granularity, tz } ) {
340+ const base = trimBase ( station . baseUrl ) ;
341+ if ( ! base ) throw new Error ( "缺少站点地址" ) ;
342+ const params = {
343+ start_date : localDateStr ( startMs ) ,
344+ end_date : localDateStr ( endMs ) ,
345+ granularity,
346+ timezone : tz ,
347+ } ;
348+ const get = async ( path , extra ) => {
349+ let bearer = await sub2apiBearer ( station , base ) ;
350+ let r = await request ( `${ base } ${ path } ?${ new URLSearchParams ( { ...params , ...extra } ) } ` , {
351+ headers : { Authorization : `Bearer ${ bearer } ` } ,
352+ } ) ;
353+ if ( r . status === 401 && station . type === "sub2api-password" ) {
354+ bearer = await sub2apiBearer ( station , base , true ) ;
355+ r = await request ( `${ base } ${ path } ?${ new URLSearchParams ( { ...params , ...extra } ) } ` , {
356+ headers : { Authorization : `Bearer ${ bearer } ` } ,
357+ } ) ;
358+ }
359+ return r ;
360+ } ;
361+
362+ // 首选 snapshot-v2 一次拿全;老版本站点回退到 trend + models 两个接口
363+ const snap = await get ( "/api/v1/usage/dashboard/snapshot-v2" , {
364+ include_trend : "true" , include_model_stats : "true" ,
365+ } ) ;
366+ if ( snap . status < 300 && snap . body ?. code === 0 && snap . body . data ) {
367+ const d = snap . body . data ;
368+ return { models : normSub2Models ( d . models ) , trend : normSub2Trend ( d . trend ) } ;
369+ }
370+ const [ tr , mo ] = await Promise . all ( [
371+ get ( "/api/v1/usage/dashboard/trend" ) ,
372+ get ( "/api/v1/usage/dashboard/models" ) ,
373+ ] ) ;
374+ 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+ } ;
379+ }
380+
381+ async function newApiUsage ( station , { startMs, endMs } ) {
382+ const base = trimBase ( station . baseUrl ) ;
383+ const token = String ( station . accessToken || "" ) . trim ( ) ;
384+ if ( ! base || ! token ) throw new Error ( "缺少站点地址或访问令牌" ) ;
385+ const headers = { Authorization : token } ;
386+ const userId = String ( station . userId || "" ) . trim ( ) ;
387+ if ( userId ) headers [ "New-Api-User" ] = userId ;
388+
389+ const qs = `start_timestamp=${ Math . floor ( startMs / 1000 ) } &end_timestamp=${ Math . ceil ( endMs / 1000 ) } ` ;
390+ const r = await request ( `${ base } /api/data/self?${ qs } ` , { headers } ) ;
391+ if ( r . status >= 300 ) throw new Error ( httpErrorMessage ( r ) ) ;
392+ if ( r . body ?. success === false ) throw new Error ( r . body ?. message || "站点未开启数据看板" ) ;
393+ const rows = Array . isArray ( r . body ?. data ) ? r . body . data : null ;
394+ if ( ! rows ) throw new Error ( "站点未开启数据看板(DataExportEnabled)" ) ;
395+
396+ const models = new Map ( ) , trend = new Map ( ) ;
397+ for ( const row of rows ) {
398+ const tokens = num ( row . token_used ) ;
399+ const cost = num ( row . quota ) / QUOTA_PER_UNIT ;
400+ const reqs = num ( row . count ) ;
401+ const name = row . model_name || "unknown" ;
402+ const m = models . get ( name ) || { model : name , tokens : 0 , cost : 0 , requests : 0 , inputTokens : null , outputTokens : null } ;
403+ m . tokens += tokens ; m . cost += cost ; m . requests += reqs ;
404+ models . set ( name , m ) ;
405+ const t = num ( row . created_at ) * 1000 ;
406+ const tr = trend . get ( t ) || { t, label : "" , tokens : 0 , cost : 0 , requests : 0 } ;
407+ tr . tokens += tokens ; tr . cost += cost ; tr . requests += reqs ;
408+ trend . set ( t , tr ) ;
409+ }
410+ return {
411+ models : [ ...models . values ( ) ] . sort ( ( a , b ) => b . tokens - a . tokens ) ,
412+ trend : [ ...trend . values ( ) ] . sort ( ( a , b ) => a . t - b . t ) ,
413+ } ;
414+ }
415+
416+ /**
417+ * 查询站点的用量明细(分模型 + 分时间),返回 {models, trend}。
418+ * 与余额查询不同,出错直接 throw,由调用方汇总每个站点的错误。
419+ */
420+ export async function queryStationUsage ( station , opts ) {
421+ switch ( station . type ) {
422+ case "newapi" : return newApiUsage ( station , opts ) ;
423+ case "newapi-key" : throw new Error ( "sk 密钥模式没有用量明细接口" ) ;
424+ case "sub2api" :
425+ case "sub2api-password" : return sub2apiUsage ( station , opts ) ;
426+ default : throw new Error ( `未知的中转站类型:${ station . type } ` ) ;
427+ }
428+ }
429+
285430const HANDLERS = {
286431 newapi : queryNewApi ,
287432 "newapi-key" : queryNewApiKey ,
0 commit comments