mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 14:56:38 +08:00
[优化] go 引用调整
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
const openFlareTokenStorageKey = 'OpenFlare-Token';
|
||||
|
||||
export function getStoredOpenFlareToken() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
return window.localStorage.getItem(openFlareTokenStorageKey) || '';
|
||||
}
|
||||
|
||||
export function setStoredOpenFlareToken(token: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(openFlareTokenStorageKey, token);
|
||||
}
|
||||
|
||||
export function clearStoredOpenFlareToken() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(openFlareTokenStorageKey);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { publicEnv } from '@/lib/env/public-env';
|
||||
import { getStoredOpenFlareToken } from '@/lib/api/auth-token';
|
||||
import type { ApiEnvelope } from '@/types/api';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiUrl(path: string) {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${publicEnv.apiBaseUrl}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, init?: RequestInit) {
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
|
||||
if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (!headers.has('OpenFlare-Token')) {
|
||||
const token = getStoredOpenFlareToken();
|
||||
if (token) {
|
||||
headers.set('OpenFlare-Token', token);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(path), {
|
||||
credentials: 'include',
|
||||
headers,
|
||||
cache: method === 'GET' ? 'no-store' : init?.cache,
|
||||
...init,
|
||||
});
|
||||
|
||||
let payload: ApiEnvelope<T> | null = null;
|
||||
|
||||
try {
|
||||
payload = (await response.json()) as ApiEnvelope<T>;
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
payload?.message || `请求失败(${response.status})`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
throw new ApiError('响应格式无效', response.status);
|
||||
}
|
||||
|
||||
if (!payload.success) {
|
||||
throw new ApiError(payload.message || '请求失败', response.status);
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {NavigationItem} from '@/types/navigation';
|
||||
|
||||
export const dashboardNavigation: NavigationItem[] = [
|
||||
{
|
||||
href: '/',
|
||||
label: '总览',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
href: '/node',
|
||||
label: '节点',
|
||||
icon: 'node',
|
||||
},
|
||||
{
|
||||
href: '/proxy-route',
|
||||
label: '规则',
|
||||
icon: 'proxy',
|
||||
},
|
||||
{
|
||||
href: '/pages',
|
||||
label: 'Pages',
|
||||
icon: 'pages',
|
||||
},
|
||||
{
|
||||
href: '/website',
|
||||
label: '网站',
|
||||
icon: 'website',
|
||||
},
|
||||
{
|
||||
href: '/waf',
|
||||
label: 'WAF',
|
||||
icon: 'waf',
|
||||
},
|
||||
{
|
||||
href: '/origin',
|
||||
label: '源站',
|
||||
icon: 'origin',
|
||||
},
|
||||
{
|
||||
href: '/config-version',
|
||||
label: '发布',
|
||||
icon: 'release',
|
||||
},
|
||||
{
|
||||
href: '/access-log',
|
||||
label: '日志',
|
||||
icon: 'log',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/performance',
|
||||
label: '性能',
|
||||
icon: 'performance',
|
||||
},
|
||||
{
|
||||
href: '/setting',
|
||||
label: '设置',
|
||||
icon: 'setting',
|
||||
},
|
||||
];
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
const publicEnvSchema = z.object({
|
||||
NEXT_PUBLIC_API_BASE_URL: z.string().default('/api'),
|
||||
NEXT_PUBLIC_APP_VERSION: z.string().default('dev'),
|
||||
});
|
||||
|
||||
const rawEnv = {
|
||||
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
|
||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
};
|
||||
|
||||
const parsedEnv = publicEnvSchema.parse(rawEnv);
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
if (value === '/') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export const publicEnv = {
|
||||
apiBaseUrl: normalizeBaseUrl(parsedEnv.NEXT_PUBLIC_API_BASE_URL),
|
||||
appVersion: parsedEnv.NEXT_PUBLIC_APP_VERSION,
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
export const THEME_STORAGE_KEY = 'openflare-theme-mode';
|
||||
export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)';
|
||||
|
||||
export const themeModes = ['light', 'dark', 'system'] as const;
|
||||
|
||||
export type ThemeMode = (typeof themeModes)[number];
|
||||
export type ResolvedTheme = Exclude<ThemeMode, 'system'>;
|
||||
|
||||
export function isThemeMode(
|
||||
value: string | null | undefined,
|
||||
): value is ThemeMode {
|
||||
return themeModes.includes(value as ThemeMode);
|
||||
}
|
||||
|
||||
export function resolveTheme(
|
||||
mode: ThemeMode,
|
||||
systemPrefersDark: boolean,
|
||||
): ResolvedTheme {
|
||||
if (mode === 'system') {
|
||||
return systemPrefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return mode;
|
||||
}
|
||||
|
||||
export function getThemeInitScript() {
|
||||
return `(() => {
|
||||
const storageKey = '${THEME_STORAGE_KEY}';
|
||||
const mediaQuery = '${THEME_MEDIA_QUERY}';
|
||||
const isThemeMode = (value) => value === 'light' || value === 'dark' || value === 'system';
|
||||
const storedValue = window.localStorage.getItem(storageKey);
|
||||
const themeMode = isThemeMode(storedValue) ? storedValue : 'system';
|
||||
const resolvedTheme = themeMode === 'system'
|
||||
? (window.matchMedia(mediaQuery).matches ? 'dark' : 'light')
|
||||
: themeMode;
|
||||
const root = document.documentElement;
|
||||
|
||||
root.dataset.themeMode = themeMode;
|
||||
root.dataset.theme = resolvedTheme;
|
||||
root.style.colorScheme = resolvedTheme;
|
||||
})();`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat('zh-CN', {
|
||||
numeric: 'auto',
|
||||
});
|
||||
|
||||
function toDate(value: string | Date | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedValue =
|
||||
typeof value === 'string' ? value.replace(' ', 'T') : value;
|
||||
const date =
|
||||
normalizedValue instanceof Date
|
||||
? normalizedValue
|
||||
: new Date(normalizedValue);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | Date | null | undefined) {
|
||||
const date = toDate(value);
|
||||
|
||||
if (!date) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return dateTimeFormatter.format(date).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
export function formatRelativeTime(value: string | Date | null | undefined) {
|
||||
const date = toDate(value);
|
||||
|
||||
if (!date) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const diffMs = date.getTime() - Date.now();
|
||||
const diffSeconds = Math.round(diffMs / 1000);
|
||||
const absSeconds = Math.abs(diffSeconds);
|
||||
|
||||
if (absSeconds < 60) {
|
||||
return relativeTimeFormatter.format(diffSeconds, 'second');
|
||||
}
|
||||
|
||||
const diffMinutes = Math.round(diffSeconds / 60);
|
||||
if (Math.abs(diffMinutes) < 60) {
|
||||
return relativeTimeFormatter.format(diffMinutes, 'minute');
|
||||
}
|
||||
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (Math.abs(diffHours) < 24) {
|
||||
return relativeTimeFormatter.format(diffHours, 'hour');
|
||||
}
|
||||
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
if (Math.abs(diffDays) < 30) {
|
||||
return relativeTimeFormatter.format(diffDays, 'day');
|
||||
}
|
||||
|
||||
const diffMonths = Math.round(diffDays / 30);
|
||||
if (Math.abs(diffMonths) < 12) {
|
||||
return relativeTimeFormatter.format(diffMonths, 'month');
|
||||
}
|
||||
|
||||
const diffYears = Math.round(diffMonths / 12);
|
||||
return relativeTimeFormatter.format(diffYears, 'year');
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
const compactNumberFormatter = new Intl.NumberFormat('zh-CN', {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
type FormatBytesOptions = {
|
||||
emptyText?: string;
|
||||
zeroText?: string;
|
||||
};
|
||||
|
||||
export function formatCompactNumber(value?: number | null) {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
return compactNumberFormatter.format(value);
|
||||
}
|
||||
|
||||
export function formatPercent(value?: number | null) {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
return `${formatCompactNumber(value)}%`;
|
||||
}
|
||||
|
||||
export function formatBytes(
|
||||
value?: number | null,
|
||||
options: FormatBytesOptions = {},
|
||||
) {
|
||||
const { emptyText = '—', zeroText = '0 B' } = options;
|
||||
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return emptyText;
|
||||
}
|
||||
if (value <= 0) {
|
||||
return zeroText;
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let current = value;
|
||||
let index = 0;
|
||||
while (current >= 1024 && index < units.length - 1) {
|
||||
current /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const digits = current >= 100 || index === 0 ? 0 : 1;
|
||||
return `${new Intl.NumberFormat('zh-CN', {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(current)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function formatBytesPerSecond(
|
||||
value?: number | null,
|
||||
windowSeconds = 1,
|
||||
options?: FormatBytesOptions,
|
||||
) {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return options?.emptyText ?? '—';
|
||||
}
|
||||
if (windowSeconds <= 0) {
|
||||
return options?.emptyText ?? '—';
|
||||
}
|
||||
return `${formatBytes(value / windowSeconds, options)}/s`;
|
||||
}
|
||||
|
||||
export function calculateNiceAxisMax(values: number[]) {
|
||||
const rawMax = Math.max(
|
||||
0,
|
||||
...values.map((value) => (Number.isFinite(value) && value > 0 ? value : 0)),
|
||||
);
|
||||
|
||||
if (rawMax <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const paddedMax = rawMax * 1.1;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(paddedMax));
|
||||
const normalized = paddedMax / magnitude;
|
||||
const steps = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
|
||||
const niceStep = steps.find((step) => normalized <= step) ?? 10;
|
||||
|
||||
return niceStep * magnitude;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {dashboardNavigation} from '@/lib/constants/navigation';
|
||||
import type {NavigationItem} from '@/types/navigation';
|
||||
|
||||
const navigationPathAliases: Record<string, string> = {
|
||||
'/apply-log': '/node',
|
||||
};
|
||||
|
||||
function normalizeNavigationPath(pathname: string) {
|
||||
return navigationPathAliases[pathname] ?? pathname;
|
||||
}
|
||||
|
||||
export function isPathActive(pathname: string, href: string) {
|
||||
const normalizedPathname = normalizeNavigationPath(pathname);
|
||||
|
||||
if (href === '/') {
|
||||
return normalizedPathname === '/';
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedPathname === href || normalizedPathname.startsWith(`${href}/`)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNavigationItemActive(
|
||||
pathname: string,
|
||||
item: NavigationItem,
|
||||
): boolean {
|
||||
return (
|
||||
isPathActive(pathname, item.href) ||
|
||||
item.children?.some((child) => isNavigationItemActive(pathname, child)) ||
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
export function flattenNavigationItems(
|
||||
items: NavigationItem[],
|
||||
): NavigationItem[] {
|
||||
return items.flatMap((item) => [
|
||||
item,
|
||||
...(item.children ? flattenNavigationItems(item.children) : []),
|
||||
]);
|
||||
}
|
||||
|
||||
export function getCurrentNavigationItem(
|
||||
pathname: string,
|
||||
): NavigationItem | undefined {
|
||||
const findMatch = (items: NavigationItem[]): NavigationItem | undefined => {
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
const childMatch = findMatch(item.children);
|
||||
if (childMatch) {
|
||||
return childMatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPathActive(pathname, item.href)) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return findMatch(dashboardNavigation);
|
||||
}
|
||||
Reference in New Issue
Block a user