22// 中转站管理页:站点列表 + 添加/编辑弹窗 + 单站刷新/删除 + 余额趋势详情弹窗
33// 功能对照 v1 app.js:renderStations/stationRow(553-586、193-288)、站点表单弹窗(1487-1614)、
44// 趋势弹窗 openTrend/drawChart(1675-1822)——文案与数字口径逐条对齐,布局用 Pro 风格重排
5- import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
5+ import { useCallback , useEffect , useMemo , useState } from "react" ;
66import { PageContainer , ProCard } from "@ant-design/pro-components" ;
77import {
88 App ,
99 Button ,
1010 Checkbox ,
11- Col ,
1211 DatePicker ,
1312 Empty ,
1413 Form ,
1514 Input ,
1615 Modal ,
17- Row ,
18- Segmented ,
1916 Select ,
2017 Space ,
21- Spin ,
22- Statistic ,
2318 Tag ,
2419 theme ,
2520} from "antd" ;
@@ -31,7 +26,7 @@ import {
3126 CloseOutlined ,
3227} from "@ant-design/icons" ;
3328import { Line } from "@ant-design/plots" ;
34- import ChartBox from "../chart-box " ;
29+ import TrendModal from "../trend-modal " ;
3530import LastRefreshed from "../last-refreshed" ;
3631import dayjs from "dayjs" ;
3732import { api , cny , usd , rateOf , fmtTokens , fmtEta , statusOf } from "../../../lib/client" ;
@@ -116,87 +111,6 @@ function Spark({ pts }: { pts: any[] }) {
116111 ) ;
117112}
118113
119- // ---- 详情弹窗的余额走势图(v1 drawChart 平移:历史实线 + 虚线耗尽投影)---------
120- function TrendChart ( { points, prediction } : { points : any [ ] ; prediction : any } ) {
121- const { dark } = useThemeMode ( ) ;
122- const { token } = theme . useToken ( ) ;
123- const cfg = useMemo ( ( ) => {
124- if ( ! points || points . length < 2 ) return null ;
125- const t0 = points [ 0 ] [ 0 ] ;
126- const lastT = points [ points . length - 1 ] [ 0 ] ;
127- const lastR = points [ points . length - 1 ] [ 1 ] ;
128- // 投影段:最多延伸一个历史窗口的长度,避免把历史压扁(同 v1)
129- let proj : { t : number ; r : number ; hitsZero : boolean } | null = null ;
130- if ( prediction && prediction . burnPerDay > 0 && prediction . etaDays != null ) {
131- const etaMs = new Date ( prediction . etaAt ) . getTime ( ) ;
132- const cap = lastT + Math . max ( lastT - t0 , 3600000 ) ;
133- if ( etaMs <= cap ) proj = { t : etaMs , r : 0 , hitsZero : true } ;
134- else proj = { t : cap , r : Math . max ( lastR - prediction . burnPerDay * ( ( cap - lastT ) / 86400000 ) , 0 ) , hitsZero : false } ;
135- }
136- const data = [
137- ...points . map ( ( p : any ) => ( { date : new Date ( p [ 0 ] ) , v : p [ 1 ] , s : "余额" } ) ) ,
138- ...( proj
139- ? [
140- { date : new Date ( lastT ) , v : lastR , s : "预测" } ,
141- { date : new Date ( proj . t ) , v : proj . r , s : "预测" } ,
142- ]
143- : [ ] ) ,
144- ] ;
145- return {
146- data,
147- xField : "date" ,
148- yField : "v" ,
149- colorField : "s" ,
150- height : 280 ,
151- animate : false ,
152- theme : dark ? "classicDark" : "classic" ,
153- scale : { color : { range : [ "#1677ff" , "#faad14" ] } , y : { domainMin : 0 , nice : true } } ,
154- // 预测段画虚线(G2 折线的 style 回调收到的是该分组的数据数组)
155- style : { lineWidth : 2 , lineDash : ( d : any [ ] ) => ( d [ 0 ] ?. s === "预测" ? [ 5 , 5 ] : null ) } ,
156- axis : {
157- x : { labelFormatter : ( d : any ) => fmtClock ( d ) } ,
158- y : { labelFormatter : ( v : any ) => `¥${ v >= 100 ? Math . round ( v ) : v } ` } ,
159- } ,
160- legend : proj ? undefined : false ,
161- tooltip : {
162- title : ( d : any ) => fmtClock ( d . date ) ,
163- items : [ { channel : "y" , valueFormatter : ( v : any ) => cny ( v ) } ] ,
164- } ,
165- // 耗尽点:投影落到 0 时标红点 + 「预计耗尽」时间
166- annotations : proj ?. hitsZero
167- ? [
168- {
169- type : "point" ,
170- data : [ { date : new Date ( proj . t ) , v : 0 } ] ,
171- encode : { x : "date" , y : "v" } ,
172- style : { fill : COLOR . danger , r : 4 } ,
173- tooltip : false ,
174- } ,
175- {
176- type : "text" ,
177- data : [ { date : new Date ( proj . t ) , v : 0 } ] ,
178- encode : { x : "date" , y : "v" } ,
179- style : { text : `预计耗尽 ${ fmtClock ( proj . t ) } ` , dy : - 10 , dx : - 6 , textAlign : "end" , fill : COLOR . danger , fontSize : 12 } ,
180- tooltip : false ,
181- } ,
182- ]
183- : [ ] ,
184- } as any ;
185- } , [ points , prediction , dark ] ) ;
186-
187- if ( ! cfg )
188- return (
189- < div style = { { height : 280 , display : "flex" , alignItems : "center" , justifyContent : "center" , color : token . colorTextSecondary } } >
190- 数据点不足(需要至少两次成功查询),稍后再来看看
191- </ div >
192- ) ;
193- return (
194- < ChartBox h = { 280 } >
195- < Line { ...cfg } />
196- </ ChartBox >
197- ) ;
198- }
199-
200114// ---- 单行站点(v1 stationRow 平移)-------------------------------------------
201115function StationRow ( props : {
202116 s : any ;
@@ -369,13 +283,6 @@ function StationRow(props: {
369283}
370284
371285// ---- 页面 --------------------------------------------------------------------
372- const RANGES = [
373- { label : "24 小时" , value : 24 } ,
374- { label : "3 天" , value : 72 } ,
375- { label : "7 天" , value : 168 } ,
376- { label : "30 天" , value : 720 } ,
377- ] ;
378-
379286export default function StationsPage ( ) {
380287 const { message, modal } = App . useApp ( ) ;
381288 const { token } = theme . useToken ( ) ;
@@ -398,13 +305,8 @@ export default function StationsPage() {
398305 const [ purchases , setPurchases ] = useState < any [ ] > ( [ ] ) ; // 固定成本付费记录行
399306 const formType = Form . useWatch ( "type" , form ) ;
400307
401- // 趋势详情弹窗
308+ // 趋势详情弹窗(数据拉取与范围切换在共享组件 TrendModal 内)
402309 const [ trendStation , setTrendStation ] = useState < any > ( null ) ;
403- const [ trendHours , setTrendHours ] = useState ( 72 ) ;
404- const [ trendData , setTrendData ] = useState < any > ( null ) ;
405- const [ trendErr , setTrendErr ] = useState ( "" ) ;
406- const [ trendLoading , setTrendLoading ] = useState ( false ) ;
407- const trendSeq = useRef ( 0 ) ; // 丢弃过期响应:快速切换站点/范围时慢的那次不能覆盖后打开的图
408310
409311 // 列表加载(GET /api/stations 同时带回全局设置,同 v1 reload)
410312 const reload = useCallback ( async ( ) => {
@@ -554,38 +456,10 @@ export default function StationsPage() {
554456 }
555457 } ;
556458
557- // ---- 趋势详情弹窗(v1 openTrend 平移,外加范围切换)-------------------------
558- const openTrend = ( s : any ) => {
559- setTrendStation ( s ) ;
560- setTrendHours ( 72 ) ;
561- setTrendData ( null ) ;
562- setTrendErr ( "" ) ;
563- } ;
564- useEffect ( ( ) => {
565- if ( ! trendStation ) return ;
566- const seq = ++ trendSeq . current ;
567- setTrendLoading ( true ) ;
568- setTrendErr ( "" ) ;
569- api ( `/api/stations/${ trendStation . id } /history?hours=${ trendHours } ` )
570- . then ( ( r ) => {
571- if ( seq !== trendSeq . current ) return ;
572- setTrendData ( r ) ;
573- } )
574- . catch ( ( e ) => {
575- if ( seq !== trendSeq . current ) return ;
576- setTrendData ( null ) ;
577- setTrendErr ( e . message ) ;
578- } )
579- . finally ( ( ) => {
580- if ( seq === trendSeq . current ) setTrendLoading ( false ) ;
581- } ) ;
582- } , [ trendStation , trendHours ] ) ;
583-
459+ // ---- 趋势详情弹窗(v1 openTrend 平移,实现见共享组件 TrendModal)-----------
460+ const openTrend = ( s : any ) => setTrendStation ( s ) ;
584461 // 弹窗内 KPI 用列表里的最新站点数据(轮询会更新)
585462 const trendCur = trendStation ? stations . find ( ( x ) => x . id === trendStation . id ) || trendStation : null ;
586- const trendRate = trendCur ? rateOf ( trendCur ) : 1 ;
587- const trendPred = trendData ?. prediction ;
588- const trendEta = etaText ( trendPred , trendRate , rules . etaDays ?? 3 ) ;
589463
590464 // 表单当前类型的凭证需求与可见性(v1 syncCredFields)
591465 const curType = types . find ( ( t ) => t . value === formType ) ;
@@ -758,81 +632,8 @@ export default function StationsPage() {
758632 </ Form >
759633 </ Modal >
760634
761- { /* ---- 趋势详情弹窗 ---- */ }
762- < Modal
763- title = { trendStation ? `余额趋势 · ${ trendStation . name } ` : "" }
764- open = { ! ! trendStation }
765- onCancel = { ( ) => { setTrendStation ( null ) ; trendSeq . current ++ ; } }
766- footer = { null }
767- width = { 760 }
768- >
769- { trendCur && (
770- < >
771- < Row gutter = { [ 12 , 12 ] } style = { { marginBottom : 16 } } >
772- < Col xs = { 12 } sm = { 6 } >
773- < Statistic
774- title = "当前余额"
775- value = { trendCur . balance ?. ok ? cny ( trendCur . balance . remaining * trendRate ) : "—" }
776- />
777- { trendCur . balance ?. ok && trendRate !== 1 ? (
778- < div style = { hint } > 站点余额 { usd ( trendCur . balance . remaining ) } </ div >
779- ) : null }
780- </ Col >
781- < Col xs = { 12 } sm = { 6 } >
782- < Statistic
783- title = "今日消耗"
784- value = {
785- trendCur . todayUsed != null
786- ? ( trendCur . todayIsEstimate ? "≈ " : "" ) + cny ( trendCur . todayUsed * trendRate )
787- : "—"
788- }
789- />
790- { trendCur . todayTokens != null || trendCur . todayRequests != null ? (
791- < div style = { hint } >
792- { [
793- trendCur . todayTokens != null ? fmtTokens ( trendCur . todayTokens ) + " tokens" : null ,
794- trendCur . todayRequests != null ? trendCur . todayRequests . toLocaleString ( "en-US" ) + " 次" : null ,
795- ] . filter ( Boolean ) . join ( " · " ) }
796- </ div >
797- ) : null }
798- </ Col >
799- < Col xs = { 12 } sm = { 6 } >
800- < Statistic
801- title = "日均消耗(估算)"
802- value = { trendPred ?. burnPerDay > 0 ? cny ( trendPred . burnPerDay * trendRate ) : "—" }
803- />
804- </ Col >
805- < Col xs = { 12 } sm = { 6 } >
806- < Statistic
807- title = "预计耗尽"
808- value = { trendPred ?. etaDays != null ? fmtEta ( trendPred . etaDays ) : "—" }
809- valueStyle = {
810- trendEta ?. cls ? { color : trendEta . cls === "danger" ? COLOR . danger : COLOR . warn } : undefined
811- }
812- />
813- </ Col >
814- </ Row >
815- < div style = { { display : "flex" , justifyContent : "flex-end" , marginBottom : 8 , overflowX : "auto" } } >
816- < Segmented options = { RANGES } value = { trendHours } onChange = { ( v ) => setTrendHours ( Number ( v ) ) } />
817- </ div >
818- < Spin spinning = { trendLoading } >
819- { trendErr ? (
820- < div style = { { height : 280 , display : "flex" , alignItems : "center" , justifyContent : "center" , color : token . colorTextSecondary } } >
821- { trendErr }
822- </ div >
823- ) : trendData ? (
824- // 图表纵轴按充值汇率折算成 ¥(耗尽时间等预测不受影响,同 v1)
825- < TrendChart
826- points = { ( trendData . points || [ ] ) . map ( ( p : any ) => [ p [ 0 ] , p [ 1 ] * trendRate ] ) }
827- prediction = { trendPred ? { ...trendPred , burnPerDay : trendPred . burnPerDay * trendRate } : trendPred }
828- />
829- ) : (
830- < div style = { { height : 280 } } />
831- ) }
832- </ Spin >
833- </ >
834- ) }
835- </ Modal >
635+ { /* ---- 趋势详情弹窗(共享组件,总览页同款) ---- */ }
636+ < TrendModal station = { trendCur } onClose = { ( ) => setTrendStation ( null ) } etaDaysRule = { rules . etaDays ?? 3 } />
836637 </ PageContainer >
837638 ) ;
838639}
0 commit comments