@@ -884,7 +884,135 @@ async function buildDailyReport() {
884884 L . push ( `未来 7 天预计:≈${ rptCny ( fc . nextTotal * ownRate ) } (区间 ${ rptCny ( ( fc . nextLo ?? 0 ) * ownRate ) } ~ ${ rptCny ( ( fc . nextHi ?? 0 ) * ownRate ) } )` ) ;
885885 L . push ( `(${ fc . method } · 基于 ${ fc . sampleDays } 天 · 回测日均偏差 ±${ fc . backtestWapePct ?? "?" } %)` ) ;
886886 }
887- return { title : `【日报】${ own . name } ${ dateLabel } ` , text : L . join ( "\n" ) } ;
887+ const html = buildReportHtml ( {
888+ own, dateLabel, dow, ownRate,
889+ totalCost, prev, avg7, incomeUsd, adminUsd, profit,
890+ totalReqs, totalTokens, activeUsers : byUser . length ,
891+ byModel, byUser, daily, fc, upstreams, balanceTotal,
892+ userCount : ( ownUsers || [ ] ) . filter ( ( u ) => u . role < 10 ) . length , hasUsers : ! ! ownUsers ,
893+ } ) ;
894+ return { title : `【日报】${ own . name } ${ dateLabel } ` , text : L . join ( "\n" ) , html } ;
895+ }
896+
897+ // 邮件安全的 HTML 日报:全内联样式 + 表格排版,图表用色块/条形实现
898+ //(Gmail 会剥离 SVG、屏蔽 data:URI 图片,这是全客户端可靠的做法)
899+ function buildReportHtml ( d ) {
900+ const esc = ( s ) => String ( s ?? "" ) . replace ( / [ & < > " ' ] / g, ( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ c ] ) ) ;
901+ const C = { ink : "#1c1c1e" , sub : "#6e6e73" , line : "#e5e5ea" , blue : "#0a84ff" , blueSoft : "#b9d6f8" , green : "#1f9d4d" , red : "#d03b3b" , track : "#eef1f5" , bg : "#f5f6f8" } ;
902+ const font = "font-family:-apple-system,'PingFang SC','Segoe UI',sans-serif;" ;
903+ const money = ( v ) => rptCny ( v ) ;
904+
905+ const kpi = ( label , value , sub , color ) => `
906+ <td width="25%" style="padding:6px"><div style="background:#fff;border:1px solid ${ C . line } ;border-radius:10px;padding:12px 14px">
907+ <div style="${ font } font-size:11px;color:${ C . sub } ">${ esc ( label ) } </div>
908+ <div style="${ font } font-size:20px;font-weight:700;color:${ color || C . ink } ;margin-top:2px">${ esc ( value ) } </div>
909+ ${ sub ? `<div style="${ font } font-size:11px;color:${ C . sub } ;margin-top:2px">${ esc ( sub ) } </div>` : "" }
910+ </div></td>` ;
911+
912+ const section = ( title , inner ) => `
913+ <tr><td style="padding:18px 12px 6px;${ font } font-size:14px;font-weight:700;color:${ C . ink } ">${ esc ( title ) } </td></tr>
914+ <tr><td style="padding:0 12px">${ inner } </td></tr>` ;
915+
916+ // 14 天消费柱状图(表格 + 色块;昨日高亮)
917+ const days = d . daily . slice ( - 14 ) ;
918+ const maxDay = Math . max ( ...days . map ( ( x ) => x . cost ) , 0.01 ) ;
919+ const trendCols = days . map ( ( x , i ) => {
920+ const h = Math . max ( 3 , Math . round ( ( x . cost / maxDay ) * 72 ) ) ;
921+ const last = i === days . length - 1 ;
922+ return `<td align="center" valign="bottom" style="padding:0 2px">
923+ <div title="${ esc ( money ( x . cost * d . ownRate ) ) } " style="height:${ h } px;background:${ last ? C . blue : C . blueSoft } ;border-radius:3px 3px 0 0"></div>
924+ </td>` ;
925+ } ) . join ( "" ) ;
926+ const fmtMD = ( t ) => { const dd = new Date ( t ) ; return `${ dd . getMonth ( ) + 1 } /${ dd . getDate ( ) } ` ; } ;
927+ const trend = days . length ? `
928+ <div style="background:#fff;border:1px solid ${ C . line } ;border-radius:10px;padding:14px">
929+ <table width="100%" cellpadding="0" cellspacing="0" style="height:76px"><tr>${ trendCols } </tr></table>
930+ <table width="100%" cellpadding="0" cellspacing="0"><tr>
931+ <td style="${ font } font-size:10px;color:${ C . sub } ">${ fmtMD ( days [ 0 ] . t ) } </td>
932+ <td align="right" style="${ font } font-size:10px;color:${ C . sub } ">昨日 ${ fmtMD ( days [ days . length - 1 ] . t ) } · ${ esc ( money ( days [ days . length - 1 ] . cost * d . ownRate ) ) } </td>
933+ </tr></table>
934+ </div>` : "" ;
935+
936+ // 条形榜单(名称 + 比例条 + 数值)
937+ const barList = ( rows , nameOf , valOf , valText ) => {
938+ const max = Math . max ( ...rows . map ( valOf ) , 1e-9 ) ;
939+ return `<div style="background:#fff;border:1px solid ${ C . line } ;border-radius:10px;padding:6px 14px">
940+ ${ rows . map ( ( r ) => `
941+ <table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0"><tr>
942+ <td width="34%" style="${ font } font-size:12px;color:${ C . ink } ;white-space:nowrap;overflow:hidden">${ nameOf ( r ) } </td>
943+ <td style="padding:0 10px"><div style="background:${ C . track } ;border-radius:4px"><div style="width:${ Math . max ( 2 , Math . round ( valOf ( r ) / max * 100 ) ) } %;height:8px;background:${ C . blue } ;border-radius:4px"></div></div></td>
944+ <td width="20%" align="right" style="${ font } font-size:12px;color:${ C . ink } ;font-weight:600;white-space:nowrap">${ valText ( r ) } </td>
945+ </tr></table>` ) . join ( "" ) }
946+ </div>` ;
947+ } ;
948+
949+ const modelBars = d . byModel . length
950+ ? barList ( d . byModel . slice ( 0 , 5 ) , ( m ) => esc ( m . key ) , ( m ) => m . cost , ( m ) => esc ( money ( m . cost * d . ownRate ) ) )
951+ : `<div style="${ font } font-size:12px;color:${ C . sub } ">昨日无消费</div>` ;
952+ const userBars = d . byUser . length
953+ ? barList ( d . byUser . slice ( 0 , 5 ) ,
954+ ( u ) => `${ esc ( u . key ) } ${ u . isAdmin ? ` <span style="color:${ C . red } ;font-size:10px">管理员</span>` : "" } ` ,
955+ ( u ) => u . cost , ( u ) => esc ( money ( u . cost * d . ownRate ) ) )
956+ : `<div style="${ font } font-size:12px;color:${ C . sub } ">昨日无消费</div>` ;
957+
958+ const MODE = { usage : "按用量" , fixed : "固定摊销" , history : "余额推算≈" } ;
959+ const costRows = ( d . profit && ! d . profit . error && d . profit . costs . length )
960+ ? `<div style="background:#fff;border:1px solid ${ C . line } ;border-radius:10px;padding:6px 14px">
961+ ${ d . profit . costs . map ( ( c ) => `
962+ <table width="100%" cellpadding="0" cellspacing="0" style="margin:7px 0"><tr>
963+ <td style="${ font } font-size:12px;color:${ C . ink } ">${ esc ( c . name ) } <span style="color:${ C . sub } ;font-size:11px">${ esc ( MODE [ c . mode ] || c . mode ) } ${ c . note ? " · " + esc ( c . note ) : "" } </span></td>
964+ <td align="right" style="${ font } font-size:12px;font-weight:600;color:${ C . ink } ">${ esc ( money ( c . cny ) ) } </td>
965+ </tr></table>` ) . join ( "" ) }
966+ ${ d . profit . unmatched . length ? `<div style="${ font } font-size:11px;color:${ C . sub } ;margin:6px 0">另有 ${ d . profit . unmatched . length } 组渠道未纳入成本计算</div>` : "" }
967+ </div>` : "" ;
968+
969+ const upRows = `<div style="background:#fff;border:1px solid ${ C . line } ;border-radius:10px;padding:6px 14px">
970+ ${ d . upstreams . map ( ( s ) => {
971+ const rate = s . cnyPerUsd != null && s . cnyPerUsd > 0 ? s . cnyPerUsd : 1 ;
972+ const b = s . balance ;
973+ let status = "尚未查询" , warn = false ;
974+ if ( b && ! b . ok ) { status = `查询失败(${ b . error || "未知" } )` ; warn = true ; }
975+ else if ( b ) {
976+ const p = history . predict ( s . id ) ;
977+ status = p && p . etaDays != null ? `≈${ money ( p . burnPerDay * rate ) } /天 · 预计 ${ fmtEta ( p . etaDays ) } 后耗尽` : "近期无消耗" ;
978+ warn = ! ! ( p && p . etaDays != null && p . etaDays <= 3 ) ;
979+ }
980+ return `<table width="100%" cellpadding="0" cellspacing="0" style="margin:7px 0"><tr>
981+ <td style="${ font } font-size:12px;color:${ C . ink } ">${ esc ( s . name ) } <span style="color:${ warn ? C . red : C . sub } ;font-size:11px">${ warn ? "⚠ " : "" } ${ esc ( status ) } </span></td>
982+ <td align="right" style="${ font } font-size:12px;font-weight:600;color:${ C . ink } ">${ b && b . ok ? esc ( money ( b . remaining * rate ) ) : "—" } </td>
983+ </tr></table>` ;
984+ } ) . join ( "" ) || `<div style="${ font } font-size:12px;color:${ C . sub } ">暂无上游站点</div>` }
985+ </div>` ;
986+
987+ const outlook = d . fc ? `
988+ <div style="background:#fff;border:1px solid ${ C . line } ;border-radius:10px;padding:12px 14px;${ font } font-size:12px;color:${ C . ink } ;line-height:1.9">
989+ 今天预计 <b>≈${ esc ( money ( d . fc . points [ 0 ] . cost * d . ownRate ) ) } </b>(区间 ${ esc ( money ( d . fc . points [ 0 ] . lo * d . ownRate ) ) } ~ ${ esc ( money ( d . fc . points [ 0 ] . hi * d . ownRate ) ) } )<br>
990+ 未来 7 天预计 <b>≈${ esc ( money ( d . fc . nextTotal * d . ownRate ) ) } </b>(区间 ${ esc ( money ( ( d . fc . nextLo ?? 0 ) * d . ownRate ) ) } ~ ${ esc ( money ( ( d . fc . nextHi ?? 0 ) * d . ownRate ) ) } )<br>
991+ <span style="color:${ C . sub } ;font-size:11px">${ esc ( d . fc . method ) } · 基于 ${ d . fc . sampleDays } 天 · 回测日均偏差 ±${ d . fc . backtestWapePct ?? "?" } %</span>
992+ </div>` : "" ;
993+
994+ const profitOk = d . profit && ! d . profit . error ;
995+ return `<!DOCTYPE html><html><body style="margin:0;padding:0;background:${ C . bg } ">
996+ <table width="100%" cellpadding="0" cellspacing="0" style="background:${ C . bg } ;padding:18px 0"><tr><td align="center">
997+ <table width="640" cellpadding="0" cellspacing="0" style="max-width:640px;width:100%">
998+ <tr><td style="padding:6px 12px 2px;${ font } font-size:18px;font-weight:700;color:${ C . ink } ">${ esc ( d . own . name ) } 日报</td></tr>
999+ <tr><td style="padding:0 12px 8px;${ font } font-size:12px;color:${ C . sub } ">${ esc ( d . dateLabel ) } (周${ esc ( d . dow ) } )· 由 relay-monitor 生成</td></tr>
1000+ <tr><td><table width="100%" cellpadding="0" cellspacing="0"><tr>
1001+ ${ kpi ( "昨日消费" , money ( d . totalCost * d . ownRate ) , d . prev != null ? `环比 ${ pctDelta ( d . totalCost , d . prev ) } · 7日均 ${ pctDelta ( d . totalCost , d . avg7 ) } ` : "" , null ) }
1002+ ${ kpi ( "收入(不含管理员)" , money ( d . incomeUsd * d . ownRate ) , d . adminUsd > 0 ? `管理员另耗 ${ money ( d . adminUsd * d . ownRate ) } ` : "" , null ) }
1003+ ${ kpi ( "成本" , profitOk ? money ( d . profit . totalCostCny ) : "—" , "" , null ) }
1004+ ${ kpi ( "利润" , profitOk ? money ( d . profit . profitCny ) : "—" , profitOk && d . profit . marginPct != null ? `利润率 ${ d . profit . marginPct } %` : "" , profitOk && d . profit . profitCny >= 0 ? C . green : C . red ) }
1005+ </tr></table></td></tr>
1006+ ${ section ( "近 14 天消费趋势" , trend ) }
1007+ ${ section ( `用量:请求 ${ d . totalReqs . toLocaleString ( "en-US" ) } 次 · Tokens ${ rptTok ( d . totalTokens ) } · 活跃用户 ${ d . activeUsers } 个` , "" ) }
1008+ ${ section ( "Top 模型(按消费)" , modelBars ) }
1009+ ${ section ( "Top 用户(按消费)" , userBars ) }
1010+ ${ costRows ? section ( "成本明细(昨日)" , costRows ) : "" }
1011+ ${ section ( "上游余额" , upRows ) }
1012+ ${ d . hasUsers ? section ( `用户余额合计:${ money ( d . balanceTotal ) } (预收 · ${ d . userCount } 个用户)` , "" ) : "" }
1013+ ${ outlook ? section ( "展望" , outlook ) : "" }
1014+ <tr><td style="padding:16px 12px;${ font } font-size:11px;color:${ C . sub } ">relay-monitor 每日日报 · 数据截至发送时刻</td></tr>
1015+ </table></td></tr></table></body></html>` ;
8881016}
8891017
8901018function reportChannels ( ) {
@@ -903,8 +1031,8 @@ app.post("/api/report/preview", async (req, res) => {
9031031
9041032app . post ( "/api/report/send" , async ( req , res ) => {
9051033 try {
906- const { title, text } = await buildDailyReport ( ) ;
907- const results = await broadcast ( reportChannels ( ) , title , text , { event : "daily-report" } ) ;
1034+ const { title, text, html } = await buildDailyReport ( ) ;
1035+ const results = await broadcast ( reportChannels ( ) , title , text , { event : "daily-report" , html } ) ;
9081036 res . json ( { ok : true , results } ) ;
9091037 } catch ( err ) {
9101038 res . status ( 400 ) . json ( { error : err ?. message || String ( err ) } ) ;
@@ -922,8 +1050,8 @@ setInterval(async () => {
9221050 cfg . lastSent = today ; // 先占位,避免同一分钟重复发送
9231051 await store . save ( ) ;
9241052 try {
925- const { title, text } = await buildDailyReport ( ) ;
926- await broadcast ( reportChannels ( ) , title , text , { event : "daily-report" } ) ;
1053+ const { title, text, html } = await buildDailyReport ( ) ;
1054+ await broadcast ( reportChannels ( ) , title , text , { event : "daily-report" , html } ) ;
9271055 console . log ( `日报已发送(${ today } ${ cfg . time } )` ) ;
9281056 } catch ( err ) {
9291057 console . error ( "日报发送失败:" , err ?. message ) ;
0 commit comments