[优化] go 引用调整

This commit is contained in:
ryan
2026-06-06 10:26:20 +08:00
parent ee1110b752
commit 3cfefb4367
552 changed files with 1642 additions and 2185 deletions
@@ -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);
}
+65
View File
@@ -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
View File
@@ -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,
};
+42
View File
@@ -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;
})();`;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+76
View File
@@ -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');
}
+83
View File
@@ -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);
}