mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
200 lines
5.4 KiB
TypeScript
200 lines
5.4 KiB
TypeScript
import i18n from '@/i18n';
|
|
|
|
export const LATENCY_SOURCE_FIELD = 'latency_ms';
|
|
export const LATENCY_SOURCE_UNIT = 'ms';
|
|
|
|
export interface LatencyStats {
|
|
averageMs: number | null;
|
|
totalMs: number | null;
|
|
sampleCount: number;
|
|
}
|
|
|
|
export interface DurationFormatOptions {
|
|
maxUnits?: number;
|
|
invalidText?: string;
|
|
secondDecimals?: number | 'auto';
|
|
locale?: string;
|
|
}
|
|
|
|
export interface LatencyAccumulator {
|
|
totalMs: number;
|
|
sampleCount: number;
|
|
}
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
|
|
const normalizeDurationMaxUnits = (value: number | undefined): number => {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return 2;
|
|
}
|
|
return Math.min(Math.floor(parsed), 4);
|
|
};
|
|
|
|
const resolveSecondDecimalPlaces = (
|
|
seconds: number,
|
|
secondDecimals: number | 'auto' | undefined
|
|
): number => {
|
|
if (secondDecimals === 'auto' || secondDecimals === undefined) {
|
|
return seconds < 10 ? 2 : 1;
|
|
}
|
|
|
|
const parsed = Math.floor(Number(secondDecimals));
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return seconds < 10 ? 2 : 1;
|
|
}
|
|
return Math.min(parsed, 3);
|
|
};
|
|
|
|
const resolveDurationLocale = (locale?: string): string | undefined =>
|
|
locale?.trim() || i18n.resolvedLanguage || i18n.language || undefined;
|
|
|
|
const formatDurationNumber = (
|
|
value: number,
|
|
locale: string | undefined,
|
|
options: Intl.NumberFormatOptions = {}
|
|
): string => {
|
|
try {
|
|
return new Intl.NumberFormat(locale, {
|
|
useGrouping: false,
|
|
...options,
|
|
}).format(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
};
|
|
|
|
const getDurationUnitLabel = (unit: 'd' | 'h' | 'm' | 's' | 'ms'): string =>
|
|
i18n.t(`usage_stats.duration_unit_${unit}`, { defaultValue: unit });
|
|
|
|
const formatDurationPart = (
|
|
value: number,
|
|
unit: 'd' | 'h' | 'm' | 's' | 'ms',
|
|
locale: string | undefined,
|
|
options: Intl.NumberFormatOptions = {}
|
|
): string => `${formatDurationNumber(value, locale, options)}${getDurationUnitLabel(unit)}`;
|
|
|
|
/**
|
|
* 从后端字段 latency_ms 提取耗时,并按毫秒解释。
|
|
*/
|
|
export function extractLatencyMs(detail: unknown): number | null {
|
|
const record = isRecord(detail) ? detail : null;
|
|
const rawValue = record?.[LATENCY_SOURCE_FIELD];
|
|
if (
|
|
rawValue === null ||
|
|
rawValue === undefined ||
|
|
(typeof rawValue === 'string' && rawValue.trim() === '')
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = Number(rawValue);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export const createLatencyAccumulator = (): LatencyAccumulator => ({
|
|
totalMs: 0,
|
|
sampleCount: 0,
|
|
});
|
|
|
|
export const addLatencySample = (
|
|
accumulator: LatencyAccumulator,
|
|
latencyMs: number | null | undefined
|
|
): void => {
|
|
if (latencyMs === null || latencyMs === undefined) {
|
|
return;
|
|
}
|
|
|
|
const parsed = Number(latencyMs);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return;
|
|
}
|
|
|
|
accumulator.totalMs += parsed;
|
|
accumulator.sampleCount += 1;
|
|
};
|
|
|
|
export const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({
|
|
averageMs: accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null,
|
|
totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null,
|
|
sampleCount: accumulator.sampleCount,
|
|
});
|
|
|
|
/**
|
|
* 从明细列表计算耗时统计
|
|
*/
|
|
export function calculateLatencyStatsFromDetails(details: Iterable<unknown>): LatencyStats {
|
|
const accumulator = createLatencyAccumulator();
|
|
for (const detail of details) {
|
|
addLatencySample(accumulator, extractLatencyMs(detail));
|
|
}
|
|
return finalizeLatencyStats(accumulator);
|
|
}
|
|
|
|
/**
|
|
* 按当前语言格式化耗时显示。
|
|
*/
|
|
export function formatDurationMs(
|
|
value: number | null | undefined,
|
|
options: DurationFormatOptions = {}
|
|
): string {
|
|
const invalidText = options.invalidText ?? '--';
|
|
if (value === null || value === undefined) {
|
|
return invalidText;
|
|
}
|
|
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return invalidText;
|
|
}
|
|
|
|
const locale = resolveDurationLocale(options.locale);
|
|
|
|
if (parsed < 1000) {
|
|
return formatDurationPart(Math.round(parsed), 'ms', locale);
|
|
}
|
|
|
|
const seconds = parsed / 1000;
|
|
if (seconds < 60) {
|
|
const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals);
|
|
return formatDurationPart(seconds, 's', locale, {
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: secondDecimalPlaces,
|
|
});
|
|
}
|
|
|
|
const totalSeconds = Math.floor(seconds);
|
|
let remainingSeconds = totalSeconds;
|
|
const days = Math.floor(remainingSeconds / 86_400);
|
|
remainingSeconds -= days * 86_400;
|
|
const hours = Math.floor(remainingSeconds / 3_600);
|
|
remainingSeconds -= hours * 3_600;
|
|
const minutes = Math.floor(remainingSeconds / 60);
|
|
remainingSeconds -= minutes * 60;
|
|
|
|
const parts = [
|
|
{ unit: 'd' as const, value: days },
|
|
{ unit: 'h' as const, value: hours },
|
|
{ unit: 'm' as const, value: minutes },
|
|
{ unit: 's' as const, value: remainingSeconds },
|
|
].filter((part) => part.value > 0);
|
|
|
|
if (!parts.length) {
|
|
return formatDurationPart(0, 's', locale);
|
|
}
|
|
|
|
return parts
|
|
.slice(0, normalizeDurationMaxUnits(options.maxUnits))
|
|
.map((part, index) =>
|
|
formatDurationPart(part.value, part.unit, locale, {
|
|
minimumIntegerDigits: index > 0 && (part.unit === 'm' || part.unit === 's') ? 2 : 1,
|
|
maximumFractionDigits: 0,
|
|
})
|
|
)
|
|
.join(' ');
|
|
}
|