feat(client/stats): 两周流量折线图加 Y轴刻度 + X轴日期 + 14个数据点
ci-pangolin / Lint — shellcheck (push) Successful in 9s
ci-pangolin / OpenAPI Sync Check (push) Successful in 16s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 5s
ci-pangolin / Flutter — analyze + test (push) Successful in 22s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 8s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Successful in 4m18s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 13s
之前折线太简单(只一条线+末点)。增强 UsageLineChart: - Y 轴:3 条网格 + 左侧 GB 刻度(max/mid/0) - X 轴:隔几天标一个日期(M/D,始终含末点) - 14 个数据点全画出(surface 光晕+accent 实心,末点加大) 传入每点日期(chartDates)。token 色经参数传入(axis=fg3/surface),零硬编码。 统计页 golden 重生成。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -44,10 +44,10 @@ class StatsPage extends ConsumerWidget {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 折线:最后 14 天每日总流量(GB)。
|
// 折线:最后 14 天每日总流量(GB) + 对应日期(X 轴标注)。
|
||||||
final chart = <double>[
|
final chartPts = usage.length > 14 ? usage.sublist(usage.length - 14) : usage;
|
||||||
for (var i = usage.length > 14 ? usage.length - 14 : 0; i < usage.length; i++) usage[i].gbTotal,
|
final chartVals = chartPts.map((p) => p.gbTotal).toList();
|
||||||
];
|
final chartDates = chartPts.map((p) => p.date).toList();
|
||||||
|
|
||||||
final body = RefreshIndicator(
|
final body = RefreshIndicator(
|
||||||
color: c.accent,
|
color: c.accent,
|
||||||
@@ -92,7 +92,14 @@ class StatsPage extends ConsumerWidget {
|
|||||||
Text('GB', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 11)),
|
Text('GB', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 11)),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
UsageLineChart(values: chart, accent: c.accent, grid: c.border),
|
UsageLineChart(
|
||||||
|
values: chartVals,
|
||||||
|
dates: chartDates,
|
||||||
|
accent: c.accent,
|
||||||
|
grid: c.border,
|
||||||
|
axis: c.fg3,
|
||||||
|
surface: c.surface,
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,52 +1,100 @@
|
|||||||
// usage_line_chart.dart — 最近两周(14 天)按天流量折线图。
|
// usage_line_chart.dart — 最近两周(14 天)按天流量折线图。
|
||||||
// CustomPainter 手绘:渐变面积 + accent 折线 + 末点强调,无第三方图表依赖。
|
// CustomPainter 手绘:Y 轴(网格+GB 刻度) + X 轴(隔几天标日期) + 渐变面积 +
|
||||||
|
// accent 折线 + 14 个数据点(末点强调),无第三方图表依赖。
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// values: 每日流量(GB,旧→新)。accent/grid 由调用方传 token 色(零硬编码)。
|
/// values: 每日流量(GB,旧→新);dates: 同长的 "YYYY-MM-DD"(用于 X 轴日期标)。
|
||||||
|
/// accent/grid/axis/surface 由调用方传 token 色(零硬编码)。
|
||||||
class UsageLineChart extends StatelessWidget {
|
class UsageLineChart extends StatelessWidget {
|
||||||
const UsageLineChart({super.key, required this.values, required this.accent, required this.grid});
|
const UsageLineChart({
|
||||||
|
super.key,
|
||||||
|
required this.values,
|
||||||
|
required this.dates,
|
||||||
|
required this.accent,
|
||||||
|
required this.grid,
|
||||||
|
required this.axis,
|
||||||
|
required this.surface,
|
||||||
|
});
|
||||||
|
|
||||||
final List<double> values;
|
final List<double> values;
|
||||||
final Color accent, grid;
|
final List<String> dates;
|
||||||
|
final Color accent, grid, axis, surface;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 116,
|
height: 138,
|
||||||
child: CustomPaint(painter: _LinePainter(values, accent, grid), size: Size.infinite),
|
child: CustomPaint(
|
||||||
|
painter: _LinePainter(values, dates, accent, grid, axis, surface),
|
||||||
|
size: Size.infinite,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LinePainter extends CustomPainter {
|
class _LinePainter extends CustomPainter {
|
||||||
_LinePainter(this.values, this.accent, this.grid);
|
_LinePainter(this.values, this.dates, this.accent, this.grid, this.axis, this.surface);
|
||||||
final List<double> values;
|
final List<double> values;
|
||||||
final Color accent, grid;
|
final List<String> dates;
|
||||||
|
final Color accent, grid, axis, surface;
|
||||||
|
|
||||||
|
// 边距:左留 Y 刻度,下留 X 日期。
|
||||||
|
static const double leftW = 30, bottomH = 16, topPad = 8, rightPad = 6;
|
||||||
|
|
||||||
|
void _label(Canvas c, String s, Offset at, Color color, TextAlign align) {
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: s,
|
||||||
|
style: TextStyle(color: color, fontSize: 9, fontFeatures: const [FontFeature.tabularFigures()]),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
var dx = at.dx;
|
||||||
|
if (align == TextAlign.right) {
|
||||||
|
dx = at.dx - tp.width;
|
||||||
|
} else if (align == TextAlign.center) {
|
||||||
|
dx = at.dx - tp.width / 2;
|
||||||
|
}
|
||||||
|
tp.paint(c, Offset(dx, at.dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _md(String iso) {
|
||||||
|
final p = iso.split('-');
|
||||||
|
if (p.length != 3) return iso;
|
||||||
|
return '${int.tryParse(p[1]) ?? p[1]}/${int.tryParse(p[2]) ?? p[2]}';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final plotL = leftW, plotR = size.width - rightPad, plotT = topPad, plotB = size.height - bottomH;
|
||||||
|
final plotW = plotR - plotL, plotH = plotB - plotT;
|
||||||
|
|
||||||
|
final maxV = values.isEmpty ? 1.0 : values.reduce((a, b) => a > b ? a : b);
|
||||||
|
final denom = maxV <= 0 ? 1.0 : maxV;
|
||||||
|
|
||||||
|
// Y 轴:3 条网格(max / mid / 0) + 左侧 GB 刻度。
|
||||||
final gp = Paint()
|
final gp = Paint()
|
||||||
..color = grid
|
..color = grid
|
||||||
..strokeWidth = 1;
|
..strokeWidth = 1;
|
||||||
for (var i = 1; i <= 3; i++) {
|
for (var k = 0; k <= 2; k++) {
|
||||||
final y = size.height * i / 4;
|
final frac = k / 2.0; // 0=顶(max),1=底(0)
|
||||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), gp);
|
final y = plotT + plotH * frac;
|
||||||
|
canvas.drawLine(Offset(plotL, y), Offset(plotR, y), gp);
|
||||||
|
_label(canvas, (denom * (1 - frac)).toStringAsFixed(1), Offset(plotL - 5, y - 5), axis, TextAlign.right);
|
||||||
}
|
}
|
||||||
if (values.isEmpty) return;
|
if (values.isEmpty) return;
|
||||||
|
|
||||||
final maxV = values.reduce((a, b) => a > b ? a : b);
|
|
||||||
final denom = maxV <= 0 ? 1.0 : maxV;
|
|
||||||
final n = values.length;
|
final n = values.length;
|
||||||
final dx = n > 1 ? size.width / (n - 1) : 0.0;
|
final dx = n > 1 ? plotW / (n - 1) : 0.0;
|
||||||
const topPad = 10.0, botPad = 6.0;
|
Offset pt(int i) => Offset(plotL + i * dx, plotT + plotH * (1 - values[i] / denom));
|
||||||
final h = size.height - topPad - botPad;
|
|
||||||
Offset pt(int i) => Offset(i * dx, topPad + h * (1 - values[i] / denom));
|
|
||||||
|
|
||||||
final area = Path()..moveTo(0, size.height);
|
// 渐变面积
|
||||||
|
final area = Path()..moveTo(plotL, plotB);
|
||||||
for (var i = 0; i < n; i++) {
|
for (var i = 0; i < n; i++) {
|
||||||
area.lineTo(pt(i).dx, pt(i).dy);
|
area.lineTo(pt(i).dx, pt(i).dy);
|
||||||
}
|
}
|
||||||
area
|
area
|
||||||
..lineTo(size.width, size.height)
|
..lineTo(plotR, plotB)
|
||||||
..close();
|
..close();
|
||||||
canvas.drawPath(
|
canvas.drawPath(
|
||||||
area,
|
area,
|
||||||
@@ -54,10 +102,11 @@ class _LinePainter extends CustomPainter {
|
|||||||
..shader = LinearGradient(
|
..shader = LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [accent.withOpacity(0.26), accent.withOpacity(0)],
|
colors: [accent.withOpacity(0.22), accent.withOpacity(0)],
|
||||||
).createShader(Rect.fromLTWH(0, 0, size.width, size.height)),
|
).createShader(Rect.fromLTWH(plotL, plotT, plotW, plotH)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 折线
|
||||||
final line = Path()..moveTo(pt(0).dx, pt(0).dy);
|
final line = Path()..moveTo(pt(0).dx, pt(0).dy);
|
||||||
for (var i = 1; i < n; i++) {
|
for (var i = 1; i < n; i++) {
|
||||||
line.lineTo(pt(i).dx, pt(i).dy);
|
line.lineTo(pt(i).dx, pt(i).dy);
|
||||||
@@ -72,10 +121,26 @@ class _LinePainter extends CustomPainter {
|
|||||||
..strokeCap = StrokeCap.round,
|
..strokeCap = StrokeCap.round,
|
||||||
);
|
);
|
||||||
|
|
||||||
canvas.drawCircle(pt(n - 1), 3.2, Paint()..color = accent);
|
// 14 个数据点:surface 光晕 + accent 实心,末点加大。
|
||||||
|
final halo = Paint()..color = surface;
|
||||||
|
final dot = Paint()..color = accent;
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
final r = i == n - 1 ? 3.6 : 2.6;
|
||||||
|
canvas.drawCircle(pt(i), r + 1.4, halo);
|
||||||
|
canvas.drawCircle(pt(i), r, dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X 轴:隔几天标一个日期(始终含末点)。
|
||||||
|
final step = (n / 4).ceil().clamp(1, n);
|
||||||
|
for (var i = 0; i < n; i += step) {
|
||||||
|
_label(canvas, _md(i < dates.length ? dates[i] : ''), Offset(pt(i).dx, plotB + 3), axis, TextAlign.center);
|
||||||
|
}
|
||||||
|
if ((n - 1) % step != 0 && dates.isNotEmpty) {
|
||||||
|
_label(canvas, _md(dates[n - 1]), Offset(pt(n - 1).dx, plotB + 3), axis, TextAlign.center);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _LinePainter old) =>
|
bool shouldRepaint(covariant _LinePainter old) =>
|
||||||
old.values != values || old.accent != accent || old.grid != grid;
|
old.values != values || old.dates != dates || old.accent != accent;
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB |