Skip to content

Commit 430bee1

Browse files
committed
总览行点击弹出余额趋势图;移除迷你图悬浮提示 (v2.0.4)
- 趋势详情弹窗从中转站页提取为共享组件 trend-modal.tsx(历史实线+ 耗尽投影、KPI 行、24h~30天范围切换),两页复用 - 总览站点行整行可点击打开趋势弹窗(v1 本有此交互,v2 移植时遗漏), 行内刷新按钮 stopPropagation 防误触,预测行末尾补「点击查看趋势」; 固定成本站无历史数据不可点(与中转站页一致) - 移除总览迷你走势图外层的 antd Tooltip「近 48 小时余额走势」—— 黑色悬浮框遮挡行内文字且与常驻「近 48h 余额」标签重复
1 parent fea1f73 commit 430bee1

4 files changed

Lines changed: 276 additions & 216 deletions

File tree

app/(dashboard)/page.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
55
import { PageContainer, ProCard } from "@ant-design/pro-components";
66
import LastRefreshed from "./last-refreshed";
7-
import { App, Button, Col, Empty, Row, Segmented, Tag, Tooltip, Typography, theme } from "antd";
7+
import { App, Button, Col, Empty, Row, Segmented, Tag, Typography, theme } from "antd";
88
import { ReloadOutlined } from "@ant-design/icons";
99
import { Line, Bar } from "@ant-design/plots";
1010
import { api, cny, usd, rateOf, fmtTokens, fmtEta, statusOf } from "../../lib/client";
1111
import ChartBox from "./chart-box";
12+
import TrendModal from "./trend-modal";
1213
import { useThemeMode } from "../providers";
1314

1415
const { Text } = Typography;
@@ -151,6 +152,8 @@ export default function OverviewPage() {
151152
const [overviewErr, setOverviewErr] = useState<string | null>(null);
152153
const [refreshingId, setRefreshingId] = useState<string | null>(null);
153154
const [refreshedAt, setRefreshedAt] = useState<number | null>(null);
155+
// 趋势详情弹窗(共享组件 TrendModal,与中转站页点击行为一致)
156+
const [trendStation, setTrendStation] = useState<any>(null);
154157
const hoursRef = useRef(trendHours);
155158
hoursRef.current = trendHours;
156159

@@ -394,8 +397,10 @@ export default function OverviewPage() {
394397
if (s.todayTokens != null) pieces.push(<span key="k">{fmtTokens(s.todayTokens)} tokens</span>);
395398
}
396399
if (eta) pieces.push(<span key="e" style={{ color: clsColor(eta.cls) }}>{eta.text}</span>);
400+
if (pieces.length) pieces.push(<span key="c" style={{ color: token.colorTextSecondary }}>点击查看趋势</span>);
397401
return (
398-
<div key={s.id} style={rowStyle}>
402+
// 整行可点开余额趋势弹窗(同 v1 总览 / 中转站页行为)
403+
<div key={s.id} style={{ ...rowStyle, cursor: "pointer" }} title="查看余额趋势" onClick={() => setTrendStation(s)}>
399404
<div style={plateStyle}>{PLATE[s.type] || "?"}</div>
400405
<div style={{ flex: 1, minWidth: 0 }}>
401406
<div style={{ fontWeight: 600, display: "flex", alignItems: "center", flexWrap: "wrap" }}>
@@ -406,12 +411,10 @@ export default function OverviewPage() {
406411
</div>
407412
<div style={{ fontSize: 12, color: token.colorTextSecondary, marginTop: 2 }}>{meta}</div>
408413
{b && b.ok && s.spark && s.spark.length >= 2 ? (
409-
<Tooltip title="近 48 小时余额走势">
410-
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6 }}>
411-
<SparkSvg pts={s.spark} />
412-
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>近 48h 余额</span>
413-
</div>
414-
</Tooltip>
414+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6 }}>
415+
<SparkSvg pts={s.spark} />
416+
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>近 48h 余额</span>
417+
</div>
415418
) : null}
416419
{pieces.length ? (
417420
<div style={{ fontSize: 12, color: token.colorTextSecondary, marginTop: 4, display: "flex", flexWrap: "wrap", gap: "0 6px" }}>
@@ -431,7 +434,8 @@ export default function OverviewPage() {
431434
size="small"
432435
icon={<ReloadOutlined />}
433436
loading={refreshingId === s.id}
434-
onClick={() => refreshOne(s.id)}
437+
// 行本身可点开趋势弹窗,刷新按钮要拦住冒泡避免误开
438+
onClick={(e) => { e.stopPropagation(); refreshOne(s.id); }}
435439
title="刷新"
436440
/>
437441
</div>
@@ -616,6 +620,13 @@ export default function OverviewPage() {
616620
/>
617621
</ProCard>
618622
)}
623+
624+
{/* 余额趋势详情弹窗(共享组件,中转站页同款);KPI 用列表里的最新站点数据(轮询会更新) */}
625+
<TrendModal
626+
station={trendStation ? stations.find((x) => x.id === trendStation.id) || trendStation : null}
627+
onClose={() => setTrendStation(null)}
628+
etaDaysRule={rules?.etaDays ?? 3}
629+
/>
619630
</PageContainer>
620631
);
621632
}

app/(dashboard)/stations/page.tsx

Lines changed: 7 additions & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,19 @@
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";
66
import { PageContainer, ProCard } from "@ant-design/pro-components";
77
import {
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";
3328
import { Line } from "@ant-design/plots";
34-
import ChartBox from "../chart-box";
29+
import TrendModal from "../trend-modal";
3530
import LastRefreshed from "../last-refreshed";
3631
import dayjs from "dayjs";
3732
import { 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 平移)-------------------------------------------
201115
function 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-
379286
export 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

Comments
 (0)