This commit is contained in:
ryan
2026-07-13 15:17:45 +08:00
parent 08e61ce833
commit e0eb4e5975
334 changed files with 17212 additions and 12321 deletions
+4 -1
View File
@@ -47,7 +47,10 @@ export async function readApiEnvelope<T>(
const envelope = body as ApiEnvelope<T>;
if (!res.ok) {
throw new ApiEnvelopeError(envelope.error_msg || fallbackMessage, res.status);
throw new ApiEnvelopeError(
envelope.error_msg || fallbackMessage,
res.status,
);
}
if (envelope.error_msg) {
throw new ApiEnvelopeError(envelope.error_msg, res.status);
+6 -4
View File
@@ -1,6 +1,8 @@
import packageJson from "../package.json"
import packageJson from '../package.json';
const rawVersion = process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version
const rawVersion = process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version;
export const APP_VERSION = rawVersion.startsWith('v') ? rawVersion.slice(1) : rawVersion
export const APP_BUILD_DATE = process.env.NEXT_PUBLIC_APP_BUILD_DATE || ""
export const APP_VERSION = rawVersion.startsWith('v')
? rawVersion.slice(1)
: rawVersion;
export const APP_BUILD_DATE = process.env.NEXT_PUBLIC_APP_BUILD_DATE || '';
+1 -1
View File
@@ -7,7 +7,7 @@
// ——— Challenge / Redeem API types ———
import {readApiData} from '@/lib/api-envelope';
import { readApiData } from '@/lib/api-envelope';
export interface ChallengeResponse {
challenge: { c: number; s: number; d: number };
@@ -1,5 +1,9 @@
import {BaseService} from '@/lib/services/core';
import type {AuthSource, AuthSourceRequest, ToggleAuthSourceRequest} from './types';
import { BaseService } from '@/lib/services/core';
import type {
AuthSource,
AuthSourceRequest,
ToggleAuthSourceRequest,
} from './types';
export class AdminAuthSourceService extends BaseService {
protected static readonly basePath = '/api/v1/admin';
@@ -8,19 +12,27 @@ export class AdminAuthSourceService extends BaseService {
return this.get<AuthSource[]>('/auth-sources');
}
static async createAuthSource(request: AuthSourceRequest): Promise<AuthSource> {
static async createAuthSource(
request: AuthSourceRequest,
): Promise<AuthSource> {
return this.post<AuthSource>('/auth-sources', request);
}
static async updateAuthSource(id: string, request: AuthSourceRequest): Promise<AuthSource> {
static async updateAuthSource(
id: string,
request: AuthSourceRequest,
): Promise<AuthSource> {
return this.put<AuthSource>(`/auth-sources/${id}`, request);
}
static async toggleAuthSource(id: string, request: ToggleAuthSourceRequest): Promise<void> {
static async toggleAuthSource(
id: string,
request: ToggleAuthSourceRequest,
): Promise<void> {
return this.put<void>(`/auth-sources/${id}/toggle`, request);
}
static async deleteAuthSource(id: string): Promise<void> {
return this.delete<void>(`/auth-sources/${id}`);
}
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
import {BaseService} from '@/lib/services/core';
import type {CacheConfig, CacheStatus} from './types';
import { BaseService } from '@/lib/services/core';
import type { CacheConfig, CacheStatus } from './types';
export class AdminCacheService extends BaseService {
protected static readonly basePath = '/api/v1/admin';
@@ -15,4 +15,4 @@ export class AdminCacheService extends BaseService {
static async clearCache(): Promise<void> {
return this.post<void>('/cache/clear');
}
}
}
+1 -1
View File
@@ -41,4 +41,4 @@ export type {
StorageDriver,
StorageConfig,
ObjectStorageConfig,
} from './types';
} from './types';
+6 -3
View File
@@ -1,9 +1,12 @@
import {BaseService} from '@/lib/services/core';
import { BaseService } from '@/lib/services/core';
export class AdminLogService extends BaseService {
protected static readonly basePath = '/api/v1/admin';
static async getLogs(cursor: number = 0, limit: number = 200): Promise<{
static async getLogs(
cursor: number = 0,
limit: number = 200,
): Promise<{
lines: Array<{ index: number; data: string }>;
has_more: boolean;
next_cursor: number;
@@ -50,4 +53,4 @@ export class AdminLogService extends BaseService {
}> {
return this.get('/logs/analytics');
}
}
}
@@ -1,5 +1,5 @@
import {BaseService} from '@/lib/services/core';
import type {AppUpdateStatus, SystemStatus} from './types';
import { BaseService } from '@/lib/services/core';
import type { AppUpdateStatus, SystemStatus } from './types';
export class AdminStatusService extends BaseService {
protected static readonly basePath = '/api/v1/admin';
@@ -15,4 +15,4 @@ export class AdminStatusService extends BaseService {
static async applyUpdate(): Promise<void> {
return this.post<void>('/update/apply');
}
}
}
@@ -1,4 +1,4 @@
import {BaseService} from '@/lib/services/core';
import { BaseService } from '@/lib/services/core';
import type {
CreateSystemConfigRequest,
SystemConfig,
@@ -8,11 +8,15 @@ import type {
export class AdminSystemConfigService extends BaseService {
protected static readonly basePath = '/api/v1/admin';
static async createSystemConfig(request: CreateSystemConfigRequest): Promise<void> {
static async createSystemConfig(
request: CreateSystemConfigRequest,
): Promise<void> {
return this.post<void>('/system-configs', request);
}
static async listSystemConfigs(type?: 'system' | 'business'): Promise<SystemConfig[]> {
static async listSystemConfigs(
type?: 'system' | 'business',
): Promise<SystemConfig[]> {
const query = type ? `?type=${type}` : '';
return this.get<SystemConfig[]>(`/system-configs${query}`);
}
@@ -21,7 +25,10 @@ export class AdminSystemConfigService extends BaseService {
return this.get<SystemConfig>(`/system-configs/${key}`);
}
static async updateSystemConfig(key: string, request: UpdateSystemConfigRequest): Promise<void> {
static async updateSystemConfig(
key: string,
request: UpdateSystemConfigRequest,
): Promise<void> {
return this.put<void>(`/system-configs/${key}`, request);
}
@@ -41,4 +48,4 @@ export class AdminSystemConfigService extends BaseService {
static async listUploadTypes(): Promise<string[]> {
return this.get<string[]>('/uploads/types');
}
}
}
+9 -4
View File
@@ -1,4 +1,4 @@
import {BaseService} from '@/lib/services/core';
import { BaseService } from '@/lib/services/core';
import type {
CreateScheduleRequest,
DispatchTaskRequest,
@@ -61,15 +61,20 @@ export class AdminTaskService extends BaseService {
return this.get<Schedule[]>('/tasks/schedules');
}
static async createSchedule(request: CreateScheduleRequest): Promise<Schedule> {
static async createSchedule(
request: CreateScheduleRequest,
): Promise<Schedule> {
return this.post<Schedule>('/tasks/schedules', request);
}
static async updateSchedule(id: string, request: UpdateScheduleRequest): Promise<Schedule> {
static async updateSchedule(
id: string,
request: UpdateScheduleRequest,
): Promise<Schedule> {
return this.put<Schedule>(`/tasks/schedules/${id}`, request);
}
static async deleteSchedule(id: string): Promise<void> {
return this.delete<void>(`/tasks/schedules/${id}`);
}
}
}
@@ -1,5 +1,9 @@
import {BaseService} from '@/lib/services/core';
import type {CreateTemplateRequest, Template, UpdateTemplateRequest} from './types';
import { BaseService } from '@/lib/services/core';
import type {
CreateTemplateRequest,
Template,
UpdateTemplateRequest,
} from './types';
export class AdminTemplateService extends BaseService {
protected static readonly basePath = '/api/v1/admin';
@@ -12,15 +16,20 @@ export class AdminTemplateService extends BaseService {
return this.get<Template>(`/templates/${key}`);
}
static async createTemplate(request: CreateTemplateRequest): Promise<Template> {
static async createTemplate(
request: CreateTemplateRequest,
): Promise<Template> {
return this.post<Template>('/templates', request);
}
static async updateTemplate(key: string, request: UpdateTemplateRequest): Promise<Template> {
static async updateTemplate(
key: string,
request: UpdateTemplateRequest,
): Promise<Template> {
return this.put<Template>(`/templates/${key}`, request);
}
static async deleteTemplate(key: string): Promise<void> {
return this.delete<void>(`/templates/${key}`);
}
}
}
+2 -3
View File
@@ -1,5 +1,3 @@
/**
* 系统配置信息
*/
@@ -178,7 +176,8 @@ export interface DispatchTaskRequest {
payload?: string;
}
export type TaskExecutionStatus = 'pending' | 'running' | 'succeeded' | 'failed';
export type TaskExecutionStatus =
'pending' | 'running' | 'succeeded' | 'failed';
/**
* 任务执行记录
+17 -6
View File
@@ -1,4 +1,4 @@
import {BaseService} from '@/lib/services/core';
import { BaseService } from '@/lib/services/core';
import type {
AdminUser,
CreateUserRequest,
@@ -11,15 +11,23 @@ import type {
export class AdminUserService extends BaseService {
protected static readonly basePath = '/api/v1/admin';
static async listUsers(request: ListUsersRequest): Promise<ListUsersResponse> {
return this.get<ListUsersResponse>('/users', request as unknown as Record<string, unknown>);
static async listUsers(
request: ListUsersRequest,
): Promise<ListUsersResponse> {
return this.get<ListUsersResponse>(
'/users',
request as unknown as Record<string, unknown>,
);
}
static async getUser(id: string): Promise<AdminUser> {
return this.get<AdminUser>(`/users/${id}`);
}
static async updateUserStatus(id: string, request: UpdateUserStatusRequest): Promise<void> {
static async updateUserStatus(
id: string,
request: UpdateUserStatusRequest,
): Promise<void> {
return this.put<void>(`/users/${id}/status`, request);
}
@@ -31,7 +39,10 @@ export class AdminUserService extends BaseService {
return this.delete<void>(`/users/${id}`);
}
static async updateUser(id: string, request: UpdateUserRequest): Promise<void> {
static async updateUser(
id: string,
request: UpdateUserRequest,
): Promise<void> {
return this.put<void>(`/users/${id}`, request);
}
}
}
+49 -12
View File
@@ -1,5 +1,5 @@
import type {InternalAxiosRequestConfig} from 'axios';
import {BaseService} from '@/lib/services/core';
import type { InternalAxiosRequestConfig } from 'axios';
import { BaseService } from '@/lib/services/core';
import type {
AuthSource,
ChangePasswordRequest,
@@ -40,8 +40,13 @@ export class AuthService extends BaseService {
return this.get<AuthSource[]>('/oauth/sources');
}
static async getAuthorizeUrl(source: string, purpose: 'login' | 'bind' = 'login'): Promise<OAuthAuthorizeResponse> {
return this.get<OAuthAuthorizeResponse>(`/oauth/${encodeURIComponent(source)}/authorize?purpose=${purpose}`);
static async getAuthorizeUrl(
source: string,
purpose: 'login' | 'bind' = 'login',
): Promise<OAuthAuthorizeResponse> {
return this.get<OAuthAuthorizeResponse>(
`/oauth/${encodeURIComponent(source)}/authorize?purpose=${purpose}`,
);
}
/**
@@ -64,7 +69,9 @@ export class AuthService extends BaseService {
* }
* ```
*/
static async handleCallback(request: OAuthCallbackRequest): Promise<OAuthCallbackResult> {
static async handleCallback(
request: OAuthCallbackRequest,
): Promise<OAuthCallbackResult> {
return this.post<OAuthCallbackResult>('/oauth/callback', request);
}
@@ -105,16 +112,44 @@ export class AuthService extends BaseService {
await this.get<void>('/oauth/logout');
}
static async login(request: LoginRequest, headers?: Record<string, string>): Promise<User> {
return this.post<User>('/user/login', request, headers ? ({ headers } as unknown as InternalAxiosRequestConfig) : undefined);
static async login(
request: LoginRequest,
headers?: Record<string, string>,
): Promise<User> {
return this.post<User>(
'/user/login',
request,
headers
? ({ headers } as unknown as InternalAxiosRequestConfig)
: undefined,
);
}
static async register(request: RegisterRequest, headers?: Record<string, string>): Promise<User> {
return this.post<User>('/user/register', request, headers ? ({ headers } as unknown as InternalAxiosRequestConfig) : undefined);
static async register(
request: RegisterRequest,
headers?: Record<string, string>,
): Promise<User> {
return this.post<User>(
'/user/register',
request,
headers
? ({ headers } as unknown as InternalAxiosRequestConfig)
: undefined,
);
}
static async sendEmailCode(email: string, scene: 'register' | 'login', headers?: Record<string, string>): Promise<void> {
return this.post<void>('/user/send-email-code', { email, scene }, headers ? ({ headers } as unknown as InternalAxiosRequestConfig) : undefined);
static async sendEmailCode(
email: string,
scene: 'register' | 'login',
headers?: Record<string, string>,
): Promise<void> {
return this.post<void>(
'/user/send-email-code',
{ email, scene },
headers
? ({ headers } as unknown as InternalAxiosRequestConfig)
: undefined,
);
}
static async changePassword(request: ChangePasswordRequest): Promise<void> {
@@ -130,6 +165,8 @@ export class AuthService extends BaseService {
}
static async deleteExternalAccountBinding(id: string): Promise<void> {
return this.post<void>(`/oauth/external-accounts/${encodeURIComponent(id)}/delete`);
return this.post<void>(
`/oauth/external-accounts/${encodeURIComponent(id)}/delete`,
);
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
/**
* 认证服务模块
*
*
* @description
* 提供 OAuth 认证相关的功能,包括:
* - 获取登录 URL
@@ -8,18 +8,18 @@
* - 获取用户信息
* - 用户登出
* - 发起登录流程
*
*
* @example
* ```typescript
* import { AuthService } from '@/lib/services';
*
*
* // 发起登录
* await AuthService.initiateLogin();
*
*
* // 获取用户信息
* const user = await AuthService.getUserInfo();
* console.log('当前用户:', user.username);
*
*
* // 登出
* await AuthService.logout();
* ```
@@ -1,5 +1,5 @@
import {BaseService} from '@/lib/services/core';
import type {PublicConfigResponse} from './types';
import { BaseService } from '@/lib/services/core';
import type { PublicConfigResponse } from './types';
/**
* 配置服务
+4 -4
View File
@@ -1,19 +1,19 @@
/**
* 配置服务模块
*
*
* @description
* 提供系统公共配置相关的功能,包括:
* - 获取公共配置(争议时间窗口等)
* - 获取用户平台等级配置(公开接口)
*
*
* @example
* ```typescript
* import { ConfigService } from '@/lib/services';
*
*
* // 获取公共配置
* const config = await ConfigService.getPublicConfig();
* console.log('争议时间窗口:', config.dispute_time_window_hours, '小时');
*
*
* // 获取用户平台等级配置
* const payConfigs = await ConfigService.getUserPayConfigs();
* console.log('平台等级列表:', payConfigs);
+82 -33
View File
@@ -1,6 +1,11 @@
import axios, {AxiosError, AxiosResponse, CancelTokenSource, InternalAxiosRequestConfig} from 'axios';
import {toast} from 'sonner';
import {apiConfig} from './config';
import axios, {
AxiosError,
AxiosResponse,
CancelTokenSource,
InternalAxiosRequestConfig,
} from 'axios';
import { toast } from 'sonner';
import { apiConfig } from './config';
import {
ApiErrorBase,
ForbiddenError,
@@ -11,7 +16,7 @@ import {
UnauthorizedError,
ValidationError,
} from './errors';
import {ApiError, ApiResponse} from './types';
import { ApiError, ApiResponse } from './types';
/**
* API 客户端实例
@@ -37,20 +42,22 @@ const cancelTokens = new Map<string, CancelTokenSource>();
*/
const pendingRequests = new Map<string, Promise<AxiosResponse<ApiResponse>>>();
/**
* 生成请求的唯一键
* 包含方法、URL 和请求数据的哈希,确保不同参数的请求不会被误取消
*/
function getRequestKey(config: { method?: string; url?: string; data?: unknown }): string {
const baseKey = `${ config.method?.toUpperCase() }_${ config.url }`;
function getRequestKey(config: {
method?: string;
url?: string;
data?: unknown;
}): string {
const baseKey = `${config.method?.toUpperCase()}_${config.url}`;
/* 序列化加入键中 */
if (config.data) {
try {
const dataHash = JSON.stringify(config.data);
return `${ baseKey }_${ dataHash }`;
return `${baseKey}_${dataHash}`;
} catch {
// 失败使用基础键
return baseKey;
@@ -92,7 +99,7 @@ function initiateLogin(currentPath: string): Promise<never> {
window.location.href = loginUrl.toString();
}
return new Promise<never>(() => { });
return new Promise<never>(() => {});
}
/**
@@ -121,7 +128,9 @@ apiClient.interceptors.response.use(
/* 请求被取消时静默处理 */
if (axios.isCancel(error)) {
const cancelError = new Error(error.message || '请求已被取消') as Error & { __CANCEL__?: boolean };
const cancelError = new Error(
error.message || '请求已被取消',
) as Error & { __CANCEL__?: boolean };
cancelError.__CANCEL__ = true;
return Promise.reject(cancelError);
}
@@ -134,7 +143,11 @@ apiClient.interceptors.response.use(
/* 403 权限不足错误 */
if (error.response?.status === 403) {
return Promise.reject(
new ForbiddenError(error.response.data?.error_msg || '权限不足,请过盾后重试', error.response.data?.error_code, error.response.data?.details),
new ForbiddenError(
error.response.data?.error_msg || '权限不足,请过盾后重试',
error.response.data?.error_code,
error.response.data?.details,
),
);
}
@@ -158,17 +171,16 @@ apiClient.interceptors.response.use(
/* 429 速率限制错误 */
if (error.response?.status === 429) {
const retryAfter = error.response.headers?.['retry-after'];
const message = error.response.data?.error_msg ||
`请求过于频繁,请 ${ retryAfter || '稍后' } 秒后重试`;
const message =
error.response.data?.error_msg ||
`请求过于频繁,请 ${retryAfter || '稍后'} 秒后重试`;
toast.error('请求频率限制', {
description: message,
id: 'rate-limit-error',
});
return Promise.reject(
new ApiErrorBase(message, 'RATE_LIMITED', 429),
);
return Promise.reject(new ApiErrorBase(message, 'RATE_LIMITED', 429));
}
/* 5xx 服务器错误 */
@@ -212,9 +224,7 @@ apiClient.interceptors.response.use(
}
/* 兜底错误 */
return Promise.reject(
new ApiErrorBase(error.message || '网络请求失败'),
);
return Promise.reject(new ApiErrorBase(error.message || '网络请求失败'));
},
);
@@ -224,7 +234,7 @@ apiClient.interceptors.response.use(
* @param url - 请求 URL
*/
export function cancelRequest(method: string, url: string): void {
const requestKey = `${ method.toUpperCase() }_${ url }`;
const requestKey = `${method.toUpperCase()}_${url}`;
const source = cancelTokens.get(requestKey);
if (source) {
source.cancel('请求已被手动取消');
@@ -249,18 +259,29 @@ export function cancelAllRequests(): void {
*/
function createRequestMethod(
method: 'get' | 'post' | 'put' | 'patch' | 'delete',
hasBody: boolean
hasBody: boolean,
) {
if (hasBody) {
return <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => {
const requestKey = getRequestKey({ method: method.toUpperCase(), url, data });
return <T = ApiResponse>(
url: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
) => {
const requestKey = getRequestKey({
method: method.toUpperCase(),
url,
data,
});
if (pendingRequests.has(requestKey)) {
return pendingRequests.get(requestKey) as Promise<AxiosResponse<T>>;
}
const promise = apiClient[method]<T>(url, data, config);
pendingRequests.set(requestKey, promise as Promise<AxiosResponse<ApiResponse>>);
pendingRequests.set(
requestKey,
promise as Promise<AxiosResponse<ApiResponse>>,
);
promise.then(
() => pendingRequests.delete(requestKey),
@@ -271,15 +292,25 @@ function createRequestMethod(
};
}
return <T = ApiResponse>(url: string, config?: InternalAxiosRequestConfig) => {
const requestKey = getRequestKey({ method: method.toUpperCase(), url, data: config?.params });
return <T = ApiResponse>(
url: string,
config?: InternalAxiosRequestConfig,
) => {
const requestKey = getRequestKey({
method: method.toUpperCase(),
url,
data: config?.params,
});
if (pendingRequests.has(requestKey)) {
return pendingRequests.get(requestKey) as Promise<AxiosResponse<T>>;
}
const promise = apiClient[method]<T>(url, config);
pendingRequests.set(requestKey, promise as Promise<AxiosResponse<ApiResponse>>);
pendingRequests.set(
requestKey,
promise as Promise<AxiosResponse<ApiResponse>>,
);
promise.then(
() => pendingRequests.delete(requestKey),
@@ -295,11 +326,29 @@ function createRequestMethod(
* 在原有 axios 实例基础上添加请求缓存功能
*/
const wrappedApiClient = {
get: createRequestMethod('get', false) as <T = ApiResponse>(url: string, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
post: createRequestMethod('post', true) as <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
put: createRequestMethod('put', true) as <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
patch: createRequestMethod('patch', true) as <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
delete: createRequestMethod('delete', false) as <T = ApiResponse>(url: string, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
get: createRequestMethod('get', false) as <T = ApiResponse>(
url: string,
config?: InternalAxiosRequestConfig,
) => Promise<AxiosResponse<T>>,
post: createRequestMethod('post', true) as <T = ApiResponse>(
url: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
) => Promise<AxiosResponse<T>>,
put: createRequestMethod('put', true) as <T = ApiResponse>(
url: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
) => Promise<AxiosResponse<T>>,
patch: createRequestMethod('patch', true) as <T = ApiResponse>(
url: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
) => Promise<AxiosResponse<T>>,
delete: createRequestMethod('delete', false) as <T = ApiResponse>(
url: string,
config?: InternalAxiosRequestConfig,
) => Promise<AxiosResponse<T>>,
};
export default wrappedApiClient;
+9 -14
View File
@@ -5,12 +5,12 @@ import { InternalAxiosRequestConfig } from 'axios';
/**
* 服务基类
* 提供通用的 HTTP 方法封装
*
*
* @example
* ```typescript
* class UserService extends BaseService {
* protected static readonly basePath = '/api/v1/users';
*
*
* static async getAll() {
* return this.get<User[]>('/');
* }
@@ -30,7 +30,7 @@ export class BaseService {
* @returns 完整路径
*/
protected static getFullPath(path: string): string {
return `${ this.basePath }${ path }`;
return `${this.basePath}${path}`;
}
/**
@@ -150,18 +150,14 @@ export class BaseService {
* @param url - 完整 URL
* @param params - 查询参数
* @returns 响应数据(不经过 response.data.data 解包)
*
*
* @remarks
* 仅用于不遵循标准响应格式的特殊端点(如 /api.php)
*/
protected static async rawGet<T>(
url: string,
params?: unknown,
): Promise<T> {
const response = await apiClient.get<T>(
url,
{ params } as InternalAxiosRequestConfig,
);
protected static async rawGet<T>(url: string, params?: unknown): Promise<T> {
const response = await apiClient.get<T>(url, {
params,
} as InternalAxiosRequestConfig);
return response.data;
}
@@ -172,7 +168,7 @@ export class BaseService {
* @param data - 请求数据
* @param config - 额外的请求配置
* @returns 响应数据(不经过 response.data.data 解包)
*
*
* @remarks
* 仅用于不遵循标准响应格式的特殊端点(如 /api.php)
*/
@@ -185,4 +181,3 @@ export class BaseService {
return response.data;
}
}
+5 -2
View File
@@ -8,7 +8,11 @@
*/
export function getApiBaseUrl(): string {
if (typeof window === 'undefined') {
return process.env.WAVELET_BACKEND_URL || process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || 'http://localhost:8000';
return (
process.env.WAVELET_BACKEND_URL ||
process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL ||
'http://localhost:8000'
);
}
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || '';
}
@@ -24,4 +28,3 @@ export const apiConfig = {
/** 携带凭证 */
withCredentials: true,
} as const;
+8 -1
View File
@@ -101,5 +101,12 @@ export class ValidationError extends ApiErrorBase {
* @returns 是否为取消错误
*/
export function isCancelError(error: unknown): boolean {
return error !== null && typeof error === 'object' && ('__CANCEL__' in error && (error as { __CANCEL__?: boolean }).__CANCEL__ === true || ('message' in error && (error as { message?: string }).message === '请求已被取消'));
return (
error !== null &&
typeof error === 'object' &&
(('__CANCEL__' in error &&
(error as { __CANCEL__?: boolean }).__CANCEL__ === true) ||
('message' in error &&
(error as { message?: string }).message === '请求已被取消'))
);
}
+5 -2
View File
@@ -3,7 +3,11 @@
* 提供 API 请求的基础设施
*/
export { default as apiClient, cancelRequest, cancelAllRequests } from './api-client';
export {
default as apiClient,
cancelRequest,
cancelAllRequests,
} from './api-client';
export { BaseService } from './base.service';
export { apiConfig } from './config';
export {
@@ -24,4 +28,3 @@ export type {
PaginationResponse,
RequestConfig,
} from './types';
-1
View File
@@ -61,4 +61,3 @@ export interface RequestConfig {
/** 是否携带凭证 */
withCredentials?: boolean;
}
@@ -1,8 +1,12 @@
import type {InternalAxiosRequestConfig} from 'axios';
import type { InternalAxiosRequestConfig } from 'axios';
import apiClient from '@/lib/services/core/api-client';
import {BaseService} from '@/lib/services/core';
import type {DBOverview, ExecuteSQLResponse, TableDataResponse} from './types';
import { BaseService } from '@/lib/services/core';
import type {
DBOverview,
ExecuteSQLResponse,
TableDataResponse,
} from './types';
/**
* 数据库管理服务类
@@ -32,7 +36,10 @@ export class DbManageService extends BaseService {
page: number;
pageSize: number;
}): Promise<TableDataResponse> {
return this.get<TableDataResponse>('/table-data', params as unknown as Record<string, unknown>);
return this.get<TableDataResponse>(
'/table-data',
params as unknown as Record<string, unknown>,
);
}
/**
@@ -51,7 +58,8 @@ export class DbManageService extends BaseService {
responseType: 'blob',
} as InternalAxiosRequestConfig);
const disposition = response.headers['content-disposition'] as string | undefined;
const disposition = response.headers['content-disposition'] as
string | undefined;
let filename = 'wavelet_export';
if (disposition) {
const match = disposition.match(/filename="?([^";]+)"?/);
+5 -1
View File
@@ -1,2 +1,6 @@
export { DbManageService } from './db-manage.service';
export type { DBOverview, TableDataResponse, ExecuteSQLResponse } from './types';
export type {
DBOverview,
TableDataResponse,
ExecuteSQLResponse,
} from './types';
+1 -1
View File
@@ -23,7 +23,7 @@ export interface TableDataResponse {
* 执行自定义 SQL 的响应
*/
export interface ExecuteSQLResponse {
type: "select" | "exec";
type: 'select' | 'exec';
columns?: string[];
results?: Record<string, unknown>[];
affected_rows: number;
+42 -11
View File
@@ -20,12 +20,12 @@ import {
AdminTemplateService,
AdminUserService,
} from './admin';
import {AuthService} from './auth';
import {ConfigService} from './config';
import {DbManageService} from './db-manage';
import {PushService} from './push';
import {AdminUploadService, UploadService} from './upload';
import {UserService} from './user';
import { AuthService } from './auth';
import { ConfigService } from './config';
import { DbManageService } from './db-manage';
import { PushService } from './push';
import { AdminUploadService, UploadService } from './upload';
import { UserService } from './user';
const services = {
auth: AuthService,
@@ -81,7 +81,15 @@ export type {
// ==================== 业务服务导出 ====================
export { AuthService } from './auth';
export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest, AuthSource, ExternalAccountBinding, ChangePasswordRequest, UpdateProfileRequest } from './auth';
export type {
User,
OAuthLoginUrlResponse,
OAuthCallbackRequest,
AuthSource,
ExternalAccountBinding,
ChangePasswordRequest,
UpdateProfileRequest,
} from './auth';
export { ConfigService } from './config';
export type { PublicConfigResponse } from './config';
@@ -136,11 +144,34 @@ export type {
export { UserService } from './user';
export type { AccessToken, CreateTokenResponse } from './user';
export { UploadService, AdminUploadService, formatFileSize, getFileUrl } from './upload';
export type { UploadImageResponse, Upload, ListUploadsResponse, FileStatsResponse, ImageQuality } from './upload';
export {
UploadService,
AdminUploadService,
formatFileSize,
getFileUrl,
} from './upload';
export type {
UploadImageResponse,
Upload,
ListUploadsResponse,
FileStatsResponse,
ImageQuality,
} from './upload';
export { DbManageService } from './db-manage';
export type { DBOverview, TableDataResponse, ExecuteSQLResponse } from './db-manage';
export type {
DBOverview,
TableDataResponse,
ExecuteSQLResponse,
} from './db-manage';
export { PushService } from './push';
export type { PushEvent, PushHistory, PushChannelConfig, ListPushHistoriesRequest, ListPushHistoriesResponse, UpdatePushEventRequest, TestPushRequest } from './push';
export type {
PushEvent,
PushHistory,
PushChannelConfig,
ListPushHistoriesRequest,
ListPushHistoriesResponse,
UpdatePushEventRequest,
TestPushRequest,
} from './push';
+37 -11
View File
@@ -1,7 +1,7 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
import {BaseService} from '@/lib/services/core';
import { BaseService } from '@/lib/services/core';
import type {
ChannelDefinition,
CreateChannelRequest,
@@ -14,7 +14,7 @@ import type {
TestChannelRequest,
TestPushRequest,
UpdateChannelRequest,
UpdatePushEventRequest
UpdatePushEventRequest,
} from './types';
/**
@@ -41,14 +41,23 @@ export class PushService extends BaseService {
* 创建通知事件配置
*/
static async createEvent(data: CreatePushEventRequest): Promise<PushEvent> {
return this.post<PushEvent>('/events', data as unknown as Record<string, unknown>);
return this.post<PushEvent>(
'/events',
data as unknown as Record<string, unknown>,
);
}
/**
* 更新指定通知事件配置
*/
static async updateEvent(id: number, data: UpdatePushEventRequest): Promise<void> {
return this.put<void>(`/events/${id}`, data as unknown as Record<string, unknown>);
static async updateEvent(
id: number,
data: UpdatePushEventRequest,
): Promise<void> {
return this.put<void>(
`/events/${id}`,
data as unknown as Record<string, unknown>,
);
}
/**
@@ -68,8 +77,13 @@ export class PushService extends BaseService {
/**
* 分页查询通知推送历史
*/
static async listHistories(params: ListPushHistoriesRequest): Promise<ListPushHistoriesResponse> {
return this.get<ListPushHistoriesResponse>('/histories', params as unknown as Record<string, unknown>);
static async listHistories(
params: ListPushHistoriesRequest,
): Promise<ListPushHistoriesResponse> {
return this.get<ListPushHistoriesResponse>(
'/histories',
params as unknown as Record<string, unknown>,
);
}
/**
@@ -90,14 +104,23 @@ export class PushService extends BaseService {
* 创建新的消息通道
*/
static async createChannel(data: CreateChannelRequest): Promise<PushChannel> {
return this.post<PushChannel>('/channels', data as unknown as Record<string, unknown>);
return this.post<PushChannel>(
'/channels',
data as unknown as Record<string, unknown>,
);
}
/**
* 更新指定消息通道
*/
static async updateChannel(id: number, data: UpdateChannelRequest): Promise<PushChannel> {
return this.put<PushChannel>(`/channels/${id}`, data as unknown as Record<string, unknown>);
static async updateChannel(
id: number,
data: UpdateChannelRequest,
): Promise<PushChannel> {
return this.put<PushChannel>(
`/channels/${id}`,
data as unknown as Record<string, unknown>,
);
}
/**
@@ -111,7 +134,10 @@ export class PushService extends BaseService {
* 测试消息通道连通性
*/
static async testChannel(data: TestChannelRequest): Promise<void> {
return this.post<void>('/channels/test', data as unknown as Record<string, unknown>);
return this.post<void>(
'/channels/test',
data as unknown as Record<string, unknown>,
);
}
/**
-1
View File
@@ -135,4 +135,3 @@ export interface ChannelDefinition {
description: string;
fields: ChannelFieldDef[];
}
@@ -1,7 +1,7 @@
import type {InternalAxiosRequestConfig} from 'axios';
import type { InternalAxiosRequestConfig } from 'axios';
import {BaseService} from '../core/base.service';
import type {FileStatsResponse, ListUploadsResponse} from './types';
import { BaseService } from '../core/base.service';
import type { FileStatsResponse, ListUploadsResponse } from './types';
export class AdminUploadService extends BaseService {
protected static readonly basePath = '/api/v1/admin/uploads';
@@ -13,7 +13,10 @@ export class AdminUploadService extends BaseService {
type?: string,
extension?: string,
): Promise<ListUploadsResponse> {
const params: Record<string, string | number> = { page, page_size: pageSize };
const params: Record<string, string | number> = {
page,
page_size: pageSize,
};
if (keyword) params.keyword = keyword;
if (type) params.type = type;
if (extension) params.extension = extension;
@@ -37,4 +40,4 @@ export class AdminUploadService extends BaseService {
responseType: 'blob',
} as InternalAxiosRequestConfig);
}
}
}
+6 -1
View File
@@ -2,4 +2,9 @@ export { UploadService } from './upload.service';
export { AdminUploadService } from './admin-upload.service';
export { formatFileSize, getFileUrl } from './utils';
export type { ImageQuality } from './utils';
export type { UploadImageResponse, Upload, ListUploadsResponse, FileStatsResponse } from './types';
export type {
UploadImageResponse,
Upload,
ListUploadsResponse,
FileStatsResponse,
} from './types';
+44 -44
View File
@@ -2,34 +2,34 @@
* 文件上传元数据
*/
export interface UploadMetadata {
width?: number
height?: number
duration?: number
original_mime?: string
user_agent?: string
client_ip?: string
bucket?: string
extra?: Record<string, unknown>
width?: number;
height?: number;
duration?: number;
original_mime?: string;
user_agent?: string;
client_ip?: string;
bucket?: string;
extra?: Record<string, unknown>;
}
/**
* 上传记录
*/
export interface Upload {
id: string
user_id: string
file_name: string
file_path: string
file_size: number
mime_type: string
extension: string
hash: string
type: string
status: string
access_mode: number
metadata: UploadMetadata
created_at: string
updated_at: string
id: string;
user_id: string;
file_name: string;
file_path: string;
file_size: number;
mime_type: string;
extension: string;
hash: string;
type: string;
status: string;
access_mode: number;
metadata: UploadMetadata;
created_at: string;
updated_at: string;
}
/**
@@ -37,55 +37,55 @@ export interface Upload {
*/
export interface UploadImageResponse {
/** 上传记录 ID */
id: string
id: string;
}
/**
* 文件列表查询参数
*/
export interface ListUploadsQuery {
page?: number
page_size?: number
type?: string
extension?: string
keyword?: string
page?: number;
page_size?: number;
type?: string;
extension?: string;
keyword?: string;
}
/**
* 文件列表分页响应
*/
export interface ListUploadsResponse {
total: number
page: number
page_size: number
items: Upload[]
total: number;
page: number;
page_size: number;
items: Upload[];
}
/**
* 近 7 天新增文件趋势项
*/
export interface TrendItem {
date: string
count: number
size: number
date: string;
count: number;
size: number;
}
/**
* 文件分类/类型统计项
*/
export interface DistributionItem {
name: string
count: number
size: number
name: string;
count: number;
size: number;
}
/**
* 文件统计信息响应
*/
export interface FileStatsResponse {
total_count: number
total_size: number
trend: TrendItem[]
categories: DistributionItem[]
types: DistributionItem[]
}
total_count: number;
total_size: number;
trend: TrendItem[];
categories: DistributionItem[];
types: DistributionItem[];
}
+13 -6
View File
@@ -1,7 +1,7 @@
import type {InternalAxiosRequestConfig} from 'axios';
import type { InternalAxiosRequestConfig } from 'axios';
import {BaseService} from '../core/base.service';
import type {ListUploadsResponse, Upload, UploadImageResponse} from './types';
import { BaseService } from '../core/base.service';
import type { ListUploadsResponse, Upload, UploadImageResponse } from './types';
export class UploadService extends BaseService {
protected static readonly basePath = '/api/v1/upload';
@@ -48,7 +48,10 @@ export class UploadService extends BaseService {
type?: string,
extension?: string,
): Promise<ListUploadsResponse> {
const params: Record<string, string | number> = { page, page_size: pageSize };
const params: Record<string, string | number> = {
page,
page_size: pageSize,
};
if (keyword) params.keyword = keyword;
if (type) params.type = type;
if (extension) params.extension = extension;
@@ -59,7 +62,11 @@ export class UploadService extends BaseService {
return this.delete<void>(`/${id}`);
}
static async updateMyFile(id: string, fileName: string, accessMode?: number): Promise<Upload> {
static async updateMyFile(
id: string,
fileName: string,
accessMode?: number,
): Promise<Upload> {
return this.put<Upload>(`/${id}`, {
file_name: fileName,
access_mode: accessMode,
@@ -75,4 +82,4 @@ export class UploadService extends BaseService {
static getDownloadUrl(id: string): string {
return `${this.basePath}/download/${id}`;
}
}
}
+1 -1
View File
@@ -15,4 +15,4 @@ export function formatFileSize(bytes: number): string {
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* 用户服务模块
*
*
* @description
* 提供用户个人设置相关的功能
*/
-2
View File
@@ -1,2 +0,0 @@
+9 -3
View File
@@ -1,4 +1,4 @@
import {BaseService} from '../core/base.service';
import { BaseService } from '../core/base.service';
export interface AccessToken {
id: number;
@@ -34,8 +34,14 @@ export class UserService extends BaseService {
* @param name - 令牌名称
* @param isAdmin - 是否赋予管理员权限(默认 false)
*/
static async createAccessToken(name: string, isAdmin = false): Promise<CreateTokenResponse> {
return this.post<CreateTokenResponse>('/access-tokens', { name, is_admin: isAdmin });
static async createAccessToken(
name: string,
isAdmin = false,
): Promise<CreateTokenResponse> {
return this.post<CreateTokenResponse>('/access-tokens', {
name,
is_admin: isAdmin,
});
}
/**
+5 -2
View File
@@ -10,7 +10,7 @@
* - 不引入任何框架/UI 依赖,纯工具函数。
*/
import type {TaskParam} from '@/lib/services/admin/types';
import type { TaskParam } from '@/lib/services/admin/types';
/** buildTaskPayload 的返回结构 */
export interface BuildPayloadResult {
@@ -32,7 +32,10 @@ export interface BuildPayloadResult {
function coerceParamValue(
param: TaskParam,
rawValue: string,
): { value: string | number | boolean; ok: true } | { value: undefined; ok: true } | { error: string; ok: false } {
):
| { value: string | number | boolean; ok: true }
| { value: undefined; ok: true }
| { error: string; ok: false } {
const trimmed = rawValue.trim();
switch (param.type) {
+4 -4
View File
@@ -1,10 +1,10 @@
import type { ThemeConfig } from "./types"
import type { ThemeConfig } from './types';
/**
* 主题系统配置
* 定义主题文件的存储位置和 localStorage 键名
*/
export const THEME_CONFIG: ThemeConfig = {
styleDir: "public/style",
storageKey: "app-theme-id",
}
styleDir: 'public/style',
storageKey: 'app-theme-id',
};
+67 -54
View File
@@ -1,70 +1,80 @@
"use client"
'use client';
import React, { createContext, useContext, useEffect, useState, useCallback } from "react"
import { useTheme as useNextTheme } from "next-themes"
import type { Theme } from "./types"
import { getStoredThemeId, setStoredThemeId } from "./storage"
import { getAvailableThemes } from "./parser"
import React, {
createContext,
useContext,
useEffect,
useState,
useCallback,
} from 'react';
import { useTheme as useNextTheme } from 'next-themes';
import type { Theme } from './types';
import { getStoredThemeId, setStoredThemeId } from './storage';
import { getAvailableThemes } from './parser';
/**
* 主题上下文类型定义
*/
interface ThemeContextValue {
/** 所有可用主题列表 */
themes: Theme[]
themes: Theme[];
/** 当前激活的主题对象 */
currentTheme: Theme | null
currentTheme: Theme | null;
/** 当前激活的主题 ID */
currentThemeId: string | null
currentThemeId: string | null;
/** 设置主题的方法 */
setTheme: (themeId: string) => void
setTheme: (themeId: string) => void;
/** 是否正在加载主题 */
isLoading: boolean
isLoading: boolean;
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
const ThemeContext = createContext<ThemeContextValue | null>(null);
/**
* 自定义主题 Hook
* 必须在 CustomThemeProvider 内部使用
*/
export function useCustomTheme() {
const context = useContext(ThemeContext)
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useCustomTheme must be used within CustomThemeProvider")
throw new Error('useCustomTheme must be used within CustomThemeProvider');
}
return context
return context;
}
/**
* 自定义主题提供者
* 管理用户选择的界面外观主题(配色方案),独立于明暗模式
*/
export function CustomThemeProvider({ children }: { children: React.ReactNode }) {
const [themes, setThemes] = useState<Theme[]>([])
const [currentThemeId, setCurrentThemeId] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [mounted, setMounted] = useState(false)
const { resolvedTheme } = useNextTheme()
export function CustomThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const [themes, setThemes] = useState<Theme[]>([]);
const [currentThemeId, setCurrentThemeId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [mounted, setMounted] = useState(false);
const { resolvedTheme } = useNextTheme();
/* 加载所有可用主题 */
useEffect(() => {
async function loadThemes() {
const availableThemes = await getAvailableThemes()
const storedId = getStoredThemeId()
const availableThemes = await getAvailableThemes();
const storedId = getStoredThemeId();
setThemes(availableThemes)
setCurrentThemeId(storedId)
setIsLoading(false)
setThemes(availableThemes);
setCurrentThemeId(storedId);
setIsLoading(false);
}
loadThemes()
}, [])
loadThemes();
}, []);
/* 确保组件已挂载,避免水合不一致 */
useEffect(() => {
setMounted(true)
}, [])
setMounted(true);
}, []);
/**
* 将主题颜色应用到 DOM
@@ -72,46 +82,49 @@ export function CustomThemeProvider({ children }: { children: React.ReactNode })
* @param isDark - 是否为暗色模式
*/
const applyThemeColors = useCallback((theme: Theme, isDark: boolean) => {
if (typeof window === "undefined") return
if (typeof window === 'undefined') return;
const root = document.documentElement
const colors = isDark ? theme.colors.dark : theme.colors.light
const root = document.documentElement;
const colors = isDark ? theme.colors.dark : theme.colors.light;
/* 应用所有 CSS 变量到根元素 */
Object.entries(colors).forEach(([key, value]) => {
root.style.setProperty(`--${ key }`, value)
})
}, [])
root.style.setProperty(`--${key}`, value);
});
}, []);
/* 当主题 ID 或模式改变时,重新应用颜色 */
useEffect(() => {
if (!mounted || !currentThemeId || themes.length === 0) return
if (!mounted || !currentThemeId || themes.length === 0) return;
const theme = themes.find(t => t.id === currentThemeId)
if (!theme) return
const theme = themes.find((t) => t.id === currentThemeId);
if (!theme) return;
/* 使用 next-themes 的 resolvedTheme,它会自动处理系统偏好 */
const isDark = resolvedTheme === "dark"
applyThemeColors(theme, isDark)
}, [currentThemeId, resolvedTheme, themes, mounted, applyThemeColors])
const isDark = resolvedTheme === 'dark';
applyThemeColors(theme, isDark);
}, [currentThemeId, resolvedTheme, themes, mounted, applyThemeColors]);
/**
* 设置主题
* @param themeId - 要设置的主题 ID
*/
const setTheme = useCallback((themeId: string) => {
const theme = themes.find(t => t.id === themeId)
if (!theme) return
const setTheme = useCallback(
(themeId: string) => {
const theme = themes.find((t) => t.id === themeId);
if (!theme) return;
setCurrentThemeId(themeId)
setStoredThemeId(themeId)
setCurrentThemeId(themeId);
setStoredThemeId(themeId);
/* 根据当前解析的主题模式计算是否为暗色 */
const isDark = resolvedTheme === "dark"
applyThemeColors(theme, isDark)
}, [themes, resolvedTheme, applyThemeColors])
/* 根据当前解析的主题模式计算是否为暗色 */
const isDark = resolvedTheme === 'dark';
applyThemeColors(theme, isDark);
},
[themes, resolvedTheme, applyThemeColors],
);
const currentTheme = themes.find(t => t.id === currentThemeId) || null
const currentTheme = themes.find((t) => t.id === currentThemeId) || null;
return (
<ThemeContext.Provider
@@ -120,10 +133,10 @@ export function CustomThemeProvider({ children }: { children: React.ReactNode })
currentTheme,
currentThemeId,
setTheme,
isLoading
isLoading,
}}
>
{children}
</ThemeContext.Provider>
)
);
}
+9 -5
View File
@@ -4,16 +4,20 @@
*/
/* 类型定义 */
export type { Theme, ThemeColors, ThemeConfig } from "./types"
export type { Theme, ThemeColors, ThemeConfig } from './types';
/* 配置 */
export { THEME_CONFIG } from "./config"
export { THEME_CONFIG } from './config';
/* 服务端工具 */
export { getAvailableThemes } from "./parser"
export { getAvailableThemes } from './parser';
/* 客户端工具 */
export { getStoredThemeId, setStoredThemeId, removeStoredThemeId } from "./storage"
export {
getStoredThemeId,
setStoredThemeId,
removeStoredThemeId,
} from './storage';
/* React 上下文和 Hooks */
export { CustomThemeProvider, useCustomTheme } from "./context"
export { CustomThemeProvider, useCustomTheme } from './context';
+2 -2
View File
@@ -1,5 +1,5 @@
import type {Theme} from "./types"
import themesData from "./themes.json"
import type { Theme } from './types';
import themesData from './themes.json';
/**
* 获取所有可用主题
+8 -8
View File
@@ -1,6 +1,6 @@
"use client"
'use client';
import { THEME_CONFIG } from "./config"
import { THEME_CONFIG } from './config';
/**
* 客户端主题存储工具
@@ -11,22 +11,22 @@ import { THEME_CONFIG } from "./config"
* 获取存储的主题 ID
*/
export function getStoredThemeId(): string | null {
if (typeof window === "undefined") return null
return localStorage.getItem(THEME_CONFIG.storageKey)
if (typeof window === 'undefined') return null;
return localStorage.getItem(THEME_CONFIG.storageKey);
}
/**
* 保存主题 ID 到本地存储
*/
export function setStoredThemeId(themeId: string): void {
if (typeof window === "undefined") return
localStorage.setItem(THEME_CONFIG.storageKey, themeId)
if (typeof window === 'undefined') return;
localStorage.setItem(THEME_CONFIG.storageKey, themeId);
}
/**
* 移除存储的主题 ID
*/
export function removeStoredThemeId(): void {
if (typeof window === "undefined") return
localStorage.removeItem(THEME_CONFIG.storageKey)
if (typeof window === 'undefined') return;
localStorage.removeItem(THEME_CONFIG.storageKey);
}
+1 -1
View File
@@ -4559,4 +4559,4 @@
}
}
}
]
]
+7 -7
View File
@@ -7,8 +7,8 @@
* 包含明亮和暗色两种模式的 CSS 变量映射
*/
export interface ThemeColors {
light: Record<string, string>
dark: Record<string, string>
light: Record<string, string>;
dark: Record<string, string>;
}
/**
@@ -16,11 +16,11 @@ export interface ThemeColors {
*/
export interface Theme {
/** 主题唯一标识符(CSS 文件名) */
id: string
id: string;
/** 主题显示名称 */
name: string
name: string;
/** 主题颜色配置 */
colors: ThemeColors
colors: ThemeColors;
}
/**
@@ -28,7 +28,7 @@ export interface Theme {
*/
export interface ThemeConfig {
/** CSS 样式文件所在目录 */
styleDir: string
styleDir: string;
/** 主题 ID 在 localStorage 中的存储键名 */
storageKey: string
storageKey: string;
}
+77 -64
View File
@@ -1,13 +1,13 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
export function formatDateTime(dateStr: string | Date) {
try {
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
@@ -15,10 +15,10 @@ export function formatDateTime(dateStr: string | Date) {
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(date)
hour12: false,
}).format(date);
} catch {
return String(dateStr)
return String(dateStr);
}
}
@@ -28,18 +28,21 @@ export function formatDateTime(dateStr: string | Date) {
* @returns 格式化后的日期字符串,如 "2024-01-15T00:00:00+08:00"
*/
export function formatLocalDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${ year }-${ month }-${ day }T${ hours }:${ minutes }:${ seconds }+08:00`
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+08:00`;
}
type Base64Buffer = {
from: (input: string, encoding: 'utf-8') => { toString: (encoding: 'base64') => string }
}
from: (
input: string,
encoding: 'utf-8',
) => { toString: (encoding: 'base64') => string };
};
/**
* Base64 编码
@@ -48,15 +51,17 @@ type Base64Buffer = {
*/
export function encodeBase64(value: string): string {
if (typeof globalThis.btoa === 'function') {
return globalThis.btoa(value)
return globalThis.btoa(value);
}
const bufferConstructor = (globalThis as typeof globalThis & { Buffer?: Base64Buffer }).Buffer
const bufferConstructor = (
globalThis as typeof globalThis & { Buffer?: Base64Buffer }
).Buffer;
if (bufferConstructor) {
return bufferConstructor.from(value, 'utf-8').toString('base64')
return bufferConstructor.from(value, 'utf-8').toString('base64');
}
throw new Error('当前环境不支持 Base64 编码')
throw new Error('当前环境不支持 Base64 编码');
}
/**
@@ -65,80 +70,88 @@ export function encodeBase64(value: string): string {
* @returns 缓存键字符串
*/
export function generateTransactionCacheKey(params: {
types?: string[]
statuses?: string[]
payee_transfer_status?: string
client_id?: string
page?: number
page_size?: number
startTime?: string
endTime?: string
id?: string
order_name?: string
payer_username?: string
payee_username?: string
types?: string[];
statuses?: string[];
payee_transfer_status?: string;
client_id?: string;
page?: number;
page_size?: number;
startTime?: string;
endTime?: string;
id?: string;
order_name?: string;
payer_username?: string;
payee_username?: string;
}): string {
const typesKey = params.types?.length ? params.types.sort().join(',') : 'all'
const statusesKey = params.statuses?.length ? params.statuses.sort().join(',') : 'all'
const transferStatusKey = params.payee_transfer_status || 'all'
const clientIdKey = params.client_id || 'all'
const startTimeKey = params.startTime || 'no-start'
const endTimeKey = params.endTime || 'no-end'
const idKey = params.id || 'no-id'
const orderNameKey = params.order_name || 'no-name'
const payerKey = params.payer_username || 'no-payer'
const payeeKey = params.payee_username || 'no-payee'
const typesKey = params.types?.length ? params.types.sort().join(',') : 'all';
const statusesKey = params.statuses?.length
? params.statuses.sort().join(',')
: 'all';
const transferStatusKey = params.payee_transfer_status || 'all';
const clientIdKey = params.client_id || 'all';
const startTimeKey = params.startTime || 'no-start';
const endTimeKey = params.endTime || 'no-end';
const idKey = params.id || 'no-id';
const orderNameKey = params.order_name || 'no-name';
const payerKey = params.payer_username || 'no-payer';
const payeeKey = params.payee_username || 'no-payee';
return `${ typesKey }_${ statusesKey }_${ transferStatusKey }_${ clientIdKey }_${ params.page }_${ params.page_size }_${ startTimeKey }_${ endTimeKey }_${ idKey }_${ orderNameKey }_${ payerKey }_${ payeeKey }`
return `${typesKey}_${statusesKey}_${transferStatusKey}_${clientIdKey}_${params.page}_${params.page_size}_${startTimeKey}_${endTimeKey}_${idKey}_${orderNameKey}_${payerKey}_${payeeKey}`;
}
/**
* 验证并净化重定向目标 URL,防止 Open Redirect 和 XSS 攻击。
* 只允许以单个斜杠 "/" 开头的相对路径,拒绝 "//"、协议、反斜杠、控制字符等编码变体。
*/
export function safeRedirectTarget(url: string | null | undefined, fallback = "/home"): string {
if (!url) return fallback
export function safeRedirectTarget(
url: string | null | undefined,
fallback = '/home',
): string {
if (!url) return fallback;
// 1. 拒绝包含控制字符、空白字符或反斜杠的 URL
if (/[\u0000-\u001F\u007F-\u009F\s\\]/.test(url)) {
return fallback
return fallback;
}
// 2. 必须以单个 '/' 开头,且不能以 '//' 开头
if (!url.startsWith("/") || url.startsWith("//")) {
return fallback
if (!url.startsWith('/') || url.startsWith('//')) {
return fallback;
}
// 3. 递归 URL 解码并检测潜在的绕过载荷
try {
let decoded = url
let prev = ""
let attempts = 0
while (decoded !== prev && decoded.includes("%") && attempts < 3) {
prev = decoded
decoded = decodeURIComponent(decoded)
attempts++
let decoded = url;
let prev = '';
let attempts = 0;
while (decoded !== prev && decoded.includes('%') && attempts < 3) {
prev = decoded;
decoded = decodeURIComponent(decoded);
attempts++;
}
// 检查解码后的内容是否包含控制字符、空白字符或反斜杠
if (/[\u0000-\u001F\u007F-\u009F\s\\]/.test(decoded)) {
return fallback
return fallback;
}
// 检查解码后的内容是否以单个 '/' 开头,且不能以 '//' 开头
if (!decoded.startsWith("/") || decoded.startsWith("//")) {
return fallback
if (!decoded.startsWith('/') || decoded.startsWith('//')) {
return fallback;
}
// 提取路径部分(即问号 ? 或井号 # 之前的内容),确保其中不含冒号(防止 scheme)、双斜杠或反斜杠
const pathPart = decoded.split(/[?#]/)[0]
if (pathPart.includes(":") || pathPart.includes("//") || pathPart.includes("\\")) {
return fallback
const pathPart = decoded.split(/[?#]/)[0];
if (
pathPart.includes(':') ||
pathPart.includes('//') ||
pathPart.includes('\\')
) {
return fallback;
}
} catch {
return fallback
return fallback;
}
return url
return url;
}
+16 -15
View File
@@ -1,24 +1,24 @@
import { isCancelError } from '@/lib/services/core'
import { toast } from 'sonner'
import { isCancelError } from '@/lib/services/core';
import { toast } from 'sonner';
/**
* 错误处理选项
*/
interface HandleContextErrorOptions {
/** 是否显示 toast 提示 */
showToast?: boolean
showToast?: boolean;
/** 是否在控制台记录错误 */
logError?: boolean
logError?: boolean;
}
/**
* 统一处理 Context 中的错误
*
*
* @param error - 捕获的错误对象
* @param defaultMessage - 默认错误消息
* @param options - 错误处理选项
* @returns 标准化的 Error 对象
*
*
* @example
* ```typescript
* try {
@@ -35,27 +35,28 @@ interface HandleContextErrorOptions {
export function handleContextError(
error: unknown,
defaultMessage: string,
options: HandleContextErrorOptions = {}
options: HandleContextErrorOptions = {},
): Error {
const { showToast = false, logError = true } = options
const { showToast = false, logError = true } = options;
// 取消的请求不算错误
if (isCancelError(error)) {
return new Error('请求已取消')
return new Error('请求已取消');
}
const errorMessage = error instanceof Error ? error.message : defaultMessage
const errorObject = error instanceof Error ? error : new Error(defaultMessage)
const errorMessage = error instanceof Error ? error.message : defaultMessage;
const errorObject =
error instanceof Error ? error : new Error(defaultMessage);
if (logError) {
console.error(defaultMessage, error)
console.error(defaultMessage, error);
}
if (showToast) {
toast.error(defaultMessage, {
description: errorMessage
})
description: errorMessage,
});
}
return errorObject
return errorObject;
}
+72 -42
View File
@@ -1,4 +1,4 @@
import PinyinMatch from 'pinyin-match'
import PinyinMatch from 'pinyin-match';
type MatchResult = [number, number] | false;
type MatchFunction = (input: string, keys: string) => MatchResult;
@@ -30,14 +30,14 @@ const match = ((): MatchFunction | null => {
})();
export interface SearchItem {
id: string
title: string
description: string
url: string
category: 'page' | 'feature' | 'setting' | 'admin'
keywords: string[]
icon?: string
matchRange?: [number, number]
id: string;
title: string;
description: string;
url: string;
category: 'page' | 'feature' | 'setting' | 'admin';
keywords: string[];
icon?: string;
matchRange?: [number, number];
}
/**
@@ -105,7 +105,15 @@ export const searchData: SearchItem[] = [
description: '管理系统登录注册与认证源配置 (管理员专属)',
url: '/admin/settings',
category: 'admin',
keywords: ['admin', '管理员', '系统设置', '安全', 'security', 'oidc', 'login'],
keywords: [
'admin',
'管理员',
'系统设置',
'安全',
'security',
'oidc',
'login',
],
},
{
id: 'admin-system',
@@ -129,9 +137,17 @@ export const searchData: SearchItem[] = [
description: '下发与排查后台异步定时任务 (管理员专属)',
url: '/admin/tasks',
category: 'admin',
keywords: ['admin', '管理员', '任务', '异步', 'tasks', 'scheduler', 'worker'],
keywords: [
'admin',
'管理员',
'任务',
'异步',
'tasks',
'scheduler',
'worker',
],
},
]
];
/**
* 搜索功能
@@ -139,47 +155,61 @@ export const searchData: SearchItem[] = [
* @param isAdmin 是否为管理员
* @returns 匹配的搜索结果
*/
export function searchItems(query: string, isAdmin: boolean = false): SearchItem[] {
const trimmedQuery = query.trim()
export function searchItems(
query: string,
isAdmin: boolean = false,
): SearchItem[] {
const trimmedQuery = query.trim();
// 非管理员不能搜索 admin 类别项
const filteredData = isAdmin
? searchData
: searchData.filter(item => item.category !== 'admin')
const filteredData = isAdmin
? searchData
: searchData.filter((item) => item.category !== 'admin');
if (!trimmedQuery) {
return filteredData
return filteredData;
}
return filteredData.map(item => {
// 优先匹配标题
const titleMatch = typeof match === 'function' ? match(item.title, trimmedQuery) : null
if (titleMatch) {
return { ...item, matchRange: titleMatch as [number, number] }
}
return filteredData
.map((item) => {
// 优先匹配标题
const titleMatch =
typeof match === 'function' ? match(item.title, trimmedQuery) : null;
if (titleMatch) {
return { ...item, matchRange: titleMatch as [number, number] };
}
// 匹配描述
if (typeof match === 'function' && match(item.description, trimmedQuery)) {
return item
}
// 匹配描述
if (
typeof match === 'function' &&
match(item.description, trimmedQuery)
) {
return item;
}
// 匹配关键词
if (item.keywords.some(keyword => typeof match === 'function' && match(keyword, trimmedQuery))) {
return item
}
// 匹配关键词
if (
item.keywords.some(
(keyword) =>
typeof match === 'function' && match(keyword, trimmedQuery),
)
) {
return item;
}
return null
}).filter((item): item is SearchItem => item !== null)
return null;
})
.filter((item): item is SearchItem => item !== null)
.sort((a, b) => {
// 标题匹配优先
if (a.matchRange && !b.matchRange) return -1
if (!a.matchRange && b.matchRange) return 1
if (a.matchRange && !b.matchRange) return -1;
if (!a.matchRange && b.matchRange) return 1;
// 如果都是标题匹配,按匹配位置排序
if (a.matchRange && b.matchRange) {
return a.matchRange[0] - b.matchRange[0]
return a.matchRange[0] - b.matchRange[0];
}
return 0
})
return 0;
});
}