mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps
- Merge all files inside openflare-server to the repository root directory. - Relocate agent, relay, and flared subprojects from internal/ to internal/apps/. - Combine docker-compose files and update build context paths to root. - Update GitHub workflows and Dockerfiles to refer to new directories and package names. - Rewrite Go package imports across all files. - Resolve database renew test race condition and clean up docs.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import packageJson from "../package.json"
|
||||
|
||||
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 || ""
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Cap PoW (Proof-of-Work) 人机验证前端实现
|
||||
* 与后端 internal/util/cap 算法完全对应
|
||||
*
|
||||
* 求解在 Web Worker 中执行,不阻塞主线程 UI。
|
||||
*/
|
||||
|
||||
// ——— Challenge / Redeem API types ———
|
||||
|
||||
export interface ChallengeResponse {
|
||||
challenge: { c: number; s: number; d: number };
|
||||
token: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
export interface RedeemResponse {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
expires?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ——— Worker 代码(内联 Blob,避免独立文件的打包配置问题)———
|
||||
//
|
||||
// 算法与 Go 后端 internal/util/cap/cap.go + prng.go 完全对应:
|
||||
// · FNV-1a 32-bit (Math.imul 保证 32-bit 截断)
|
||||
// · xorshift32 PRNG → hex 字符串
|
||||
// · SubtleCrypto SHA-256 校验答案
|
||||
|
||||
const WORKER_SOURCE = /* js */ `
|
||||
// FNV-1a 32-bit
|
||||
function fnv1a(str) {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
function fnv1aResume(state, str) {
|
||||
let h = state >>> 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// xorshift32 PRNG → hex string
|
||||
function prngFromHash(seed, len) {
|
||||
let s = seed >>> 0, r = '';
|
||||
while (r.length < len) {
|
||||
s ^= (s << 13) >>> 0;
|
||||
s ^= (s >>> 17) >>> 0;
|
||||
s ^= (s << 5) >>> 0;
|
||||
s = s >>> 0;
|
||||
r += s.toString(16).padStart(8, '0');
|
||||
}
|
||||
return r.slice(0, len);
|
||||
}
|
||||
|
||||
// SHA-256 via SubtleCrypto (available in workers)
|
||||
async function sha256Hex(input) {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// Solve one puzzle
|
||||
async function solvePuzzle(token, index, size, difficulty) {
|
||||
const tFnv = fnv1a(token);
|
||||
const saltSeed = fnv1aResume(tFnv, String(index + 1));
|
||||
const targetSeed = fnv1aResume(saltSeed, 'd');
|
||||
const salt = prngFromHash(saltSeed, size);
|
||||
const target = prngFromHash(targetSeed, difficulty);
|
||||
|
||||
for (let nonce = 0; nonce < 10_000_000; nonce++) {
|
||||
if ((await sha256Hex(salt + nonce)).startsWith(target)) return nonce;
|
||||
}
|
||||
throw new Error('无法在合理范围内求解第 ' + (index + 1) + ' 个难题');
|
||||
}
|
||||
|
||||
// Entry point — receive task from main thread
|
||||
self.onmessage = async ({ data }) => {
|
||||
const { token, count, size, difficulty } = data;
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const solutions = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
solutions.push(await solvePuzzle(token, i, size, difficulty));
|
||||
}
|
||||
const elapsed = ((performance.now() - t0) / 1000).toFixed(3);
|
||||
self.postMessage({ type: 'done', solutions, elapsed });
|
||||
} catch (err) {
|
||||
self.postMessage({ type: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
// ——— 在 Web Worker 中求解,返回 Promise<number[]> ———
|
||||
|
||||
function solveInWorker(
|
||||
token: string,
|
||||
count: number,
|
||||
size: number,
|
||||
difficulty: number,
|
||||
): Promise<{ solutions: number[]; elapsed: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob = new Blob([WORKER_SOURCE], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const worker = new Worker(url);
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
URL.revokeObjectURL(url);
|
||||
worker.terminate();
|
||||
if (e.data.type === 'done') {
|
||||
resolve({ solutions: e.data.solutions, elapsed: e.data.elapsed });
|
||||
} else {
|
||||
reject(new Error(e.data.message));
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (err) => {
|
||||
URL.revokeObjectURL(url);
|
||||
worker.terminate();
|
||||
reject(new Error(err.message || 'Worker 执行失败'));
|
||||
};
|
||||
|
||||
worker.postMessage({ token, count, size, difficulty });
|
||||
});
|
||||
}
|
||||
|
||||
// ——— Full Cap flow: get challenge → solve (Worker) → redeem → return X-Cap-Token ———
|
||||
|
||||
export async function getCapToken(scope = 'login'): Promise<string> {
|
||||
// 1. 获取难题
|
||||
const challengeRes = await fetch('/api/cap/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scope }),
|
||||
});
|
||||
if (!challengeRes.ok) {
|
||||
throw new Error('获取人机验证难题失败');
|
||||
}
|
||||
const challenge: ChallengeResponse = await challengeRes.json();
|
||||
const { c: count, s: size, d: difficulty } = challenge.challenge;
|
||||
|
||||
console.groupCollapsed('[Cap] 人机验证 PoW 求解开始');
|
||||
console.log(` 难题数量 (count) : ${count}`);
|
||||
console.log(` 验证难度 (difficulty): ${difficulty}`);
|
||||
console.log(` 盐值长度 (size) : ${size}`);
|
||||
console.groupEnd();
|
||||
|
||||
// 2. 在 Worker 中求解(不阻塞主线程)
|
||||
const t0 = performance.now();
|
||||
const { solutions, elapsed } = await solveInWorker(
|
||||
challenge.token,
|
||||
count,
|
||||
size,
|
||||
difficulty,
|
||||
);
|
||||
const wallTime = ((performance.now() - t0) / 1000).toFixed(3);
|
||||
|
||||
console.groupCollapsed('[Cap] 人机验证 PoW 求解完成');
|
||||
console.log(` 难题数量 (count) : ${count}`);
|
||||
console.log(` 验证难度 (difficulty): ${difficulty}`);
|
||||
console.log(` Worker 耗时 : ${elapsed}s`);
|
||||
console.log(` 总耗时(含通信) : ${wallTime}s`);
|
||||
console.log(` Solutions :`, solutions);
|
||||
console.groupEnd();
|
||||
|
||||
// 3. 提交答案兑换一次性凭证
|
||||
const redeemRes = await fetch('/api/cap/redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: challenge.token, solutions, scope }),
|
||||
});
|
||||
const redeemData: RedeemResponse = await redeemRes.json();
|
||||
if (!redeemData.success || !redeemData.token) {
|
||||
throw new Error(redeemData.error || '人机验证失败');
|
||||
}
|
||||
|
||||
return redeemData.token;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQuery} from '@tanstack/react-query';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {AdminStatusService} from '@/lib/services/admin';
|
||||
import type {AppUpdateStatus} from '@/lib/services/admin/types';
|
||||
import {StatusService} from '@/lib/services/openflare';
|
||||
|
||||
export const openflarePublicStatusQueryKey = ['openflare', 'public-status'] as const;
|
||||
|
||||
export const adminUpdateStatusQueryKey = ['admin', 'update'] as const;
|
||||
|
||||
export function useOpenFlareServerUpgrade({
|
||||
open,
|
||||
canUpgrade,
|
||||
}: {
|
||||
open: boolean;
|
||||
canUpgrade: boolean;
|
||||
}) {
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
const upgradeReloadStartedRef = useRef(false);
|
||||
const upgradeReloadTimerRef = useRef<number | null>(null);
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: openflarePublicStatusQueryKey,
|
||||
queryFn: () => StatusService.getPublicStatus(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const updateQuery = useQuery({
|
||||
queryKey: adminUpdateStatusQueryKey,
|
||||
queryFn: () => AdminStatusService.getUpdateStatus(),
|
||||
enabled: open && canUpgrade,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const scheduleUpgradePageReload = useCallback(() => {
|
||||
if (upgradeReloadStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
upgradeReloadStartedRef.current = true;
|
||||
setFeedback('服务升级已进入重启阶段,页面将在服务恢复后自动刷新。');
|
||||
|
||||
const reloadWhenServerReady = async () => {
|
||||
try {
|
||||
await StatusService.getPublicStatus();
|
||||
window.location.reload();
|
||||
} catch {
|
||||
upgradeReloadTimerRef.current = window.setTimeout(reloadWhenServerReady, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
upgradeReloadTimerRef.current = window.setTimeout(reloadWhenServerReady, 1200);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (upgradeReloadTimerRef.current !== null) {
|
||||
window.clearTimeout(upgradeReloadTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: () => AdminStatusService.applyUpdate(),
|
||||
onSuccess: () => {
|
||||
scheduleUpgradePageReload();
|
||||
setFeedback('升级包已校验完成,服务正在重启。');
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback(error instanceof Error ? error.message : '升级失败,请稍后重试。');
|
||||
},
|
||||
});
|
||||
|
||||
const resetTransientState = useCallback(() => {
|
||||
setFeedback(null);
|
||||
upgradeReloadStartedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
resetTransientState();
|
||||
if (canUpgrade) {
|
||||
void updateQuery.refetch();
|
||||
}
|
||||
}, [canUpgrade, resetTransientState, updateQuery]);
|
||||
|
||||
const handleCheckRelease = useCallback(() => {
|
||||
setFeedback(null);
|
||||
if (!canUpgrade) {
|
||||
return;
|
||||
}
|
||||
void updateQuery.refetch();
|
||||
}, [canUpgrade, updateQuery]);
|
||||
|
||||
const handleUpgrade = useCallback(() => {
|
||||
setFeedback(null);
|
||||
upgradeMutation.mutate();
|
||||
}, [upgradeMutation]);
|
||||
|
||||
const update = updateQuery.data;
|
||||
const releaseErrorMessage =
|
||||
feedback ||
|
||||
(updateQuery.isError
|
||||
? updateQuery.error instanceof Error
|
||||
? updateQuery.error.message
|
||||
: '版本检查失败,请稍后重试。'
|
||||
: undefined);
|
||||
|
||||
const currentVersion = statusQuery.data?.version || update?.current_version || 'unknown';
|
||||
|
||||
return {
|
||||
currentVersion,
|
||||
update,
|
||||
releaseErrorMessage,
|
||||
isInitialLoading: updateQuery.isLoading && !updateQuery.data && canUpgrade,
|
||||
isChecking: updateQuery.isFetching,
|
||||
isUpgrading: upgradeMutation.isPending,
|
||||
handleOpen,
|
||||
handleCheckRelease,
|
||||
handleUpgrade,
|
||||
};
|
||||
}
|
||||
|
||||
export type {AppUpdateStatus};
|
||||
@@ -0,0 +1,125 @@
|
||||
import type {LucideIcon} from 'lucide-react';
|
||||
import {
|
||||
FileText,
|
||||
Gauge,
|
||||
GitBranch,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Route,
|
||||
ScrollText,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface OpenFlareNavItem {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
/** 子页面在侧栏中仍高亮父级菜单项 */
|
||||
childUrls?: string[];
|
||||
}
|
||||
|
||||
export interface OpenFlareNavSubItem {
|
||||
title: string;
|
||||
url: string;
|
||||
childUrls?: string[];
|
||||
}
|
||||
|
||||
export interface OpenFlareNavGroup {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
items: OpenFlareNavSubItem[];
|
||||
}
|
||||
|
||||
export type OpenFlareSidebarNavEntry =
|
||||
| ({kind: 'item'} & OpenFlareNavItem)
|
||||
| ({kind: 'group'} & OpenFlareNavGroup);
|
||||
|
||||
/** 安全性折叠组 */
|
||||
export const openflareSecurityNavGroup: OpenFlareNavGroup = {
|
||||
title: '安全性',
|
||||
icon: ShieldCheck,
|
||||
items: [
|
||||
{title: 'WAF', url: '/waf'},
|
||||
{title: 'IP 组', url: '/waf/ip-groups'},
|
||||
],
|
||||
};
|
||||
|
||||
/** 网站管理折叠组 */
|
||||
export const openflareWebsiteNavGroup: OpenFlareNavGroup = {
|
||||
title: '网站管理',
|
||||
icon: Globe,
|
||||
items: [
|
||||
{title: '域名列表', url: '/websites', childUrls: ['/websites/detail']},
|
||||
{title: 'TLS证书', url: '/websites/certificates'},
|
||||
{title: 'DNS账号', url: '/websites/dns-accounts'},
|
||||
{title: '源站地址', url: '/origins', childUrls: ['/origins/detail']},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenFlare 侧栏导航顺序(单一配置源)。
|
||||
* 调整菜单顺序或折叠组位置时,只需修改此数组。
|
||||
*/
|
||||
export const openflareSidebarNav: OpenFlareSidebarNavEntry[] = [
|
||||
{kind: 'item', title: '数据看板', url: '/', icon: LayoutDashboard},
|
||||
{kind: 'item', title: '节点管理', url: '/nodes', icon: Server, childUrls: ['/nodes/detail']},
|
||||
{kind: 'item', title: '规则管理', url: '/proxy-routes', icon: Route, childUrls: ['/proxy-routes/detail']},
|
||||
{kind: 'group', ...openflareWebsiteNavGroup},
|
||||
{kind: 'group', ...openflareSecurityNavGroup},
|
||||
{kind: 'item', title: 'Pages', url: '/pages', icon: FileText, childUrls: ['/pages/detail']},
|
||||
{kind: 'item', title: '版本发布', url: '/config-versions', icon: GitBranch},
|
||||
{kind: 'item', title: '访问日志', url: '/access-logs', icon: ScrollText},
|
||||
{kind: 'item', title: '性能调优', url: '/performance', icon: Gauge},
|
||||
];
|
||||
|
||||
/** 扁平菜单项(供路由判断等逻辑复用) */
|
||||
export const openflareNavItems: OpenFlareNavItem[] = openflareSidebarNav
|
||||
.filter((entry): entry is {kind: 'item'} & OpenFlareNavItem => entry.kind === 'item')
|
||||
.map(({kind: _kind, ...item}) => item);
|
||||
|
||||
/** 网站模块页内二级导航 */
|
||||
export const openflareWebsiteSubNav = [
|
||||
{title: '网站列表', url: '/websites'},
|
||||
{title: '证书', url: '/websites/certificates'},
|
||||
{title: 'DNS 账号', url: '/websites/dns-accounts'},
|
||||
] as const;
|
||||
|
||||
const nonConsoleRoutePrefixes = ['/admin', '/settings', '/files', '/home', '/login', '/register', '/docs'];
|
||||
|
||||
export function matchesNavPath(
|
||||
pathname: string,
|
||||
url: string,
|
||||
childUrls?: string[],
|
||||
): boolean {
|
||||
if (url === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
|
||||
if (pathname === url || pathname.startsWith(`${url}/`)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (childUrls ?? []).some(
|
||||
(childUrl) => pathname === childUrl || pathname.startsWith(`${childUrl}/`),
|
||||
);
|
||||
}
|
||||
|
||||
export function isNavGroupActive(pathname: string, group: OpenFlareNavGroup): boolean {
|
||||
return group.items.some((item) => matchesNavPath(pathname, item.url, item.childUrls));
|
||||
}
|
||||
|
||||
/** 判断当前路径是否属于 OpenFlare 业务控制台 */
|
||||
export function isOpenFlareConsoleRoute(pathname: string): boolean {
|
||||
if (nonConsoleRoutePrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return openflareSidebarNav.some((entry) => {
|
||||
if (entry.kind === 'group') {
|
||||
return isNavGroupActive(pathname, entry);
|
||||
}
|
||||
|
||||
return matchesNavPath(pathname, entry.url, entry.childUrls);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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';
|
||||
|
||||
static async listAuthSources(): Promise<AuthSource[]> {
|
||||
return this.get<AuthSource[]>('/auth-sources');
|
||||
}
|
||||
|
||||
static async createAuthSource(request: AuthSourceRequest): Promise<AuthSource> {
|
||||
return this.post<AuthSource>('/auth-sources', request);
|
||||
}
|
||||
|
||||
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> {
|
||||
return this.put<void>(`/auth-sources/${id}/toggle`, request);
|
||||
}
|
||||
|
||||
static async deleteAuthSource(id: string): Promise<void> {
|
||||
return this.delete<void>(`/auth-sources/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {BaseService} from '@/lib/services/core';
|
||||
import type {CacheConfig, CacheStatus} from './types';
|
||||
|
||||
export class AdminCacheService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/admin';
|
||||
|
||||
static async getCacheStatus(): Promise<CacheStatus> {
|
||||
return this.get<CacheStatus>('/cache/status');
|
||||
}
|
||||
|
||||
static async updateCacheConfig(config: CacheConfig): Promise<void> {
|
||||
return this.post<void>('/cache/config', config);
|
||||
}
|
||||
|
||||
static async clearCache(): Promise<void> {
|
||||
return this.post<void>('/cache/clear');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export { AdminSystemConfigService } from './system-config.service';
|
||||
export { AdminAuthSourceService } from './auth-source.service';
|
||||
export { AdminTaskService } from './task.service';
|
||||
export { AdminUserService } from './user.service';
|
||||
export { AdminStatusService } from './status.service';
|
||||
export { AdminLogService } from './log.service';
|
||||
export { AdminTemplateService } from './template.service';
|
||||
export { AdminCacheService } from './cache.service';
|
||||
|
||||
export type {
|
||||
SystemConfig,
|
||||
CreateSystemConfigRequest,
|
||||
CreateUserRequest,
|
||||
UpdateSystemConfigRequest,
|
||||
AuthSource,
|
||||
AuthSourceRequest,
|
||||
ToggleAuthSourceRequest,
|
||||
TaskMeta,
|
||||
TaskParam,
|
||||
TaskParamType,
|
||||
TaskExecution,
|
||||
TaskExecutionStatus,
|
||||
ListTaskExecutionsRequest,
|
||||
ListTaskExecutionsResponse,
|
||||
DispatchTaskRequest,
|
||||
AdminUser,
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
UpdateUserStatusRequest,
|
||||
SystemStatus,
|
||||
AppUpdateStatus,
|
||||
Schedule,
|
||||
CreateScheduleRequest,
|
||||
UpdateScheduleRequest,
|
||||
CacheStatus,
|
||||
CacheConfig,
|
||||
Template,
|
||||
CreateTemplateRequest,
|
||||
UpdateTemplateRequest,
|
||||
StorageDriver,
|
||||
StorageConfig,
|
||||
ObjectStorageConfig,
|
||||
} from './types';
|
||||
@@ -0,0 +1,53 @@
|
||||
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<{
|
||||
lines: Array<{ index: number; data: string }>;
|
||||
has_more: boolean;
|
||||
next_cursor: number;
|
||||
}> {
|
||||
return this.get('/logs', { cursor, limit });
|
||||
}
|
||||
|
||||
static async getAccessLogs(params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
username?: string;
|
||||
path?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}): Promise<{
|
||||
total: number;
|
||||
list: Array<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
path: string;
|
||||
method: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
headers: string;
|
||||
status: number;
|
||||
latency: number;
|
||||
created_at: string;
|
||||
}>;
|
||||
}> {
|
||||
return this.get('/logs/access', params as Record<string, unknown>);
|
||||
}
|
||||
|
||||
static async getLogsAnalytics(): Promise<{
|
||||
trend: Array<{ date: string; count: number }>;
|
||||
browsers: Array<{ browser: string; count: number }>;
|
||||
top_users: Array<{
|
||||
user_id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
count: number;
|
||||
}>;
|
||||
}> {
|
||||
return this.get('/logs/analytics');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {BaseService} from '@/lib/services/core';
|
||||
import type {AppUpdateStatus, SystemStatus} from './types';
|
||||
|
||||
export class AdminStatusService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/admin';
|
||||
|
||||
static async getSystemStatus(): Promise<SystemStatus> {
|
||||
return this.get<SystemStatus>('/status');
|
||||
}
|
||||
|
||||
static async getUpdateStatus(): Promise<AppUpdateStatus> {
|
||||
return this.get<AppUpdateStatus>('/update');
|
||||
}
|
||||
|
||||
static async applyUpdate(): Promise<void> {
|
||||
return this.post<void>('/update/apply');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {BaseService} from '@/lib/services/core';
|
||||
import type {CreateSystemConfigRequest, SystemConfig, UpdateSystemConfigRequest,} from './types';
|
||||
|
||||
export class AdminSystemConfigService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/admin';
|
||||
|
||||
static async createSystemConfig(request: CreateSystemConfigRequest): Promise<void> {
|
||||
return this.post<void>('/system-configs', request);
|
||||
}
|
||||
|
||||
static async listSystemConfigs(type?: 'system' | 'business'): Promise<SystemConfig[]> {
|
||||
const query = type ? `?type=${type}` : '';
|
||||
return this.get<SystemConfig[]>(`/system-configs${query}`);
|
||||
}
|
||||
|
||||
static async getSystemConfig(key: string): Promise<SystemConfig> {
|
||||
return this.get<SystemConfig>(`/system-configs/${key}`);
|
||||
}
|
||||
|
||||
static async updateSystemConfig(key: string, request: UpdateSystemConfigRequest): Promise<void> {
|
||||
return this.put<void>(`/system-configs/${key}`, request);
|
||||
}
|
||||
|
||||
static async testSMTP(request: {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
to: string;
|
||||
}): Promise<{ success: boolean; log: string; error: string }> {
|
||||
return this.post<{ success: boolean; log: string; error: string }>(
|
||||
'/system-configs/smtp/test',
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
static async listUploadTypes(): Promise<string[]> {
|
||||
return this.get<string[]>('/uploads/types');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {BaseService} from '@/lib/services/core';
|
||||
import type {
|
||||
CreateScheduleRequest,
|
||||
DispatchTaskRequest,
|
||||
ListTaskExecutionsRequest,
|
||||
ListTaskExecutionsResponse,
|
||||
Schedule,
|
||||
TaskExecution,
|
||||
TaskMeta,
|
||||
TaskParamType,
|
||||
TaskTypeResponse,
|
||||
UpdateScheduleRequest,
|
||||
} from './types';
|
||||
|
||||
export class AdminTaskService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/admin';
|
||||
|
||||
static async getTaskTypes(): Promise<TaskMeta[]> {
|
||||
const response = await this.get<TaskTypeResponse[]>('/tasks/types');
|
||||
return response.map((item) => ({
|
||||
type: item.Type || item.type || '',
|
||||
asynq_task: item.AsynqTask || item.asynq_task || '',
|
||||
name: item.Name || item.name || '',
|
||||
description: item.Description || item.description || '',
|
||||
supports_time: item.SupportsTime ?? item.supports_time ?? false,
|
||||
max_retry: item.MaxRetry ?? item.max_retry ?? 0,
|
||||
queue: item.Queue || item.queue || '',
|
||||
params: (item.Params || item.params || []).map((p) => ({
|
||||
name: p.Name || p.name || '',
|
||||
label: p.Label || p.label || '',
|
||||
type: (p.Type || p.type || 'string') as TaskParamType,
|
||||
required: p.Required ?? p.required ?? false,
|
||||
placeholder: p.Placeholder || p.placeholder || '',
|
||||
description: p.Description || p.description || '',
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
static async dispatchTask(request: DispatchTaskRequest): Promise<string> {
|
||||
return this.post<string>('/tasks/dispatch', request);
|
||||
}
|
||||
|
||||
static async listTaskExecutions(
|
||||
request: ListTaskExecutionsRequest = {},
|
||||
): Promise<ListTaskExecutionsResponse> {
|
||||
return this.get<ListTaskExecutionsResponse>(
|
||||
'/tasks/executions',
|
||||
request as unknown as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
static async getTaskExecution(id: string): Promise<TaskExecution> {
|
||||
return this.get<TaskExecution>(`/tasks/executions/${id}`);
|
||||
}
|
||||
|
||||
static async retryTaskExecution(id: string): Promise<string> {
|
||||
return this.post<string>(`/tasks/executions/${id}/retry`);
|
||||
}
|
||||
|
||||
static async listSchedules(): Promise<Schedule[]> {
|
||||
return this.get<Schedule[]>('/tasks/schedules');
|
||||
}
|
||||
|
||||
static async createSchedule(request: CreateScheduleRequest): Promise<Schedule> {
|
||||
return this.post<Schedule>('/tasks/schedules', request);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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';
|
||||
|
||||
static async listTemplates(): Promise<Template[]> {
|
||||
return this.get<Template[]>('/templates');
|
||||
}
|
||||
|
||||
static async getTemplate(key: string): Promise<Template> {
|
||||
return this.get<Template>(`/templates/${key}`);
|
||||
}
|
||||
|
||||
static async createTemplate(request: CreateTemplateRequest): Promise<Template> {
|
||||
return this.post<Template>('/templates', request);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
|
||||
|
||||
/**
|
||||
* 系统配置信息
|
||||
*/
|
||||
export interface SystemConfig {
|
||||
/** 配置键 */
|
||||
key: string;
|
||||
/** 配置值 */
|
||||
value: string;
|
||||
/** 配置类型:'system' | 'business' */
|
||||
type: 'system' | 'business';
|
||||
/** 是否对公共配置接口可见:0 不可见,1 可见 */
|
||||
visibility: 0 | 1;
|
||||
/** 配置描述 */
|
||||
description: string;
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
/** 更新时间 */
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建系统配置请求参数
|
||||
*/
|
||||
export interface CreateSystemConfigRequest {
|
||||
/** 配置键(最大64字符) */
|
||||
key: string;
|
||||
/** 配置值 */
|
||||
value: string;
|
||||
/** 配置类型:'system' | 'business' */
|
||||
type: 'system' | 'business';
|
||||
/** 是否对公共配置接口可见:0 不可见,1 可见 */
|
||||
visibility?: 0 | 1;
|
||||
/** 配置描述(最大255字符,可选) */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新系统配置请求参数
|
||||
*/
|
||||
export interface UpdateSystemConfigRequest {
|
||||
/** 配置值 */
|
||||
value: string;
|
||||
/** 是否对公共配置接口可见:0 不可见,1 可见 */
|
||||
visibility?: 0 | 1;
|
||||
/** 配置描述(最大255字符,可选) */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type StorageDriver = 'local' | 's3' | 'r2' | 'minio' | 'oss' | 'webdav';
|
||||
|
||||
export interface ObjectStorageConfig {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
access_key_id: string;
|
||||
secret_access_key: string;
|
||||
account_id?: string;
|
||||
path_style: boolean;
|
||||
key_prefix: string;
|
||||
cdn_url: string;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
driver: StorageDriver;
|
||||
local: { root: string };
|
||||
s3: ObjectStorageConfig;
|
||||
r2: ObjectStorageConfig;
|
||||
minio: ObjectStorageConfig;
|
||||
oss: ObjectStorageConfig;
|
||||
webdav: {
|
||||
endpoint: string;
|
||||
username: string;
|
||||
password: string;
|
||||
base_path: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 任务管理 ====================
|
||||
|
||||
/**
|
||||
* 任务参数响应
|
||||
*/
|
||||
export interface TaskParamResponse {
|
||||
Name?: string;
|
||||
name?: string;
|
||||
Label?: string;
|
||||
label?: string;
|
||||
Type?: string;
|
||||
type?: string;
|
||||
Required?: boolean;
|
||||
required?: boolean;
|
||||
Placeholder?: string;
|
||||
placeholder?: string;
|
||||
Description?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务类型响应
|
||||
*/
|
||||
export interface TaskTypeResponse {
|
||||
Type?: string;
|
||||
type?: string;
|
||||
AsynqTask?: string;
|
||||
asynq_task?: string;
|
||||
Name?: string;
|
||||
name?: string;
|
||||
Description?: string;
|
||||
description?: string;
|
||||
SupportsTime?: boolean;
|
||||
supports_time?: boolean;
|
||||
MaxRetry?: number;
|
||||
max_retry?: number;
|
||||
Queue?: string;
|
||||
queue?: string;
|
||||
Params?: TaskParamResponse[];
|
||||
params?: TaskParamResponse[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务参数的数据类型
|
||||
* - string → 单行文本输入,JSON 中序列化为 string
|
||||
* - text → 多行文本输入,JSON 中序列化为 string
|
||||
* - number → 数字输入,JSON 中序列化为 number(而非 string)
|
||||
* - boolean → 开关输入,JSON 中序列化为 boolean(而非 string)
|
||||
*/
|
||||
export type TaskParamType = 'string' | 'text' | 'number' | 'boolean';
|
||||
|
||||
/**
|
||||
* 任务参数定义
|
||||
*/
|
||||
export interface TaskParam {
|
||||
name: string;
|
||||
label: string;
|
||||
type: TaskParamType;
|
||||
required: boolean;
|
||||
placeholder: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务元数据
|
||||
*/
|
||||
export interface TaskMeta {
|
||||
/** 任务类型标识 */
|
||||
type: string;
|
||||
/** Asynq 任务名称 */
|
||||
asynq_task: string;
|
||||
/** 任务名称 */
|
||||
name: string;
|
||||
/** 任务描述 */
|
||||
description: string;
|
||||
/** 是否支持时间范围参数 */
|
||||
supports_time: boolean;
|
||||
/** 最大重试次数 */
|
||||
max_retry: number;
|
||||
/** 队列名称 */
|
||||
queue: string;
|
||||
/** 任务所需的自定义参数定义 */
|
||||
params?: TaskParam[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发任务请求参数
|
||||
*/
|
||||
export interface DispatchTaskRequest {
|
||||
/** 任务类型 */
|
||||
task_type: string;
|
||||
/** 开始时间(可选,仅部分任务支持) */
|
||||
start_time?: string;
|
||||
/** 结束时间(可选,仅部分任务支持) */
|
||||
end_time?: string;
|
||||
/** 用户 ID(可选,仅部分任务需要) */
|
||||
user_id?: string;
|
||||
/** 任务自定义参数 JSON 字符串 */
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
export type TaskExecutionStatus = 'pending' | 'running' | 'succeeded' | 'failed';
|
||||
|
||||
/**
|
||||
* 任务执行记录
|
||||
*/
|
||||
export interface TaskExecution {
|
||||
id: string;
|
||||
task_id: string;
|
||||
task_type: string;
|
||||
task_name: string;
|
||||
status: TaskExecutionStatus;
|
||||
retryable: boolean;
|
||||
max_retry: number;
|
||||
retry_count: number;
|
||||
log: string;
|
||||
error_message: string;
|
||||
result: string;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
duration: number;
|
||||
payload: string;
|
||||
triggered_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务执行记录请求参数
|
||||
*/
|
||||
export interface ListTaskExecutionsRequest {
|
||||
status?: TaskExecutionStatus;
|
||||
task_type?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务执行记录响应
|
||||
*/
|
||||
export interface ListTaskExecutionsResponse {
|
||||
items: TaskExecution[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时任务配置信息
|
||||
*/
|
||||
export interface Schedule {
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: string;
|
||||
cron: string;
|
||||
payload: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建定时任务请求参数
|
||||
*/
|
||||
export interface CreateScheduleRequest {
|
||||
name: string;
|
||||
task_type: string;
|
||||
cron: string;
|
||||
payload: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改定时任务请求参数
|
||||
*/
|
||||
export interface UpdateScheduleRequest {
|
||||
name: string;
|
||||
task_type: string;
|
||||
cron: string;
|
||||
payload: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
/**
|
||||
* 管理员用户信息
|
||||
*/
|
||||
export interface AdminUser {
|
||||
/** 用户 ID */
|
||||
id: string;
|
||||
/** 用户名 */
|
||||
username: string;
|
||||
/** 昵称 */
|
||||
nickname: string;
|
||||
/** 邮箱 */
|
||||
email?: string;
|
||||
/** 头像 URL */
|
||||
avatar_url: string;
|
||||
/** 是否激活 */
|
||||
is_active: boolean;
|
||||
/** 是否管理员 */
|
||||
is_admin: boolean;
|
||||
/** 个人简介 */
|
||||
bio?: string;
|
||||
/** 手机号 */
|
||||
phone?: string;
|
||||
/** 性别 */
|
||||
gender?: string;
|
||||
/** 个人网站 */
|
||||
website?: string;
|
||||
/** 所在地 */
|
||||
location?: string;
|
||||
/** 最后登录时间 */
|
||||
last_login_at: string;
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
/** 更新时间 */
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户列表查询请求参数
|
||||
*/
|
||||
export interface ListUsersRequest {
|
||||
/** 页码(从 1 开始) */
|
||||
page: number;
|
||||
/** 每页数量(1-100) */
|
||||
page_size: number;
|
||||
/** 用户 ID 精确过滤(可选) */
|
||||
user_id?: string;
|
||||
/** 用户名前缀过滤(可选) */
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户列表响应
|
||||
*/
|
||||
export interface ListUsersResponse {
|
||||
/** 用户列表 */
|
||||
users: AdminUser[];
|
||||
/** 总数 */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户状态请求参数
|
||||
*/
|
||||
export interface UpdateUserStatusRequest {
|
||||
/** 是否激活 */
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户请求参数
|
||||
*/
|
||||
export interface CreateUserRequest {
|
||||
/** 用户名 */
|
||||
username: string;
|
||||
/** 密码 */
|
||||
password: string;
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
/** 邮箱 */
|
||||
email: string;
|
||||
/** 是否激活 */
|
||||
is_active?: boolean;
|
||||
/** 是否管理员 */
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证源信息
|
||||
*/
|
||||
export interface AuthSource {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oidc';
|
||||
display_name: string;
|
||||
is_active: boolean;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
client_secret_configured?: boolean;
|
||||
openid_discovery_url: string;
|
||||
scopes: string;
|
||||
icon_url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AuthSourceRequest {
|
||||
name: string;
|
||||
type: 'oidc';
|
||||
display_name: string;
|
||||
is_active: boolean;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
openid_discovery_url: string;
|
||||
scopes: string;
|
||||
icon_url: string;
|
||||
}
|
||||
|
||||
export interface ToggleAuthSourceRequest {
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统状态信息
|
||||
*/
|
||||
export interface SystemStatus {
|
||||
uptime: string;
|
||||
num_goroutine: number;
|
||||
alloc: string;
|
||||
total_alloc: string;
|
||||
sys: string;
|
||||
lookups: number;
|
||||
mallocs: number;
|
||||
frees: number;
|
||||
heap_alloc: string;
|
||||
heap_sys: string;
|
||||
heap_idle: string;
|
||||
heap_inuse: string;
|
||||
heap_released: string;
|
||||
heap_objects: number;
|
||||
stack_inuse: string;
|
||||
stack_sys: string;
|
||||
mspan_inuse: string;
|
||||
mspan_sys: string;
|
||||
mcache_inuse: string;
|
||||
mcache_sys: string;
|
||||
buck_hash_sys: string;
|
||||
gc_sys: string;
|
||||
other_sys: string;
|
||||
next_gc: string;
|
||||
last_gc_time: string;
|
||||
pause_total_ns: string;
|
||||
last_pause: string;
|
||||
num_gc: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板配置信息
|
||||
*/
|
||||
export interface Template {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
description: string;
|
||||
is_system: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建模板请求参数
|
||||
*/
|
||||
export interface CreateTemplateRequest {
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板请求参数
|
||||
*/
|
||||
export interface UpdateTemplateRequest {
|
||||
name: string;
|
||||
type: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用更新状态
|
||||
*/
|
||||
export interface AppUpdateStatus {
|
||||
current_version: string;
|
||||
build_time: string;
|
||||
latest_version: string;
|
||||
update_available: boolean;
|
||||
can_upgrade: boolean;
|
||||
prerelease: boolean;
|
||||
release_name: string;
|
||||
release_notes: string;
|
||||
release_url: string;
|
||||
published_at: string;
|
||||
upstream_repository: string;
|
||||
asset_name: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/**
|
||||
* 缓存状态信息
|
||||
*/
|
||||
export interface CacheStatus {
|
||||
/** 缓存文件总大小(字节) */
|
||||
total_size: number;
|
||||
/** 缓存 Key 数量 */
|
||||
keys_count: number;
|
||||
/** 最大容量限制 (MB) */
|
||||
max_size_mb: number;
|
||||
/** 过期时间 (分钟) */
|
||||
ttl_minutes: number;
|
||||
/** 是否启用 LRU 淘汰 */
|
||||
lru_enabled: boolean;
|
||||
/** 缓存目录基准路径 */
|
||||
base_path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存配置请求参数
|
||||
*/
|
||||
export interface CacheConfig {
|
||||
/** 最大容量限制 (MB) */
|
||||
max_size_mb: number;
|
||||
/** 过期时间 (分钟) */
|
||||
ttl_minutes: number;
|
||||
/** 是否启用 LRU 淘汰 */
|
||||
lru_enabled: boolean;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {BaseService} from '@/lib/services/core';
|
||||
import type {
|
||||
AdminUser,
|
||||
CreateUserRequest,
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
UpdateUserStatusRequest,
|
||||
} from './types';
|
||||
|
||||
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 getUser(id: string): Promise<AdminUser> {
|
||||
return this.get<AdminUser>(`/users/${id}`);
|
||||
}
|
||||
|
||||
static async updateUserStatus(id: string, request: UpdateUserStatusRequest): Promise<void> {
|
||||
return this.put<void>(`/users/${id}/status`, request);
|
||||
}
|
||||
|
||||
static async createUser(request: CreateUserRequest): Promise<AdminUser> {
|
||||
return this.post<AdminUser>('/users', request);
|
||||
}
|
||||
|
||||
static async deleteUser(id: string): Promise<void> {
|
||||
return this.delete<void>(`/users/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {InternalAxiosRequestConfig} from 'axios';
|
||||
import {BaseService} from '@/lib/services/core';
|
||||
import type {
|
||||
AuthSource,
|
||||
ChangePasswordRequest,
|
||||
ExternalAccountBinding,
|
||||
LoginRequest,
|
||||
OAuthAuthorizeResponse,
|
||||
OAuthCallbackRequest,
|
||||
OAuthCallbackResult,
|
||||
OAuthLoginUrlResponse,
|
||||
RegisterRequest,
|
||||
UpdateProfileRequest,
|
||||
User,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
* 处理 OAuth 认证、用户信息获取、登出等
|
||||
*/
|
||||
export class AuthService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1';
|
||||
|
||||
/**
|
||||
* 获取 OAuth 登录 URL
|
||||
* @returns OAuth 授权 URL
|
||||
* @throws {ApiErrorBase} 当获取失败时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const url = await AuthService.getLoginUrl();
|
||||
* window.location.href = url; // 重定向到 OAuth 授权页面
|
||||
* ```
|
||||
*/
|
||||
static async getLoginUrl(): Promise<OAuthLoginUrlResponse> {
|
||||
return this.get<OAuthLoginUrlResponse>('/oauth/login');
|
||||
}
|
||||
|
||||
static async getAuthSources(): Promise<AuthSource[]> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 OAuth 回调
|
||||
* @param request - OAuth 回调参数(state 和 code)
|
||||
* @throws {ApiErrorBase} 当回调处理失败时
|
||||
* @throws {ValidationError} 当 state 无效时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 在回调页面获取 URL 参数
|
||||
* const params = new URLSearchParams(window.location.search);
|
||||
* const state = params.get('state');
|
||||
* const code = params.get('code');
|
||||
*
|
||||
* if (state && code) {
|
||||
* await AuthService.handleCallback({ state, code });
|
||||
* // 登录成功,跳转到首页
|
||||
* router.push('/home');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static async handleCallback(request: OAuthCallbackRequest): Promise<OAuthCallbackResult> {
|
||||
return this.post<OAuthCallbackResult>('/oauth/callback', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
* @returns 用户信息
|
||||
* @throws {UnauthorizedError} 当未登录时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* const user = await AuthService.getUserInfo();
|
||||
* console.log('当前用户:', user.username);
|
||||
* } catch (error) {
|
||||
* if (error instanceof UnauthorizedError) {
|
||||
* // 跳转到登录页
|
||||
* router.push('/login');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static async getUserInfo(): Promise<User> {
|
||||
return this.get<User>('/user-info');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
* @throws {ApiErrorBase} 当登出失败时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await AuthService.logout();
|
||||
* // 清除本地状态
|
||||
* router.push('/login');
|
||||
* ```
|
||||
*/
|
||||
static async logout(): Promise<void> {
|
||||
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 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 changePassword(request: ChangePasswordRequest): Promise<void> {
|
||||
return this.post<void>('/user/change-password', request);
|
||||
}
|
||||
|
||||
static async updateProfile(request: UpdateProfileRequest): Promise<User> {
|
||||
return this.put<User>('/user/profile', request);
|
||||
}
|
||||
|
||||
static async getExternalAccountBindings(): Promise<ExternalAccountBinding[]> {
|
||||
return this.get<ExternalAccountBinding[]>('/oauth/external-accounts');
|
||||
}
|
||||
|
||||
static async deleteExternalAccountBinding(id: string): Promise<void> {
|
||||
return this.post<void>(`/oauth/external-accounts/${encodeURIComponent(id)}/delete`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 认证服务模块
|
||||
*
|
||||
* @description
|
||||
* 提供 OAuth 认证相关的功能,包括:
|
||||
* - 获取登录 URL
|
||||
* - 处理 OAuth 回调
|
||||
* - 获取用户信息
|
||||
* - 用户登出
|
||||
* - 发起登录流程
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { AuthService } from '@/lib/services';
|
||||
*
|
||||
* // 发起登录
|
||||
* await AuthService.initiateLogin();
|
||||
*
|
||||
* // 获取用户信息
|
||||
* const user = await AuthService.getUserInfo();
|
||||
* console.log('当前用户:', user.username);
|
||||
*
|
||||
* // 登出
|
||||
* await AuthService.logout();
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { AuthService } from './auth.service';
|
||||
export type {
|
||||
User,
|
||||
OAuthLoginUrlResponse,
|
||||
OAuthCallbackRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
OAuthAuthorizeResponse,
|
||||
OAuthCallbackResult,
|
||||
AuthSource,
|
||||
ExternalAccountBinding,
|
||||
ChangePasswordRequest,
|
||||
UpdateProfileRequest,
|
||||
} from './types';
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 用户基本信息
|
||||
*/
|
||||
export interface User {
|
||||
/** 用户 ID */
|
||||
id: string;
|
||||
/** 账户 */
|
||||
username: string;
|
||||
/** 昵称 */
|
||||
nickname: string;
|
||||
/** 头像 URL */
|
||||
avatar_url: string;
|
||||
/** 是否为管理员 */
|
||||
is_admin: boolean;
|
||||
/** 是否需要修改密码 */
|
||||
need_change_password?: boolean;
|
||||
/** 邮箱 */
|
||||
email: string;
|
||||
/** 个人简介 */
|
||||
bio?: string;
|
||||
/** 手机号码 */
|
||||
phone?: string;
|
||||
/** 性别 */
|
||||
gender?: string;
|
||||
/** 个人网站 */
|
||||
website?: string;
|
||||
/** 所在地 */
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
nickname: string;
|
||||
email: string;
|
||||
avatar_url: string;
|
||||
bio?: string;
|
||||
phone?: string;
|
||||
gender?: string;
|
||||
website?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 登录 URL 响应
|
||||
* 后端直接返回字符串 URL
|
||||
*/
|
||||
export type OAuthLoginUrlResponse = string;
|
||||
|
||||
/**
|
||||
* OAuth 回调请求参数
|
||||
*/
|
||||
export interface OAuthCallbackRequest {
|
||||
/** 状态码 */
|
||||
state: string;
|
||||
/** 授权码 */
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizeResponse {
|
||||
authorize_url: string;
|
||||
}
|
||||
|
||||
export interface OAuthCallbackResult {
|
||||
status: 'logged_in' | 'bound' | 'need_bind';
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface AuthSource {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oidc';
|
||||
display_name: string;
|
||||
is_active: boolean;
|
||||
icon_url: string;
|
||||
client_secret_configured: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalAccountBinding {
|
||||
id: string;
|
||||
auth_source_id: string;
|
||||
auth_source_name: string;
|
||||
auth_source_type: string;
|
||||
auth_source_label: string;
|
||||
external_username: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {BaseService} from '@/lib/services/core';
|
||||
import type {PublicConfigResponse} from './types';
|
||||
|
||||
/**
|
||||
* 配置服务
|
||||
* 处理系统公共配置相关的 API 请求
|
||||
*/
|
||||
export class ConfigService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/config';
|
||||
|
||||
/**
|
||||
* 获取公共配置
|
||||
* @returns 公共配置信息
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = await ConfigService.getPublicConfig();
|
||||
* ```
|
||||
*/
|
||||
static async getPublicConfig(): Promise<PublicConfigResponse> {
|
||||
return this.get<PublicConfigResponse>('/public');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 配置服务模块
|
||||
*
|
||||
* @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);
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { ConfigService } from './config.service';
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 公共配置响应:key 为系统配置键,value 为配置表中保存的字符串值 */
|
||||
export type PublicConfigResponse = Record<string, string | undefined>;
|
||||
@@ -0,0 +1,324 @@
|
||||
import axios, {AxiosError, AxiosResponse, CancelTokenSource, InternalAxiosRequestConfig} from 'axios';
|
||||
import {toast} from 'sonner';
|
||||
import {apiConfig, getApiBaseUrl} from './config';
|
||||
import {
|
||||
ApiErrorBase,
|
||||
ForbiddenError,
|
||||
NetworkError,
|
||||
NotFoundError,
|
||||
ServerError,
|
||||
TimeoutError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from './errors';
|
||||
import {ApiError, ApiResponse} from './types';
|
||||
|
||||
/**
|
||||
* API 客户端实例
|
||||
* 统一处理请求配置、响应解析和错误处理
|
||||
*/
|
||||
const apiClient = axios.create({
|
||||
baseURL: apiConfig.baseURL,
|
||||
timeout: apiConfig.timeout,
|
||||
withCredentials: apiConfig.withCredentials,
|
||||
// Fail fast on slash redirect loops between Next dev proxy and Gin legacy routes.
|
||||
maxRedirects: 5,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
/** Normalize legacy /api/* paths: strip trailing slash to avoid 308/301 loops in dev proxy. */
|
||||
function normalizeApiPath(url?: string): string | undefined {
|
||||
if (!url || !url.startsWith('/api/')) {
|
||||
return url;
|
||||
}
|
||||
if (url.length > 5 && url.endsWith('/')) {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求取消令牌存储
|
||||
*/
|
||||
const cancelTokens = new Map<string, CancelTokenSource>();
|
||||
|
||||
/**
|
||||
* 请求缓存存储
|
||||
* 存储正在进行的请求 Promise,避免重复请求
|
||||
*/
|
||||
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 }`;
|
||||
|
||||
/* 序列化加入键中 */
|
||||
if (config.data) {
|
||||
try {
|
||||
const dataHash = JSON.stringify(config.data);
|
||||
return `${ baseKey }_${ dataHash }`;
|
||||
} catch {
|
||||
// 失败使用基础键
|
||||
return baseKey;
|
||||
}
|
||||
}
|
||||
|
||||
return baseKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求拦截器
|
||||
* 添加取消令牌和其他配置
|
||||
*/
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Browser must use same-origin relative URLs so Session Cookie hits Next rewrite (3010→3000).
|
||||
if (typeof window !== 'undefined') {
|
||||
config.baseURL = getApiBaseUrl();
|
||||
}
|
||||
config.url = normalizeApiPath(config.url);
|
||||
|
||||
const requestKey = getRequestKey(config);
|
||||
const source = axios.CancelToken.source();
|
||||
config.cancelToken = source.token;
|
||||
cancelTokens.set(requestKey, source);
|
||||
|
||||
return config;
|
||||
},
|
||||
(error: unknown) => Promise.reject(error),
|
||||
);
|
||||
|
||||
/**
|
||||
* 直接启动登录流程
|
||||
* @param currentPath - 当前路径,用于登录成功后重定向回来
|
||||
*/
|
||||
function initiateLogin(currentPath: string): Promise<never> {
|
||||
if (currentPath.startsWith('/login') || currentPath.startsWith('/callback')) {
|
||||
return Promise.reject(new UnauthorizedError());
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('redirect_after_login', currentPath);
|
||||
const loginUrl = new URL('/login', window.location.origin);
|
||||
loginUrl.searchParams.set('callbackUrl', currentPath);
|
||||
window.location.href = loginUrl.toString();
|
||||
}
|
||||
|
||||
return new Promise<never>(() => { });
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应拦截器
|
||||
* 处理 API 响应和统一错误处理
|
||||
*/
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
const requestKey = getRequestKey(response.config);
|
||||
cancelTokens.delete(requestKey);
|
||||
pendingRequests.delete(requestKey);
|
||||
|
||||
const resData = response.data as ApiError & ApiResponse;
|
||||
if (resData && resData.error_msg) {
|
||||
return Promise.reject(new ApiErrorBase(resData.error_msg));
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError<ApiError>) => {
|
||||
if (error.config) {
|
||||
const requestKey = getRequestKey(error.config);
|
||||
cancelTokens.delete(requestKey);
|
||||
pendingRequests.delete(requestKey);
|
||||
}
|
||||
|
||||
/* 请求被取消时静默处理 */
|
||||
if (axios.isCancel(error)) {
|
||||
const cancelError = new Error(error.message || '请求已被取消') as Error & { __CANCEL__?: boolean };
|
||||
cancelError.__CANCEL__ = true;
|
||||
return Promise.reject(cancelError);
|
||||
}
|
||||
|
||||
/* 401 未授权错误 */
|
||||
if (error.response?.status === 401) {
|
||||
return initiateLogin(window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
/* 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),
|
||||
);
|
||||
}
|
||||
|
||||
/* 404 资源未找到错误 */
|
||||
if (error.response?.status === 404) {
|
||||
return Promise.reject(
|
||||
new NotFoundError(error.response.data?.error_msg || '请求的资源不存在'),
|
||||
);
|
||||
}
|
||||
|
||||
/* 400 验证错误 */
|
||||
if (error.response?.status === 400) {
|
||||
return Promise.reject(
|
||||
new ValidationError(
|
||||
error.response.data?.error_msg || '请求参数验证失败',
|
||||
error.response.data?.details,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/* 429 速率限制错误 */
|
||||
if (error.response?.status === 429) {
|
||||
const retryAfter = error.response.headers?.['retry-after'];
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
/* 5xx 服务器错误 */
|
||||
if (error.response && error.response.status >= 500) {
|
||||
return Promise.reject(
|
||||
new ServerError(
|
||||
error.response.data?.error_msg || '服务器内部错误,请稍后重试',
|
||||
error.response.status,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/* 网络超时错误 */
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
return Promise.reject(new TimeoutError());
|
||||
}
|
||||
|
||||
/* 网络连接错误(ECONNREFUSED, ERR_NETWORK 等) */
|
||||
if (
|
||||
!error.response ||
|
||||
error.code === 'ECONNREFUSED' ||
|
||||
error.code === 'ERR_NETWORK' ||
|
||||
error.message?.includes('Network Error') ||
|
||||
error.message?.includes('Failed to fetch')
|
||||
) {
|
||||
return Promise.reject(
|
||||
new NetworkError('无法连接到服务器,请确认后端服务已启动'),
|
||||
);
|
||||
}
|
||||
|
||||
/* 其他后端返回的错误 */
|
||||
if (error.response?.data?.error_msg) {
|
||||
return Promise.reject(
|
||||
new ApiErrorBase(
|
||||
error.response.data.error_msg,
|
||||
error.response.data.error_code,
|
||||
error.response.status,
|
||||
error.response.data.details,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/* 兜底错误 */
|
||||
return Promise.reject(
|
||||
new ApiErrorBase(error.message || '网络请求失败'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 取消指定请求
|
||||
* @param method - 请求方法
|
||||
* @param url - 请求 URL
|
||||
*/
|
||||
export function cancelRequest(method: string, url: string): void {
|
||||
const requestKey = `${ method.toUpperCase() }_${ url }`;
|
||||
const source = cancelTokens.get(requestKey);
|
||||
if (source) {
|
||||
source.cancel('请求已被手动取消');
|
||||
cancelTokens.delete(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有请求
|
||||
*/
|
||||
export function cancelAllRequests(): void {
|
||||
cancelTokens.forEach((source) => {
|
||||
source.cancel('所有请求已被取消');
|
||||
});
|
||||
cancelTokens.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带有请求去重功能的请求方法
|
||||
* @param method HTTP 方法名
|
||||
* @param hasBody 是否包含请求体
|
||||
*/
|
||||
function createRequestMethod(
|
||||
method: 'get' | 'post' | 'put' | 'patch' | 'delete',
|
||||
hasBody: boolean
|
||||
) {
|
||||
if (hasBody) {
|
||||
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>>);
|
||||
|
||||
promise.then(
|
||||
() => pendingRequests.delete(requestKey),
|
||||
() => pendingRequests.delete(requestKey),
|
||||
);
|
||||
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
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>>);
|
||||
|
||||
promise.then(
|
||||
() => pendingRequests.delete(requestKey),
|
||||
() => pendingRequests.delete(requestKey),
|
||||
);
|
||||
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装的 API 客户端
|
||||
* 在原有 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>>,
|
||||
};
|
||||
|
||||
export default wrappedApiClient;
|
||||
@@ -0,0 +1,188 @@
|
||||
import apiClient from './api-client';
|
||||
import {ApiResponse} from './types';
|
||||
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[]>('/');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class BaseService {
|
||||
/**
|
||||
* API 基础路径
|
||||
* 子类必须重写此属性
|
||||
*/
|
||||
protected static readonly basePath: string = '';
|
||||
|
||||
/**
|
||||
* 获取完整的 API 路径
|
||||
* @param path - API 路径
|
||||
* @returns 完整路径
|
||||
*/
|
||||
protected static getFullPath(path: string): string {
|
||||
return `${ this.basePath }${ path }`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求
|
||||
* @template T - 响应数据类型
|
||||
* @param path - API 路径
|
||||
* @param params - 查询参数
|
||||
* @param config - 额外的请求配置
|
||||
* @returns 响应数据
|
||||
*/
|
||||
protected static async get<T>(
|
||||
path: string,
|
||||
params?: Record<string, unknown>,
|
||||
config?: InternalAxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
const requestConfig: InternalAxiosRequestConfig = {
|
||||
...config,
|
||||
params,
|
||||
} as InternalAxiosRequestConfig;
|
||||
const response = await apiClient.get<ApiResponse<T>>(
|
||||
this.getFullPath(path),
|
||||
requestConfig,
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
* @template T - 响应数据类型
|
||||
* @param path - API 路径
|
||||
* @param data - 请求数据
|
||||
* @param config - 额外的请求配置
|
||||
* @returns 响应数据
|
||||
*/
|
||||
protected static async post<T>(
|
||||
path: string,
|
||||
data?: unknown,
|
||||
config?: InternalAxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
const response = await apiClient.post<ApiResponse<T>>(
|
||||
this.getFullPath(path),
|
||||
data,
|
||||
config,
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 请求
|
||||
* @template T - 响应数据类型
|
||||
* @param path - API 路径
|
||||
* @param data - 请求数据
|
||||
* @param config - 额外的请求配置
|
||||
* @returns 响应数据
|
||||
*/
|
||||
protected static async put<T>(
|
||||
path: string,
|
||||
data?: unknown,
|
||||
config?: InternalAxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
const response = await apiClient.put<ApiResponse<T>>(
|
||||
this.getFullPath(path),
|
||||
data,
|
||||
config,
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH 请求
|
||||
* @template T - 响应数据类型
|
||||
* @param path - API 路径
|
||||
* @param data - 请求数据
|
||||
* @param config - 额外的请求配置
|
||||
* @returns 响应数据
|
||||
*/
|
||||
protected static async patch<T>(
|
||||
path: string,
|
||||
data?: unknown,
|
||||
config?: InternalAxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
const response = await apiClient.patch<ApiResponse<T>>(
|
||||
this.getFullPath(path),
|
||||
data,
|
||||
config,
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求
|
||||
* @template T - 响应数据类型
|
||||
* @param path - API 路径
|
||||
* @param params - 查询参数
|
||||
* @param config - 额外的请求配置
|
||||
* @returns 响应数据
|
||||
*/
|
||||
protected static async delete<T>(
|
||||
path: string,
|
||||
params?: Record<string, unknown>,
|
||||
config?: InternalAxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
const requestConfig: InternalAxiosRequestConfig = {
|
||||
...config,
|
||||
params,
|
||||
} as InternalAxiosRequestConfig;
|
||||
const response = await apiClient.delete<ApiResponse<T>>(
|
||||
this.getFullPath(path),
|
||||
requestConfig,
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 原始 GET 请求(用于特殊 API 端点)
|
||||
* @template T - 响应数据类型
|
||||
* @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,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 原始 POST 请求(用于特殊 API 端点)
|
||||
* @template T - 响应数据类型
|
||||
* @param url - 完整 URL
|
||||
* @param data - 请求数据
|
||||
* @param config - 额外的请求配置
|
||||
* @returns 响应数据(不经过 response.data.data 解包)
|
||||
*
|
||||
* @remarks
|
||||
* 仅用于不遵循标准响应格式的特殊端点(如 /api.php)
|
||||
*/
|
||||
protected static async rawPost<T>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: InternalAxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
const response = await apiClient.post<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* API 配置
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取 API 基础 URL
|
||||
* @returns API 基础 URL
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return process.env.WAVELET_BACKEND_URL || process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || 'http://localhost:3000';
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* API 配置选项
|
||||
*/
|
||||
export const apiConfig = {
|
||||
/** Basic URL */
|
||||
baseURL: getApiBaseUrl(),
|
||||
/** 超时时间(毫秒) */
|
||||
timeout: 15000,
|
||||
/** 携带凭证 */
|
||||
withCredentials: true,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* API 错误类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* API 错误基类
|
||||
*/
|
||||
export class ApiErrorBase extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
public readonly statusCode?: number,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
Object.setPrototypeOf(this, ApiErrorBase.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络错误
|
||||
*/
|
||||
export class NetworkError extends ApiErrorBase {
|
||||
constructor(message = '网络连接失败,请检查您的网络') {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
Object.setPrototypeOf(this, NetworkError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 超时错误
|
||||
*/
|
||||
export class TimeoutError extends ApiErrorBase {
|
||||
constructor(message = '请求超时,请稍后重试') {
|
||||
super(message);
|
||||
this.name = 'TimeoutError';
|
||||
Object.setPrototypeOf(this, TimeoutError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 未授权错误 (401)
|
||||
*/
|
||||
export class UnauthorizedError extends ApiErrorBase {
|
||||
constructor(message = '未授权,请先登录') {
|
||||
super(message, 'UNAUTHORIZED', 401);
|
||||
this.name = 'UnauthorizedError';
|
||||
Object.setPrototypeOf(this, UnauthorizedError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限不足错误 (403)
|
||||
*/
|
||||
export class ForbiddenError extends ApiErrorBase {
|
||||
constructor(message = '权限不足', code = 'FORBIDDEN', details?: unknown) {
|
||||
super(message, code, 403, details);
|
||||
this.name = 'ForbiddenError';
|
||||
Object.setPrototypeOf(this, ForbiddenError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源未找到错误 (404)
|
||||
*/
|
||||
export class NotFoundError extends ApiErrorBase {
|
||||
constructor(message = '请求的资源不存在') {
|
||||
super(message, 'NOT_FOUND', 404);
|
||||
this.name = 'NotFoundError';
|
||||
Object.setPrototypeOf(this, NotFoundError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器错误 (5xx)
|
||||
*/
|
||||
export class ServerError extends ApiErrorBase {
|
||||
constructor(message = '服务器内部错误,请稍后重试', statusCode = 500) {
|
||||
super(message, 'SERVER_ERROR', statusCode);
|
||||
this.name = 'ServerError';
|
||||
Object.setPrototypeOf(this, ServerError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证错误 (400)
|
||||
*/
|
||||
export class ValidationError extends ApiErrorBase {
|
||||
constructor(message = '请求参数验证失败', details?: unknown) {
|
||||
super(message, 'VALIDATION_ERROR', 400, details);
|
||||
this.name = 'ValidationError';
|
||||
Object.setPrototypeOf(this, ValidationError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查错误是否为请求取消错误
|
||||
* @param error - 错误对象
|
||||
* @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 === '请求已被取消'));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 核心服务模块
|
||||
* 提供 API 请求的基础设施
|
||||
*/
|
||||
|
||||
export { default as apiClient, cancelRequest, cancelAllRequests } from './api-client';
|
||||
export { BaseService } from './base.service';
|
||||
export { apiConfig } from './config';
|
||||
export {
|
||||
ApiErrorBase,
|
||||
NetworkError,
|
||||
TimeoutError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ServerError,
|
||||
ValidationError,
|
||||
isCancelError,
|
||||
} from './errors';
|
||||
export type {
|
||||
ApiResponse,
|
||||
ApiError,
|
||||
PaginationParams,
|
||||
PaginationResponse,
|
||||
RequestConfig,
|
||||
} from './types';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* API 响应通用结构
|
||||
* @template T - 数据类型
|
||||
*/
|
||||
export interface ApiResponse<T = unknown> {
|
||||
/** 响应数据 */
|
||||
data: T;
|
||||
/** 响应消息 */
|
||||
message?: string;
|
||||
/** 响应状态码 */
|
||||
code?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 错误响应结构
|
||||
*/
|
||||
export interface ApiError {
|
||||
/** 错误消息 */
|
||||
error_msg: string;
|
||||
/** 错误代码 */
|
||||
error_code?: string;
|
||||
/** 错误详情 */
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页请求参数
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
/** 页码,从 1 开始 */
|
||||
page: number;
|
||||
/** 每页数量 */
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应数据
|
||||
* @template T - 列表项类型
|
||||
*/
|
||||
export interface PaginationResponse<T> {
|
||||
/** 数据列表 */
|
||||
items: T[];
|
||||
/** 总数 */
|
||||
total: number;
|
||||
/** 当前页码 */
|
||||
page: number;
|
||||
/** 每页数量 */
|
||||
page_size: number;
|
||||
/** 总页数 */
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 请求配置
|
||||
*/
|
||||
export interface RequestConfig {
|
||||
/** 请求超时时间(毫秒) */
|
||||
timeout?: number;
|
||||
/** 请求头 */
|
||||
headers?: Record<string, string>;
|
||||
/** 是否携带凭证 */
|
||||
withCredentials?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 数据库管理服务类
|
||||
*/
|
||||
export class DbManageService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/admin/db-manage';
|
||||
|
||||
/**
|
||||
* 获取数据库运行概览
|
||||
*/
|
||||
static async getOverview(): Promise<DBOverview> {
|
||||
return this.get<DBOverview>('/overview');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库所有物理表名
|
||||
*/
|
||||
static async listTables(): Promise<string[]> {
|
||||
return this.get<string[]>('/tables');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某张数据表的数据(分页)
|
||||
*/
|
||||
static async getTableData(params: {
|
||||
table: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<TableDataResponse> {
|
||||
return this.get<TableDataResponse>('/table-data', params as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在数据库中执行自定义 SQL 语句
|
||||
*/
|
||||
static async executeSQL(sql: string): Promise<ExecuteSQLResponse> {
|
||||
return this.post<ExecuteSQLResponse>('/query', { sql });
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出数据库备份(SQLite .db / PostgreSQL .sql)
|
||||
*/
|
||||
static async exportDatabase(): Promise<{ blob: Blob; filename: string }> {
|
||||
const response = await apiClient.get<Blob>('/api/v1/admin/db-export', {
|
||||
withCredentials: true,
|
||||
responseType: 'blob',
|
||||
} as InternalAxiosRequestConfig);
|
||||
|
||||
const disposition = response.headers['content-disposition'] as string | undefined;
|
||||
let filename = 'openflare_export';
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";]+)"?/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
return { blob: response.data, filename };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DbManageService } from './db-manage.service';
|
||||
export type { DBOverview, TableDataResponse, ExecuteSQLResponse } from './types';
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 数据库概览信息
|
||||
*/
|
||||
export interface DBOverview {
|
||||
type: string;
|
||||
version: string;
|
||||
name: string;
|
||||
size: string;
|
||||
table_count: number;
|
||||
connections: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态数据表分页数据响应
|
||||
*/
|
||||
export interface TableDataResponse {
|
||||
columns: string[];
|
||||
total: number;
|
||||
results: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行自定义 SQL 的响应
|
||||
*/
|
||||
export interface ExecuteSQLResponse {
|
||||
type: "select" | "exec";
|
||||
columns?: string[];
|
||||
results?: Record<string, unknown>[];
|
||||
affected_rows: number;
|
||||
execution_time_ms: number;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 服务层统一入口
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import services from '@/lib/services';
|
||||
*
|
||||
* const user = await services.auth.getUserInfo();
|
||||
* const configs = await services.adminSystemConfig.listSystemConfigs();
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {
|
||||
AdminAuthSourceService,
|
||||
AdminCacheService,
|
||||
AdminLogService,
|
||||
AdminStatusService,
|
||||
AdminSystemConfigService,
|
||||
AdminTaskService,
|
||||
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 {
|
||||
AccessLogService,
|
||||
ApplyLogService,
|
||||
ConfigVersionService,
|
||||
DashboardService,
|
||||
DnsAccountService,
|
||||
NodeService,
|
||||
OptionService,
|
||||
OriginService,
|
||||
PagesService,
|
||||
ProxyRouteService,
|
||||
StatusService,
|
||||
TlsCertificateService,
|
||||
UptimeKumaService,
|
||||
WafService,
|
||||
WebsiteService,
|
||||
} from './openflare';
|
||||
|
||||
const services = {
|
||||
auth: AuthService,
|
||||
adminSystemConfig: AdminSystemConfigService,
|
||||
adminAuthSource: AdminAuthSourceService,
|
||||
adminTask: AdminTaskService,
|
||||
adminUser: AdminUserService,
|
||||
adminStatus: AdminStatusService,
|
||||
adminLog: AdminLogService,
|
||||
adminTemplate: AdminTemplateService,
|
||||
adminCache: AdminCacheService,
|
||||
user: UserService,
|
||||
config: ConfigService,
|
||||
upload: UploadService,
|
||||
adminUpload: AdminUploadService,
|
||||
dbManage: DbManageService,
|
||||
push: PushService,
|
||||
openflareNode: NodeService,
|
||||
openflareProxyRoute: ProxyRouteService,
|
||||
openflareConfigVersion: ConfigVersionService,
|
||||
openflareApplyLog: ApplyLogService,
|
||||
openflareDashboard: DashboardService,
|
||||
openflareWaf: WafService,
|
||||
openflareWebsite: WebsiteService,
|
||||
openflareTls: TlsCertificateService,
|
||||
openflareDns: DnsAccountService,
|
||||
openflarePages: PagesService,
|
||||
openflareOrigin: OriginService,
|
||||
openflareAccessLog: AccessLogService,
|
||||
openflareOption: OptionService,
|
||||
openflareUptimeKuma: UptimeKumaService,
|
||||
openflareStatus: StatusService,
|
||||
} as const;
|
||||
|
||||
export default services;
|
||||
export { services };
|
||||
|
||||
// ==================== 核心模块导出 ====================
|
||||
|
||||
export {
|
||||
apiClient,
|
||||
BaseService,
|
||||
apiConfig,
|
||||
cancelRequest,
|
||||
cancelAllRequests,
|
||||
} from './core';
|
||||
|
||||
export {
|
||||
ApiErrorBase,
|
||||
NetworkError,
|
||||
TimeoutError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ServerError,
|
||||
ValidationError,
|
||||
isCancelError,
|
||||
} from './core';
|
||||
|
||||
export type {
|
||||
ApiResponse,
|
||||
ApiError,
|
||||
PaginationParams,
|
||||
PaginationResponse,
|
||||
RequestConfig,
|
||||
} from './core';
|
||||
|
||||
// ==================== 业务服务导出 ====================
|
||||
|
||||
export { AuthService } from './auth';
|
||||
export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest, AuthSource, ExternalAccountBinding, ChangePasswordRequest, UpdateProfileRequest } from './auth';
|
||||
|
||||
export { ConfigService } from './config';
|
||||
export type { PublicConfigResponse } from './config';
|
||||
|
||||
export {
|
||||
AdminSystemConfigService,
|
||||
AdminAuthSourceService,
|
||||
AdminTaskService,
|
||||
AdminUserService,
|
||||
AdminStatusService,
|
||||
AdminLogService,
|
||||
AdminTemplateService,
|
||||
AdminCacheService,
|
||||
} from './admin';
|
||||
|
||||
export type {
|
||||
SystemConfig,
|
||||
CreateSystemConfigRequest,
|
||||
UpdateSystemConfigRequest,
|
||||
TaskMeta,
|
||||
TaskParam,
|
||||
TaskParamType,
|
||||
TaskExecution,
|
||||
TaskExecutionStatus,
|
||||
ListTaskExecutionsRequest,
|
||||
ListTaskExecutionsResponse,
|
||||
DispatchTaskRequest,
|
||||
AdminUser,
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
UpdateUserStatusRequest,
|
||||
CreateUserRequest,
|
||||
SystemStatus,
|
||||
AppUpdateStatus,
|
||||
Schedule,
|
||||
CreateScheduleRequest,
|
||||
UpdateScheduleRequest,
|
||||
CacheStatus,
|
||||
CacheConfig,
|
||||
Template,
|
||||
CreateTemplateRequest,
|
||||
UpdateTemplateRequest,
|
||||
AuthSource as AdminAuthSource,
|
||||
AuthSourceRequest,
|
||||
ToggleAuthSourceRequest,
|
||||
StorageDriver,
|
||||
StorageConfig,
|
||||
ObjectStorageConfig,
|
||||
} from './admin';
|
||||
|
||||
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 { DbManageService } 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';
|
||||
|
||||
// ==================== OpenFlare 业务服务导出 ====================
|
||||
|
||||
export {
|
||||
OpenFlareBaseService,
|
||||
NodeService,
|
||||
ProxyRouteService,
|
||||
ConfigVersionService,
|
||||
ApplyLogService,
|
||||
DashboardService,
|
||||
WafService,
|
||||
WebsiteService,
|
||||
TlsCertificateService,
|
||||
DnsAccountService,
|
||||
PagesService,
|
||||
OriginService,
|
||||
AccessLogService,
|
||||
OptionService,
|
||||
StatusService,
|
||||
UptimeKumaService,
|
||||
} from './openflare';
|
||||
|
||||
export type {
|
||||
NodeItem,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteConfigSection,
|
||||
ConfigVersionSummary,
|
||||
ConfigVersionDetail,
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ApplyLogList,
|
||||
DashboardOverview,
|
||||
WAFIPGroup,
|
||||
WAFRuleGroup,
|
||||
WAFSiteRuleGroups,
|
||||
ManagedDomainItem,
|
||||
TlsCertificateItem,
|
||||
DnsAccountItem,
|
||||
PagesProject,
|
||||
PagesDeployment,
|
||||
OriginItem,
|
||||
OriginDetail,
|
||||
AccessLogList,
|
||||
OptionItem,
|
||||
GeoIPLookupResult,
|
||||
DatabaseCleanupResult,
|
||||
OpenFlarePublicStatus,
|
||||
} from './openflare';
|
||||
@@ -0,0 +1,76 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
AccessLogCleanupPayload,
|
||||
AccessLogCleanupResult,
|
||||
AccessLogFilters,
|
||||
AccessLogIPSummaryFilters,
|
||||
AccessLogIPSummaryList,
|
||||
AccessLogIPTrend,
|
||||
AccessLogIPTrendFilters,
|
||||
AccessLogList,
|
||||
FoldedAccessLogFilters,
|
||||
FoldedAccessLogIPFilters,
|
||||
FoldedAccessLogIPList,
|
||||
FoldedAccessLogList,
|
||||
} from './types';
|
||||
|
||||
function buildSearchParams(filters: object): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
Object.entries(filters as Record<string, string | number | undefined>).forEach(
|
||||
([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
params[key] = value;
|
||||
},
|
||||
);
|
||||
return params;
|
||||
}
|
||||
|
||||
export class AccessLogService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/access-logs';
|
||||
|
||||
static list(filters: AccessLogFilters = {}): Promise<AccessLogList> {
|
||||
return this.get<AccessLogList>('/', buildSearchParams(filters));
|
||||
}
|
||||
|
||||
static listFolds(
|
||||
filters: FoldedAccessLogFilters,
|
||||
): Promise<FoldedAccessLogList> {
|
||||
return this.get<FoldedAccessLogList>(
|
||||
'/folds',
|
||||
buildSearchParams(filters),
|
||||
);
|
||||
}
|
||||
|
||||
static listFoldIPs(
|
||||
filters: FoldedAccessLogIPFilters,
|
||||
): Promise<FoldedAccessLogIPList> {
|
||||
return this.get<FoldedAccessLogIPList>(
|
||||
'/folds/ip-summary',
|
||||
buildSearchParams(filters),
|
||||
);
|
||||
}
|
||||
|
||||
static listIPSummaries(
|
||||
filters: AccessLogIPSummaryFilters = {},
|
||||
): Promise<AccessLogIPSummaryList> {
|
||||
return this.get<AccessLogIPSummaryList>(
|
||||
'/ip-summary',
|
||||
buildSearchParams(filters),
|
||||
);
|
||||
}
|
||||
|
||||
static getIPTrend(
|
||||
filters: AccessLogIPTrendFilters,
|
||||
): Promise<AccessLogIPTrend> {
|
||||
return this.get<AccessLogIPTrend>(
|
||||
'/ip-summary/trend',
|
||||
buildSearchParams(filters),
|
||||
);
|
||||
}
|
||||
|
||||
static cleanup(
|
||||
payload: AccessLogCleanupPayload,
|
||||
): Promise<AccessLogCleanupResult> {
|
||||
return this.post<AccessLogCleanupResult>('/cleanup', payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
ApplyLogCleanupPayload,
|
||||
ApplyLogCleanupResult,
|
||||
ApplyLogList,
|
||||
ApplyLogListQuery,
|
||||
} from './types';
|
||||
|
||||
export class ApplyLogService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/apply-logs';
|
||||
|
||||
static list(query: ApplyLogListQuery = {}): Promise<ApplyLogList> {
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
const normalizedNodeId = query.node_id?.trim();
|
||||
if (normalizedNodeId) {
|
||||
params.node_id = normalizedNodeId;
|
||||
}
|
||||
if (query.pageNo) {
|
||||
params.pageNo = query.pageNo;
|
||||
}
|
||||
if (query.pageSize) {
|
||||
params.pageSize = query.pageSize;
|
||||
}
|
||||
|
||||
return this.get<ApplyLogList>('/', params);
|
||||
}
|
||||
|
||||
static cleanup(payload: ApplyLogCleanupPayload): Promise<ApplyLogCleanupResult> {
|
||||
return this.post<ApplyLogCleanupResult>('/cleanup', payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {BaseService} from '@/lib/services/core';
|
||||
|
||||
export class OpenFlareBaseService extends BaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d';
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {InternalAxiosRequestConfig} from 'axios';
|
||||
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionCleanupPayload,
|
||||
ConfigVersionCleanupResult,
|
||||
ConfigVersionDetail,
|
||||
ConfigVersionSummary,
|
||||
} from './types';
|
||||
|
||||
export class ConfigVersionService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/config-versions';
|
||||
|
||||
static list(): Promise<ConfigVersionSummary[]> {
|
||||
return this.get<ConfigVersionSummary[]>('/');
|
||||
}
|
||||
|
||||
static getActive(): Promise<ConfigVersionDetail> {
|
||||
return this.get<ConfigVersionDetail>('/active');
|
||||
}
|
||||
|
||||
static preview(): Promise<ConfigPreviewResult> {
|
||||
return this.get<ConfigPreviewResult>('/preview');
|
||||
}
|
||||
|
||||
static diff(): Promise<ConfigDiffResult> {
|
||||
return this.get<ConfigDiffResult>('/diff');
|
||||
}
|
||||
|
||||
static getById(id: number): Promise<ConfigVersionDetail> {
|
||||
return this.get<ConfigVersionDetail>(`/${id}`);
|
||||
}
|
||||
|
||||
static publish(force?: boolean): Promise<ConfigVersionDetail> {
|
||||
return this.post<ConfigVersionDetail>(
|
||||
'/publish',
|
||||
undefined,
|
||||
force
|
||||
? ({ params: { force: true } } as InternalAxiosRequestConfig)
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
static activate(id: number): Promise<ConfigVersionDetail> {
|
||||
return this.post<ConfigVersionDetail>(`/${id}/activate`);
|
||||
}
|
||||
|
||||
static cleanup(
|
||||
payload: ConfigVersionCleanupPayload,
|
||||
): Promise<ConfigVersionCleanupResult> {
|
||||
return this.post<ConfigVersionCleanupResult>('/cleanup', payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
CompactCapacityTrendPoint,
|
||||
CompactDashboardNodeHealth,
|
||||
CompactDiskIOTrendPoint,
|
||||
CompactDistributionItem,
|
||||
CompactNetworkTrendPoint,
|
||||
CompactTrafficTrendPoint,
|
||||
DashboardCapacity,
|
||||
DashboardNodeHealth,
|
||||
DashboardOverview,
|
||||
DashboardOverviewCompact,
|
||||
DashboardSummary,
|
||||
DashboardTraffic,
|
||||
DistributionItem,
|
||||
} from './types';
|
||||
|
||||
function arrayOrEmpty<T>(value: T[] | null | undefined) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function isCompactDistributionItem(
|
||||
value: DistributionItem | CompactDistributionItem,
|
||||
): value is CompactDistributionItem {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactTrafficTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['traffic_24h'][number]
|
||||
| CompactTrafficTrendPoint,
|
||||
): value is CompactTrafficTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactCapacityTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['capacity_24h'][number]
|
||||
| CompactCapacityTrendPoint,
|
||||
): value is CompactCapacityTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactNetworkTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['network_24h'][number]
|
||||
| CompactNetworkTrendPoint,
|
||||
): value is CompactNetworkTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactDiskIOTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['disk_io_24h'][number]
|
||||
| CompactDiskIOTrendPoint,
|
||||
): value is CompactDiskIOTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactDashboardNode(
|
||||
value: DashboardNodeHealth | CompactDashboardNodeHealth,
|
||||
): value is CompactDashboardNodeHealth {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeDistributionItems(
|
||||
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
|
||||
): DistributionItem[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDistributionItem(item)
|
||||
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTrafficTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['traffic_24h'][number]
|
||||
| CompactTrafficTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactTrafficTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
request_count: Number(item[1] ?? 0),
|
||||
error_count: Number(item[2] ?? 0),
|
||||
unique_visitor_count: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCapacityTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['capacity_24h'][number]
|
||||
| CompactCapacityTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactCapacityTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
average_cpu_usage_percent: Number(item[1] ?? 0),
|
||||
average_memory_usage_percent: Number(item[2] ?? 0),
|
||||
reported_nodes: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeNetworkTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['network_24h'][number]
|
||||
| CompactNetworkTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactNetworkTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
network_rx_bytes: Number(item[1] ?? 0),
|
||||
network_tx_bytes: Number(item[2] ?? 0),
|
||||
openresty_rx_bytes: Number(item[3] ?? 0),
|
||||
openresty_tx_bytes: Number(item[4] ?? 0),
|
||||
reported_nodes: Number(item[5] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDiskIOTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['disk_io_24h'][number]
|
||||
| CompactDiskIOTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDiskIOTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
disk_read_bytes: Number(item[1] ?? 0),
|
||||
disk_write_bytes: Number(item[2] ?? 0),
|
||||
reported_nodes: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDashboardNodes(
|
||||
items:
|
||||
| Array<DashboardNodeHealth | CompactDashboardNodeHealth>
|
||||
| null
|
||||
| undefined,
|
||||
): DashboardNodeHealth[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDashboardNode(item)
|
||||
? {
|
||||
id: Number(item[0] ?? 0),
|
||||
node_id: String(item[1] ?? ''),
|
||||
name: String(item[2] ?? ''),
|
||||
geo_name: String(item[3] ?? ''),
|
||||
geo_latitude:
|
||||
item[4] === null || item[4] === undefined ? null : Number(item[4]),
|
||||
geo_longitude:
|
||||
item[5] === null || item[5] === undefined ? null : Number(item[5]),
|
||||
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
|
||||
openresty_status: (item[7] ??
|
||||
'unknown') as DashboardNodeHealth['openresty_status'],
|
||||
current_version: String(item[8] ?? ''),
|
||||
last_seen_at: String(item[9] ?? ''),
|
||||
active_event_count: Number(item[10] ?? 0),
|
||||
cpu_usage_percent: Number(item[11] ?? 0),
|
||||
memory_usage_percent: Number(item[12] ?? 0),
|
||||
storage_usage_percent: Number(item[13] ?? 0),
|
||||
request_count: Number(item[14] ?? 0),
|
||||
error_count: Number(item[15] ?? 0),
|
||||
unique_visitor_count: Number(item[16] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDashboardOverview(
|
||||
overview: DashboardOverview | DashboardOverviewCompact | null | undefined,
|
||||
): DashboardOverview | null {
|
||||
if (!overview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary = (overview.summary ?? {}) as DashboardSummary;
|
||||
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
|
||||
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
|
||||
|
||||
return {
|
||||
generated_at: String(overview.generated_at ?? ''),
|
||||
summary,
|
||||
traffic,
|
||||
capacity,
|
||||
nodes: normalizeDashboardNodes(overview.nodes),
|
||||
distributions: {
|
||||
source_countries: normalizeDistributionItems(
|
||||
overview.distributions?.source_countries,
|
||||
),
|
||||
status_codes: normalizeDistributionItems(
|
||||
overview.distributions?.status_codes,
|
||||
),
|
||||
top_domains: normalizeDistributionItems(
|
||||
overview.distributions?.top_domains,
|
||||
),
|
||||
},
|
||||
trends: {
|
||||
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
|
||||
capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
|
||||
network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
|
||||
disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class DashboardService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/dashboard';
|
||||
|
||||
static async getOverview(): Promise<DashboardOverview | null> {
|
||||
const overview = await this.get<
|
||||
DashboardOverview | DashboardOverviewCompact
|
||||
>('/overview');
|
||||
return normalizeDashboardOverview(overview);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {DnsAccountItem, DnsAccountMutationPayload} from './types';
|
||||
|
||||
export class DnsAccountService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/dns-accounts';
|
||||
|
||||
static async list(): Promise<DnsAccountItem[]> {
|
||||
return this.get<DnsAccountItem[]>('/');
|
||||
}
|
||||
|
||||
static async create(
|
||||
payload: DnsAccountMutationPayload,
|
||||
): Promise<DnsAccountItem> {
|
||||
return this.post<DnsAccountItem>('/', payload);
|
||||
}
|
||||
|
||||
static async update(
|
||||
id: number,
|
||||
payload: DnsAccountMutationPayload,
|
||||
): Promise<DnsAccountItem> {
|
||||
return this.post<DnsAccountItem>(`/${id}/update`, payload);
|
||||
}
|
||||
|
||||
static async deleteById(id: number): Promise<void> {
|
||||
return this.post<void>(`/${id}/delete`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
export { OpenFlareBaseService } from './base.service';
|
||||
|
||||
export { NodeService } from './node.service';
|
||||
export { ProxyRouteService } from './proxy-route.service';
|
||||
export { ConfigVersionService } from './config-version.service';
|
||||
export { ApplyLogService } from './apply-log.service';
|
||||
export { DashboardService } from './dashboard.service';
|
||||
export { WafService } from './waf.service';
|
||||
export { WebsiteService } from './website.service';
|
||||
export { TlsCertificateService } from './tls-certificate.service';
|
||||
export { DnsAccountService } from './dns-account.service';
|
||||
export { PagesService } from './pages.service';
|
||||
export { OriginService } from './origin.service';
|
||||
export { AccessLogService } from './access-log.service';
|
||||
export { OptionService } from './option.service';
|
||||
export { UptimeKumaService } from './uptimekuma.service';
|
||||
export { StatusService } from './status.service';
|
||||
|
||||
export type {
|
||||
ApplyLogCleanupPayload,
|
||||
ApplyLogCleanupResult,
|
||||
ApplyLogItem,
|
||||
ApplyLogList,
|
||||
ApplyLogListQuery,
|
||||
ApplyResult,
|
||||
ConfigDiffResult,
|
||||
ConfigOptionDiffItem,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionCleanupPayload,
|
||||
ConfigVersionCleanupResult,
|
||||
ConfigVersionDetail,
|
||||
ConfigVersionSummary,
|
||||
NodeAgentReleaseInfo,
|
||||
NodeAgentUpdatePayload,
|
||||
NodeBootstrapToken,
|
||||
NodeItem,
|
||||
NodeMutationPayload,
|
||||
NodeHealthEvent,
|
||||
NodeObservability,
|
||||
NodeObservabilityAnalytics,
|
||||
NodeObservabilityTrends,
|
||||
NodeSystemProfile,
|
||||
NodeTrafficReport,
|
||||
NodeStatus,
|
||||
NodeType,
|
||||
OpenrestyStatus,
|
||||
ProxyRouteConfigSection,
|
||||
ProxyRouteCustomHeader,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
ProxyRoutePoWConfig,
|
||||
ReleaseChannel,
|
||||
SupportFile,
|
||||
DashboardOverview,
|
||||
DashboardNodeHealth,
|
||||
DashboardSummary,
|
||||
DashboardTraffic,
|
||||
DashboardCapacity,
|
||||
DistributionItem,
|
||||
TrafficDistributions,
|
||||
TrafficTrendPoint,
|
||||
CapacityTrendPoint,
|
||||
NetworkTrendPoint,
|
||||
DiskIOTrendPoint,
|
||||
WAFIPGroup,
|
||||
WAFIPGroupAutoTestPayload,
|
||||
WAFIPGroupAutoTestResult,
|
||||
WAFIPGroupPayload,
|
||||
WAFIPGroupSyncResult,
|
||||
WAFIPGroupSubscriptionFormat,
|
||||
WAFIPGroupType,
|
||||
WAFRuleGroup,
|
||||
WAFRuleGroupPayload,
|
||||
WAFSiteRuleGroups,
|
||||
AccessLogCleanupPayload,
|
||||
AccessLogCleanupResult,
|
||||
AccessLogFilters,
|
||||
AccessLogIPSummaryFilters,
|
||||
AccessLogIPSummaryItem,
|
||||
AccessLogIPSummaryList,
|
||||
AccessLogIPTrend,
|
||||
AccessLogIPTrendFilters,
|
||||
AccessLogItem,
|
||||
AccessLogList,
|
||||
FoldedAccessLogFilters,
|
||||
FoldedAccessLogIPFilters,
|
||||
FoldedAccessLogIPList,
|
||||
FoldedAccessLogList,
|
||||
OptionItem,
|
||||
GeoIPLookupResult,
|
||||
DatabaseCleanupPayload,
|
||||
DatabaseCleanupResult,
|
||||
DatabaseCleanupTarget,
|
||||
OpenFlarePublicStatus,
|
||||
OriginDetail,
|
||||
OriginItem,
|
||||
OriginMutationPayload,
|
||||
PagesDeployment,
|
||||
PagesDeploymentFile,
|
||||
PagesProject,
|
||||
PagesProjectPayload,
|
||||
AcmeAccountItem,
|
||||
DnsAccountItem,
|
||||
DnsAccountMutationPayload,
|
||||
ManagedDomainItem,
|
||||
ManagedDomainMatchResult,
|
||||
ManagedDomainMutationPayload,
|
||||
TlsCertificateApplyPayload,
|
||||
TlsCertificateContentItem,
|
||||
TlsCertificateDetailItem,
|
||||
TlsCertificateFileImportPayload,
|
||||
TlsCertificateItem,
|
||||
TlsCertificateMutationPayload,
|
||||
} from './types';
|
||||
|
||||
import {AccessLogService} from './access-log.service';
|
||||
import {ApplyLogService} from './apply-log.service';
|
||||
import {ConfigVersionService} from './config-version.service';
|
||||
import {DashboardService} from './dashboard.service';
|
||||
import {DnsAccountService} from './dns-account.service';
|
||||
import {NodeService} from './node.service';
|
||||
import {OptionService} from './option.service';
|
||||
import {StatusService} from './status.service';
|
||||
import {UptimeKumaService} from './uptimekuma.service';
|
||||
import {OriginService} from './origin.service';
|
||||
import {PagesService} from './pages.service';
|
||||
import {ProxyRouteService} from './proxy-route.service';
|
||||
import {TlsCertificateService} from './tls-certificate.service';
|
||||
import {WafService} from './waf.service';
|
||||
import {WebsiteService} from './website.service';
|
||||
|
||||
export const openflareServices = {
|
||||
node: NodeService,
|
||||
proxyRoute: ProxyRouteService,
|
||||
configVersion: ConfigVersionService,
|
||||
applyLog: ApplyLogService,
|
||||
dashboard: DashboardService,
|
||||
waf: WafService,
|
||||
website: WebsiteService,
|
||||
tlsCertificate: TlsCertificateService,
|
||||
dnsAccount: DnsAccountService,
|
||||
pages: PagesService,
|
||||
origin: OriginService,
|
||||
accessLog: AccessLogService,
|
||||
option: OptionService,
|
||||
uptimeKuma: UptimeKumaService,
|
||||
status: StatusService,
|
||||
} as const;
|
||||
@@ -0,0 +1,78 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
NodeAgentReleaseInfo,
|
||||
NodeAgentUpdatePayload,
|
||||
NodeBootstrapToken,
|
||||
NodeItem,
|
||||
NodeMutationPayload,
|
||||
NodeObservability,
|
||||
ReleaseChannel,
|
||||
} from './types';
|
||||
|
||||
export class NodeService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/nodes';
|
||||
|
||||
static async listNodes(): Promise<NodeItem[]> {
|
||||
return this.get<NodeItem[]>('/');
|
||||
}
|
||||
|
||||
static async createNode(payload: NodeMutationPayload): Promise<NodeItem> {
|
||||
return this.post<NodeItem>('/', payload);
|
||||
}
|
||||
|
||||
static async updateNode(id: number, payload: NodeMutationPayload): Promise<NodeItem> {
|
||||
return this.post<NodeItem>(`/${id}/update`, payload);
|
||||
}
|
||||
|
||||
static async deleteNode(id: number): Promise<void> {
|
||||
return this.post<void>(`/${id}/delete`);
|
||||
}
|
||||
|
||||
static async getBootstrapToken(): Promise<NodeBootstrapToken> {
|
||||
return this.get<NodeBootstrapToken>('/bootstrap-token');
|
||||
}
|
||||
|
||||
static async rotateBootstrapToken(): Promise<NodeBootstrapToken> {
|
||||
return this.post<NodeBootstrapToken>('/bootstrap-token/rotate');
|
||||
}
|
||||
|
||||
static async requestAgentUpdate(
|
||||
id: number,
|
||||
payload?: NodeAgentUpdatePayload,
|
||||
): Promise<NodeItem> {
|
||||
return this.post<NodeItem>(`/${id}/agent-update`, payload ?? {});
|
||||
}
|
||||
|
||||
static async requestForceSync(id: number): Promise<NodeItem> {
|
||||
return this.post<NodeItem>(`/${id}/force-sync`);
|
||||
}
|
||||
|
||||
static async requestOpenrestyRestart(id: number): Promise<NodeItem> {
|
||||
return this.post<NodeItem>(`/${id}/openresty-restart`);
|
||||
}
|
||||
|
||||
static async getAgentRelease(
|
||||
id: number,
|
||||
channel: ReleaseChannel = 'stable',
|
||||
): Promise<NodeAgentReleaseInfo> {
|
||||
return this.get<NodeAgentReleaseInfo>(`/${id}/agent-release`, { channel });
|
||||
}
|
||||
|
||||
static async getObservability(
|
||||
id: number,
|
||||
options?: { hours?: number; limit?: number },
|
||||
): Promise<NodeObservability> {
|
||||
return this.get<NodeObservability>(`/${id}/observability`, {
|
||||
hours: options?.hours,
|
||||
limit: options?.limit,
|
||||
});
|
||||
}
|
||||
|
||||
static async cleanupHealthEvents(
|
||||
id: number,
|
||||
): Promise<{ node_id: string; deleted_count: number }> {
|
||||
return this.post<{ node_id: string; deleted_count: number }>(
|
||||
`/${id}/observability/cleanup`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
DatabaseCleanupPayload,
|
||||
DatabaseCleanupResult,
|
||||
GeoIPLookupResult,
|
||||
OptionBatchPayload,
|
||||
OptionItem,
|
||||
} from './types';
|
||||
|
||||
export class OptionService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/option';
|
||||
|
||||
static list(): Promise<OptionItem[]> {
|
||||
return this.get<OptionItem[]>('/');
|
||||
}
|
||||
|
||||
static update(key: string, value: string): Promise<void> {
|
||||
return this.post<void>('/update', { key, value });
|
||||
}
|
||||
|
||||
static updateBatch(options: OptionItem[]): Promise<void> {
|
||||
const payload: OptionBatchPayload = { options };
|
||||
return this.post<void>('/update-batch', payload);
|
||||
}
|
||||
|
||||
static lookupGeoIP(provider: string, ip: string): Promise<GeoIPLookupResult> {
|
||||
return this.post<GeoIPLookupResult>('/geoip/lookup', { provider, ip });
|
||||
}
|
||||
|
||||
static cleanupDatabase(
|
||||
payload: DatabaseCleanupPayload,
|
||||
): Promise<DatabaseCleanupResult> {
|
||||
return this.post<DatabaseCleanupResult>('/database/cleanup', payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {OriginDetail, OriginItem, OriginMutationPayload} from './types';
|
||||
|
||||
export class OriginService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/origins';
|
||||
|
||||
static list(): Promise<OriginItem[]> {
|
||||
return this.get<OriginItem[]>('/');
|
||||
}
|
||||
|
||||
static getById(id: number): Promise<OriginDetail> {
|
||||
return this.get<OriginDetail>(`/${id}`);
|
||||
}
|
||||
|
||||
static create(payload: OriginMutationPayload): Promise<OriginItem> {
|
||||
return this.post<OriginItem>('/', payload);
|
||||
}
|
||||
|
||||
static update(
|
||||
id: number,
|
||||
payload: OriginMutationPayload,
|
||||
): Promise<OriginItem> {
|
||||
return this.post<OriginItem>(`/${id}/update`, payload);
|
||||
}
|
||||
|
||||
static deleteById(id: number): Promise<void> {
|
||||
return this.post<void>(`/${id}/delete`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {AxiosProgressEvent, InternalAxiosRequestConfig} from 'axios';
|
||||
|
||||
import apiClient from '@/lib/services/core/api-client';
|
||||
import type {ApiResponse} from '@/lib/services/core';
|
||||
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
PagesDeployment,
|
||||
PagesDeploymentFile,
|
||||
PagesDeploymentUploadPayload,
|
||||
PagesProject,
|
||||
PagesProjectPayload,
|
||||
} from './types';
|
||||
|
||||
export class PagesService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/pages';
|
||||
|
||||
static listProjects(): Promise<PagesProject[]> {
|
||||
return this.get<PagesProject[]>('/');
|
||||
}
|
||||
|
||||
static getProject(id: number): Promise<PagesProject> {
|
||||
return this.get<PagesProject>(`/${id}`);
|
||||
}
|
||||
|
||||
static createProject(payload: PagesProjectPayload): Promise<PagesProject> {
|
||||
return this.post<PagesProject>('/', payload);
|
||||
}
|
||||
|
||||
static updateProject(
|
||||
id: number,
|
||||
payload: PagesProjectPayload,
|
||||
): Promise<PagesProject> {
|
||||
return this.post<PagesProject>(`/${id}/update`, payload);
|
||||
}
|
||||
|
||||
static deleteProject(id: number): Promise<void> {
|
||||
return this.post<void>(`/${id}/delete`);
|
||||
}
|
||||
|
||||
static listDeployments(projectId: number): Promise<PagesDeployment[]> {
|
||||
return this.get<PagesDeployment[]>(`/${projectId}/deployments`);
|
||||
}
|
||||
|
||||
static listDeploymentFiles(
|
||||
projectId: number,
|
||||
deploymentId: number,
|
||||
): Promise<PagesDeploymentFile[]> {
|
||||
return this.get<PagesDeploymentFile[]>(
|
||||
`/${projectId}/deployments/${deploymentId}/files`,
|
||||
);
|
||||
}
|
||||
|
||||
static uploadDeployment(
|
||||
projectId: number,
|
||||
payload: PagesDeploymentUploadPayload,
|
||||
): Promise<PagesDeployment> {
|
||||
const formData = new FormData();
|
||||
formData.append('package', payload.file);
|
||||
formData.append('root_dir', payload.rootDir ?? '');
|
||||
formData.append('entry_file', payload.entryFile ?? 'index.html');
|
||||
|
||||
return this.postFormData<PagesDeployment>(
|
||||
`/${projectId}/deployments/upload`,
|
||||
formData,
|
||||
payload.onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
static activateDeployment(
|
||||
projectId: number,
|
||||
deploymentId: number,
|
||||
): Promise<PagesProject> {
|
||||
return this.post<PagesProject>(
|
||||
`/${projectId}/deployments/${deploymentId}/activate`,
|
||||
);
|
||||
}
|
||||
|
||||
static deleteDeployment(
|
||||
projectId: number,
|
||||
deploymentId: number,
|
||||
): Promise<void> {
|
||||
return this.post<void>(
|
||||
`/${projectId}/deployments/${deploymentId}/delete`,
|
||||
);
|
||||
}
|
||||
|
||||
private static async postFormData<T>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<T> {
|
||||
const response = await apiClient.post<ApiResponse<T>>(
|
||||
this.getFullPath(path),
|
||||
formData,
|
||||
{
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (event: AxiosProgressEvent) => {
|
||||
if (!onProgress || !event.total) return;
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(percent);
|
||||
},
|
||||
} as InternalAxiosRequestConfig,
|
||||
);
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {ProxyRouteItem, ProxyRouteMutationPayload} from './types';
|
||||
|
||||
export class ProxyRouteService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/proxy-routes';
|
||||
|
||||
static async list(): Promise<ProxyRouteItem[]> {
|
||||
return this.get<ProxyRouteItem[]>('/');
|
||||
}
|
||||
|
||||
static async getById(id: number): Promise<ProxyRouteItem> {
|
||||
return this.get<ProxyRouteItem>(`/${id}`);
|
||||
}
|
||||
|
||||
static async create(payload: ProxyRouteMutationPayload): Promise<ProxyRouteItem> {
|
||||
return this.post<ProxyRouteItem>('/', payload);
|
||||
}
|
||||
|
||||
static async update(
|
||||
id: number,
|
||||
payload: ProxyRouteMutationPayload,
|
||||
): Promise<ProxyRouteItem> {
|
||||
return this.post<ProxyRouteItem>(`/${id}/update`, payload);
|
||||
}
|
||||
|
||||
static async deleteById(id: number): Promise<void> {
|
||||
return this.post<void>(`/${id}/delete`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {OpenFlarePublicStatus} from './types';
|
||||
|
||||
export class StatusService extends OpenFlareBaseService {
|
||||
static getPublicStatus(): Promise<OpenFlarePublicStatus> {
|
||||
return this.get<OpenFlarePublicStatus>('/status');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
AcmeAccountItem,
|
||||
TlsCertificateApplyPayload,
|
||||
TlsCertificateContentItem,
|
||||
TlsCertificateDetailItem,
|
||||
TlsCertificateFileImportPayload,
|
||||
TlsCertificateItem,
|
||||
TlsCertificateMutationPayload,
|
||||
} from './types';
|
||||
|
||||
class AcmeAccountApi extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/acme-accounts';
|
||||
|
||||
static getDefault(): Promise<AcmeAccountItem> {
|
||||
return this.get<AcmeAccountItem>('/default');
|
||||
}
|
||||
}
|
||||
|
||||
export class TlsCertificateService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/tls-certificates';
|
||||
|
||||
static async list(): Promise<TlsCertificateItem[]> {
|
||||
return this.get<TlsCertificateItem[]>('/');
|
||||
}
|
||||
|
||||
static async getById(id: number): Promise<TlsCertificateDetailItem> {
|
||||
return this.get<TlsCertificateDetailItem>(`/${id}`);
|
||||
}
|
||||
|
||||
static async getContent(id: number): Promise<TlsCertificateContentItem> {
|
||||
return this.get<TlsCertificateContentItem>(`/${id}/content`);
|
||||
}
|
||||
|
||||
static async create(
|
||||
payload: TlsCertificateMutationPayload,
|
||||
): Promise<TlsCertificateItem> {
|
||||
return this.post<TlsCertificateItem>('/', payload);
|
||||
}
|
||||
|
||||
static async update(
|
||||
id: number,
|
||||
payload: TlsCertificateMutationPayload,
|
||||
): Promise<TlsCertificateItem> {
|
||||
return this.post<TlsCertificateItem>(`/${id}/update`, payload);
|
||||
}
|
||||
|
||||
static async deleteById(id: number): Promise<void> {
|
||||
return this.post<void>(`/${id}/delete`);
|
||||
}
|
||||
|
||||
static async apply(
|
||||
payload: TlsCertificateApplyPayload,
|
||||
): Promise<TlsCertificateItem> {
|
||||
return this.post<TlsCertificateItem>('/apply', payload);
|
||||
}
|
||||
|
||||
static async renew(id: number): Promise<TlsCertificateItem> {
|
||||
return this.post<TlsCertificateItem>(`/${id}/renew`);
|
||||
}
|
||||
|
||||
static async updateAcme(
|
||||
id: number,
|
||||
payload: TlsCertificateApplyPayload,
|
||||
): Promise<TlsCertificateItem> {
|
||||
return this.post<TlsCertificateItem>(`/${id}/update-acme`, payload);
|
||||
}
|
||||
|
||||
static async convertToAcme(
|
||||
id: number,
|
||||
payload: TlsCertificateApplyPayload,
|
||||
): Promise<TlsCertificateItem> {
|
||||
return this.post<TlsCertificateItem>(`/${id}/convert-acme`, payload);
|
||||
}
|
||||
|
||||
static async importFile(
|
||||
payload: TlsCertificateFileImportPayload,
|
||||
): Promise<TlsCertificateItem> {
|
||||
const formData = new FormData();
|
||||
formData.append('name', payload.name);
|
||||
formData.append('remark', payload.remark);
|
||||
formData.append('cert_file', payload.certFile);
|
||||
formData.append('key_file', payload.keyFile);
|
||||
|
||||
return this.post<TlsCertificateItem>('/import-file', formData);
|
||||
}
|
||||
|
||||
static getDefaultAcmeAccount(): Promise<AcmeAccountItem> {
|
||||
return AcmeAccountApi.getDefault();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
|
||||
export class UptimeKumaService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/uptimekuma';
|
||||
|
||||
static sync(): Promise<void> {
|
||||
return this.post<void>('/sync');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
WAFIPGroup,
|
||||
WAFIPGroupAutoTestPayload,
|
||||
WAFIPGroupAutoTestResult,
|
||||
WAFIPGroupPayload,
|
||||
WAFIPGroupSyncResult,
|
||||
WAFRuleGroup,
|
||||
WAFRuleGroupPayload,
|
||||
WAFSiteRuleGroups,
|
||||
} from './types';
|
||||
|
||||
export class WafService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/waf';
|
||||
|
||||
static async listRuleGroups(): Promise<WAFRuleGroup[]> {
|
||||
return this.get<WAFRuleGroup[]>('/rule-groups');
|
||||
}
|
||||
|
||||
static async getRuleGroup(id: number): Promise<WAFRuleGroup> {
|
||||
return this.get<WAFRuleGroup>(`/rule-groups/${id}`);
|
||||
}
|
||||
|
||||
static async createRuleGroup(payload: WAFRuleGroupPayload): Promise<WAFRuleGroup> {
|
||||
return this.post<WAFRuleGroup>('/rule-groups', payload);
|
||||
}
|
||||
|
||||
static async updateRuleGroup(
|
||||
id: number,
|
||||
payload: WAFRuleGroupPayload,
|
||||
): Promise<WAFRuleGroup> {
|
||||
return this.post<WAFRuleGroup>(`/rule-groups/${id}/update`, payload);
|
||||
}
|
||||
|
||||
static async deleteRuleGroup(id: number): Promise<void> {
|
||||
return this.post<void>(`/rule-groups/${id}/delete`);
|
||||
}
|
||||
|
||||
static async updateRuleGroupSites(id: number, ids: number[]): Promise<WAFRuleGroup> {
|
||||
return this.post<WAFRuleGroup>(`/rule-groups/${id}/sites`, { ids });
|
||||
}
|
||||
|
||||
static async listSiteRuleGroups(routeId: number): Promise<WAFSiteRuleGroups> {
|
||||
return this.get<WAFSiteRuleGroups>(`/sites/${routeId}/rule-groups`);
|
||||
}
|
||||
|
||||
static async updateSiteRuleGroups(
|
||||
routeId: number,
|
||||
ids: number[],
|
||||
): Promise<WAFSiteRuleGroups> {
|
||||
return this.post<WAFSiteRuleGroups>(`/sites/${routeId}/rule-groups`, {
|
||||
ids,
|
||||
});
|
||||
}
|
||||
|
||||
static async listIPGroups(): Promise<WAFIPGroup[]> {
|
||||
return this.get<WAFIPGroup[]>('/ip-groups');
|
||||
}
|
||||
|
||||
static async getIPGroup(id: number): Promise<WAFIPGroup> {
|
||||
return this.get<WAFIPGroup>(`/ip-groups/${id}`);
|
||||
}
|
||||
|
||||
static async createIPGroup(payload: WAFIPGroupPayload): Promise<WAFIPGroup> {
|
||||
return this.post<WAFIPGroup>('/ip-groups', payload);
|
||||
}
|
||||
|
||||
static async updateIPGroup(id: number, payload: WAFIPGroupPayload): Promise<WAFIPGroup> {
|
||||
return this.post<WAFIPGroup>(`/ip-groups/${id}/update`, payload);
|
||||
}
|
||||
|
||||
static async deleteIPGroup(id: number): Promise<void> {
|
||||
return this.post<void>(`/ip-groups/${id}/delete`);
|
||||
}
|
||||
|
||||
static async testIPGroup(
|
||||
payload: WAFIPGroupAutoTestPayload,
|
||||
): Promise<WAFIPGroupAutoTestResult> {
|
||||
return this.post<WAFIPGroupAutoTestResult>('/ip-groups/test', payload);
|
||||
}
|
||||
|
||||
static async syncIPGroup(id: number): Promise<WAFIPGroupSyncResult> {
|
||||
return this.post<WAFIPGroupSyncResult>(`/ip-groups/${id}/sync`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
ManagedDomainItem,
|
||||
ManagedDomainMatchResult,
|
||||
ManagedDomainMutationPayload,
|
||||
} from './types';
|
||||
|
||||
export class WebsiteService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/managed-domains';
|
||||
|
||||
static async list(): Promise<ManagedDomainItem[]> {
|
||||
return this.get<ManagedDomainItem[]>('/');
|
||||
}
|
||||
|
||||
static async create(
|
||||
payload: ManagedDomainMutationPayload,
|
||||
): Promise<ManagedDomainItem> {
|
||||
return this.post<ManagedDomainItem>('/', payload);
|
||||
}
|
||||
|
||||
static async update(
|
||||
id: number,
|
||||
payload: ManagedDomainMutationPayload,
|
||||
): Promise<ManagedDomainItem> {
|
||||
return this.post<ManagedDomainItem>(`/${id}/update`, payload);
|
||||
}
|
||||
|
||||
static async deleteById(id: number): Promise<void> {
|
||||
return this.post<void>(`/${id}/delete`);
|
||||
}
|
||||
|
||||
static async match(domain: string): Promise<ManagedDomainMatchResult> {
|
||||
return this.get<ManagedDomainMatchResult>('/match', {domain});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export * from './types';
|
||||
export * from './push.service';
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {BaseService} from '@/lib/services/core';
|
||||
import type {
|
||||
ChannelDefinition,
|
||||
CreateChannelRequest,
|
||||
CreatePushEventRequest,
|
||||
EventMetadata,
|
||||
ListPushHistoriesRequest,
|
||||
ListPushHistoriesResponse,
|
||||
PushChannel,
|
||||
PushEvent,
|
||||
TestChannelRequest,
|
||||
TestPushRequest,
|
||||
UpdateChannelRequest,
|
||||
UpdatePushEventRequest
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 通知推送服务类
|
||||
*/
|
||||
export class PushService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/admin/push';
|
||||
|
||||
/**
|
||||
* 获取通知事件列表
|
||||
*/
|
||||
static async listEvents(): Promise<PushEvent[]> {
|
||||
return this.get<PushEvent[]>('/events');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统内置通知事件元数据列表
|
||||
*/
|
||||
static async listBuiltInEvents(): Promise<EventMetadata[]> {
|
||||
return this.get<EventMetadata[]>('/events/builtin');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建通知事件配置
|
||||
*/
|
||||
static async createEvent(data: CreatePushEventRequest): Promise<PushEvent> {
|
||||
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 deleteEvent(id: number): Promise<void> {
|
||||
return this.delete<void>(`/events/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷切换事件启用状态
|
||||
*/
|
||||
static async toggleEvent(id: number): Promise<boolean> {
|
||||
return this.post<boolean>(`/events/${id}/toggle`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询通知推送历史
|
||||
*/
|
||||
static async listHistories(params: ListPushHistoriesRequest): Promise<ListPushHistoriesResponse> {
|
||||
return this.get<ListPushHistoriesResponse>('/histories', params as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送测试推送进行联通性校验
|
||||
*/
|
||||
static async testPush(data: TestPushRequest): Promise<void> {
|
||||
return this.post<void>('/test', data as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有消息通道
|
||||
*/
|
||||
static async listChannels(): Promise<PushChannel[]> {
|
||||
return this.get<PushChannel[]>('/channels');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的消息通道
|
||||
*/
|
||||
static async createChannel(data: CreateChannelRequest): Promise<PushChannel> {
|
||||
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 deleteChannel(id: number): Promise<void> {
|
||||
return this.delete<void>(`/channels/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试消息通道连通性
|
||||
*/
|
||||
static async testChannel(data: TestChannelRequest): Promise<void> {
|
||||
return this.post<void>('/channels/test', data as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取各消息通道的动态表单定义
|
||||
*/
|
||||
static async listChannelDefinitions(): Promise<ChannelDefinition[]> {
|
||||
return this.get<ChannelDefinition[]>('/channels/definitions');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export interface PushEvent {
|
||||
id: number;
|
||||
event_key: string;
|
||||
name: string;
|
||||
task_type?: string;
|
||||
channels: string[];
|
||||
targets: string[];
|
||||
template: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PushHistory {
|
||||
id: number;
|
||||
event_key: string;
|
||||
channel: string;
|
||||
target: string;
|
||||
title: string;
|
||||
content: string;
|
||||
level: 'INFO' | 'IMPORTANT' | 'CRITICAL';
|
||||
status: 'success' | 'failed';
|
||||
error_msg?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PushChannelConfig {
|
||||
channel: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
key?: string;
|
||||
ext?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ListPushHistoriesRequest {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
event_key?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ListPushHistoriesResponse {
|
||||
total: number;
|
||||
results: PushHistory[];
|
||||
}
|
||||
|
||||
export interface UpdatePushEventRequest {
|
||||
channels: string[];
|
||||
targets: string[];
|
||||
template: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TestPushRequest {
|
||||
config: PushChannelConfig;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
export interface EventMetadata {
|
||||
key: string;
|
||||
name: string;
|
||||
default_template: {
|
||||
title: string;
|
||||
content: string;
|
||||
level: string;
|
||||
ext?: Record<string, unknown>;
|
||||
};
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CreatePushEventRequest {
|
||||
event_key?: string;
|
||||
task_type?: string;
|
||||
channels: string[];
|
||||
targets?: string[];
|
||||
template?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PushChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
token?: string;
|
||||
url: string;
|
||||
other: string;
|
||||
enabled: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateChannelRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
token?: string;
|
||||
url: string;
|
||||
other: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateChannelRequest {
|
||||
description?: string;
|
||||
type: string;
|
||||
token?: string;
|
||||
url: string;
|
||||
other: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface TestChannelRequest {
|
||||
name?: string;
|
||||
type?: string;
|
||||
url?: string;
|
||||
other?: string;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
export interface ChannelFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'textarea';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ChannelDefinition {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fields: ChannelFieldDef[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {InternalAxiosRequestConfig} from 'axios';
|
||||
|
||||
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';
|
||||
|
||||
static async listUploads(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
keyword?: string,
|
||||
type?: string,
|
||||
extension?: string,
|
||||
): Promise<ListUploadsResponse> {
|
||||
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;
|
||||
return this.get<ListUploadsResponse>('', params);
|
||||
}
|
||||
|
||||
static async getFileStats(): Promise<FileStatsResponse> {
|
||||
return this.get<FileStatsResponse>('/stats');
|
||||
}
|
||||
|
||||
static async deleteFile(id: string): Promise<void> {
|
||||
return this.delete<void>(`/${id}`);
|
||||
}
|
||||
|
||||
static getDownloadUrl(id: string): string {
|
||||
return `${this.basePath}/download/${id}`;
|
||||
}
|
||||
|
||||
static async batchDownload(ids: string[]): Promise<Blob> {
|
||||
return this.post<Blob>('/download/batch', { ids }, {
|
||||
responseType: 'blob',
|
||||
} as InternalAxiosRequestConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
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';
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 文件上传元数据
|
||||
*/
|
||||
export interface UploadMetadata {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传接口响应(原 UploadImageResponse 兼容)
|
||||
*/
|
||||
export interface UploadImageResponse {
|
||||
/** 上传记录 ID */
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表查询参数
|
||||
*/
|
||||
export interface ListUploadsQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
type?: string
|
||||
extension?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表分页响应
|
||||
*/
|
||||
export interface ListUploadsResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
items: Upload[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 近 7 天新增文件趋势项
|
||||
*/
|
||||
export interface TrendItem {
|
||||
date: string
|
||||
count: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件分类/类型统计项
|
||||
*/
|
||||
export interface DistributionItem {
|
||||
name: string
|
||||
count: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件统计信息响应
|
||||
*/
|
||||
export interface FileStatsResponse {
|
||||
total_count: number
|
||||
total_size: number
|
||||
trend: TrendItem[]
|
||||
categories: DistributionItem[]
|
||||
types: DistributionItem[]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {InternalAxiosRequestConfig} from 'axios';
|
||||
|
||||
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';
|
||||
|
||||
static async uploadFile(
|
||||
file: File,
|
||||
type: string = 'generic',
|
||||
metadata?: Record<string, unknown>,
|
||||
accessMode?: number,
|
||||
): Promise<Upload> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('type', type);
|
||||
if (metadata) {
|
||||
formData.append('metadata', JSON.stringify(metadata));
|
||||
}
|
||||
if (accessMode !== undefined) {
|
||||
formData.append('access_mode', String(accessMode));
|
||||
}
|
||||
|
||||
return this.post<Upload>('', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
} as InternalAxiosRequestConfig);
|
||||
}
|
||||
|
||||
static async uploadBase64Image(
|
||||
base64: string,
|
||||
type: string = 'generic',
|
||||
filename: string = 'image.png',
|
||||
accessMode?: number,
|
||||
): Promise<UploadImageResponse> {
|
||||
const response = await fetch(base64);
|
||||
const blob = await response.blob();
|
||||
const mimeType = base64.match(/data:([^;]+);/)?.[1] || 'image/png';
|
||||
const file = new File([blob], filename, { type: mimeType });
|
||||
const result = await this.uploadFile(file, type, undefined, accessMode);
|
||||
return { id: result.id };
|
||||
}
|
||||
|
||||
static async listMyUploads(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
keyword?: string,
|
||||
type?: string,
|
||||
extension?: string,
|
||||
): Promise<ListUploadsResponse> {
|
||||
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;
|
||||
return this.get<ListUploadsResponse>('/my', params);
|
||||
}
|
||||
|
||||
static async deleteMyFile(id: string): Promise<void> {
|
||||
return this.delete<void>(`/${id}`);
|
||||
}
|
||||
|
||||
static async updateMyFile(id: string, fileName: string, accessMode?: number): Promise<Upload> {
|
||||
return this.put<Upload>(`/${id}`, {
|
||||
file_name: fileName,
|
||||
access_mode: accessMode,
|
||||
});
|
||||
}
|
||||
|
||||
static async batchDownloadMyFiles(ids: string[]): Promise<Blob> {
|
||||
return this.post<Blob>('/download/batch', { ids }, {
|
||||
responseType: 'blob',
|
||||
} as InternalAxiosRequestConfig);
|
||||
}
|
||||
|
||||
static getDownloadUrl(id: string): string {
|
||||
return `${this.basePath}/download/${id}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type ImageQuality = 'low' | 'medium' | 'high' | 'origin';
|
||||
|
||||
export function getFileUrl(
|
||||
id: string | number | null | undefined,
|
||||
quality: ImageQuality = 'origin',
|
||||
): string | null {
|
||||
if (!id) return null;
|
||||
if (quality === 'origin') return `/f/${id}`;
|
||||
return `/f/${id}?quality=${quality}`;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
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]}`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 用户服务模块
|
||||
*
|
||||
* @description
|
||||
* 提供用户个人设置相关的功能
|
||||
*/
|
||||
|
||||
export { UserService } from './user.service';
|
||||
export type { AccessToken, CreateTokenResponse } from './user.service';
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {BaseService} from '../core/base.service';
|
||||
|
||||
export interface AccessToken {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
masked_token: string;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTokenResponse {
|
||||
token: string;
|
||||
record: AccessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户服务
|
||||
* 处理用户个人设置相关的 API 请求
|
||||
*/
|
||||
export class UserService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/user';
|
||||
|
||||
/**
|
||||
* 获取当前用户的 AccessToken 列表
|
||||
*/
|
||||
static async getAccessTokens(): Promise<AccessToken[]> {
|
||||
return this.get<AccessToken[]>('/access-tokens');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个新的 AccessToken
|
||||
* @param name - 令牌名称
|
||||
* @param isAdmin - 是否赋予管理员权限(默认 false)
|
||||
*/
|
||||
static async createAccessToken(name: string, isAdmin = false): Promise<CreateTokenResponse> {
|
||||
return this.post<CreateTokenResponse>('/access-tokens', { name, is_admin: isAdmin });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一个 AccessToken
|
||||
* @param id - 令牌 ID
|
||||
*/
|
||||
static async deleteAccessToken(id: number): Promise<string> {
|
||||
return this.delete<string>(`/access-tokens/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮换一个 AccessToken 密钥
|
||||
* @param id - 令牌 ID
|
||||
*/
|
||||
static async rotateAccessToken(id: number): Promise<CreateTokenResponse> {
|
||||
return this.post<CreateTokenResponse>(`/access-tokens/${id}/rotate`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* task-param-utils.ts
|
||||
*
|
||||
* 统一的任务参数 Payload 构建工具。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 所有 Task Param 的类型转换和空值过滤在这里集中处理,
|
||||
* 组件只负责收集原始字符串输入值。
|
||||
* - 新增参数类型时,只需在 coerceParamValue 中增加一个 case。
|
||||
* - 不引入任何框架/UI 依赖,纯工具函数。
|
||||
*/
|
||||
|
||||
import type {TaskParam} from '@/lib/services/admin/types';
|
||||
|
||||
/** buildTaskPayload 的返回结构 */
|
||||
export interface BuildPayloadResult {
|
||||
/** 验证通过时返回序列化好的 JSON 字符串;失败时为 null */
|
||||
payload: string | null;
|
||||
/** 验证失败时返回人类可读的错误信息;成功时为 null */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 TaskParam 的 type 声明,将原始字符串输入值转换为正确的 JSON 值类型。
|
||||
*
|
||||
* 规则:
|
||||
* - 'number' → 转为 JS number;空字符串视为「未填写」,返回 undefined(由调用方决定是否跳过)
|
||||
* - 'string' / 'text' → 保持字符串,原样返回
|
||||
*
|
||||
* @returns 转换后的值,或 `undefined` 表示该参数应当从 payload 中省略
|
||||
*/
|
||||
function coerceParamValue(
|
||||
param: TaskParam,
|
||||
rawValue: string,
|
||||
): { value: string | number | boolean; ok: true } | { value: undefined; ok: true } | { error: string; ok: false } {
|
||||
const trimmed = rawValue.trim();
|
||||
|
||||
switch (param.type) {
|
||||
case 'number': {
|
||||
if (trimmed === '') {
|
||||
// 空值 → 省略该字段(对 omitempty 字段友好)
|
||||
return { value: undefined, ok: true };
|
||||
}
|
||||
const num = Number(trimmed);
|
||||
if (Number.isNaN(num)) {
|
||||
return { error: `「${param.label}」必须是有效的数字`, ok: false };
|
||||
}
|
||||
return { value: num, ok: true };
|
||||
}
|
||||
case 'boolean': {
|
||||
if (trimmed === '') {
|
||||
// 空值 → 省略该字段
|
||||
return { value: undefined, ok: true };
|
||||
}
|
||||
const val = trimmed.toLowerCase();
|
||||
if (val === 'true') {
|
||||
return { value: true, ok: true };
|
||||
} else if (val === 'false') {
|
||||
return { value: false, ok: true };
|
||||
}
|
||||
return { error: `「${param.label}」必须是 true 或 false`, ok: false };
|
||||
}
|
||||
case 'string':
|
||||
case 'text':
|
||||
default:
|
||||
// 字符串类型:空值也保留(空字符串是合法的字符串值)
|
||||
return { value: trimmed, ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将表单收集的原始参数值(全为 string)构建为类型正确的 JSON payload 字符串。
|
||||
*
|
||||
* 行为:
|
||||
* - `number` 类型参数:序列化为 JSON number,而非 JSON string。
|
||||
* - `number` 类型参数为空且非必填:从 payload 中省略该字段(不发送),
|
||||
* 避免后端收到 `"target_type":""` 再尝试解析为 int 时报错。
|
||||
* - `boolean` 类型参数:序列化为 JSON boolean。
|
||||
* - `required` 字段为空:返回 error。
|
||||
*
|
||||
* @param params 任务的参数定义列表(来自 TaskMeta.params)
|
||||
* @param values 表单收集的原始字符串值,key 为 param.name
|
||||
* @returns BuildPayloadResult
|
||||
*/
|
||||
export function buildTaskPayload(
|
||||
params: TaskParam[],
|
||||
values: Record<string, string>,
|
||||
): BuildPayloadResult {
|
||||
const payloadData: Record<string, string | number | boolean> = {};
|
||||
|
||||
for (const param of params) {
|
||||
const raw = values[param.name] ?? '';
|
||||
|
||||
// 必填项空值检查
|
||||
if (param.required && raw.trim() === '') {
|
||||
return { payload: null, error: `「${param.label}」不能为空` };
|
||||
}
|
||||
|
||||
const result = coerceParamValue(param, raw);
|
||||
|
||||
if (!result.ok) {
|
||||
return { payload: null, error: result.error };
|
||||
}
|
||||
|
||||
if (result.value !== undefined) {
|
||||
// 有值(包括空字符串)才写入
|
||||
payloadData[param.name] = result.value;
|
||||
}
|
||||
// undefined → 省略该字段(number 类型的空值)
|
||||
}
|
||||
|
||||
return { payload: JSON.stringify(payloadData), error: null };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type {ThemeConfig} from "./types"
|
||||
|
||||
/**
|
||||
* 主题系统配置
|
||||
* 定义主题文件的存储位置和 localStorage 键名
|
||||
*/
|
||||
export const THEME_CONFIG: ThemeConfig = {
|
||||
styleDir: "public/style",
|
||||
storageKey: "app-theme-id",
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import React, {createContext, useCallback, useContext, useEffect, useState} 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[]
|
||||
/** 当前激活的主题对象 */
|
||||
currentTheme: Theme | null
|
||||
/** 当前激活的主题 ID */
|
||||
currentThemeId: string | null
|
||||
/** 设置主题的方法 */
|
||||
setTheme: (themeId: string) => void
|
||||
/** 是否正在加载主题 */
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* 自定义主题 Hook
|
||||
* 必须在 CustomThemeProvider 内部使用
|
||||
*/
|
||||
export function useCustomTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error("useCustomTheme must be used within CustomThemeProvider")
|
||||
}
|
||||
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()
|
||||
|
||||
/* 加载所有可用主题 */
|
||||
useEffect(() => {
|
||||
async function loadThemes() {
|
||||
const availableThemes = await getAvailableThemes()
|
||||
const storedId = getStoredThemeId()
|
||||
|
||||
setThemes(availableThemes)
|
||||
setCurrentThemeId(storedId)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
loadThemes()
|
||||
}, [])
|
||||
|
||||
/* 确保组件已挂载,避免水合不一致 */
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 将主题颜色应用到 DOM
|
||||
* @param theme - 要应用的主题
|
||||
* @param isDark - 是否为暗色模式
|
||||
*/
|
||||
const applyThemeColors = useCallback((theme: Theme, isDark: boolean) => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
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)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/* 当主题 ID 或模式改变时,重新应用颜色 */
|
||||
useEffect(() => {
|
||||
if (!mounted || !currentThemeId || themes.length === 0) 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])
|
||||
|
||||
/**
|
||||
* 设置主题
|
||||
* @param themeId - 要设置的主题 ID
|
||||
*/
|
||||
const setTheme = useCallback((themeId: string) => {
|
||||
const theme = themes.find(t => t.id === themeId)
|
||||
if (!theme) return
|
||||
|
||||
setCurrentThemeId(themeId)
|
||||
setStoredThemeId(themeId)
|
||||
|
||||
/* 根据当前解析的主题模式计算是否为暗色 */
|
||||
const isDark = resolvedTheme === "dark"
|
||||
applyThemeColors(theme, isDark)
|
||||
}, [themes, resolvedTheme, applyThemeColors])
|
||||
|
||||
const currentTheme = themes.find(t => t.id === currentThemeId) || null
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
themes,
|
||||
currentTheme,
|
||||
currentThemeId,
|
||||
setTheme,
|
||||
isLoading
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 主题系统
|
||||
* 统一导出整个主题系统的所有功能
|
||||
*/
|
||||
|
||||
/* 类型定义 */
|
||||
export type { Theme, ThemeColors, ThemeConfig } from "./types"
|
||||
|
||||
/* 配置 */
|
||||
export { THEME_CONFIG } from "./config"
|
||||
|
||||
/* 服务端工具 */
|
||||
export { getAvailableThemes } from "./parser"
|
||||
|
||||
/* 客户端工具 */
|
||||
export { getStoredThemeId, setStoredThemeId, removeStoredThemeId } from "./storage"
|
||||
|
||||
/* React 上下文和 Hooks */
|
||||
export { CustomThemeProvider, useCustomTheme } from "./context"
|
||||
@@ -0,0 +1,11 @@
|
||||
import type {Theme} from "./types"
|
||||
import themesData from "./themes.json"
|
||||
|
||||
/**
|
||||
* 获取所有可用主题
|
||||
* 从预生成的 themes.json 中直接读取主题列表和颜色配置
|
||||
* @returns 主题列表,已按默认主题优先且其余按名称排序
|
||||
*/
|
||||
export async function getAvailableThemes(): Promise<Theme[]> {
|
||||
return themesData as Theme[];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import {THEME_CONFIG} from "./config"
|
||||
|
||||
/**
|
||||
* 客户端主题存储工具
|
||||
* 用于在 localStorage 中保存和读取用户的主题偏好
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取存储的主题 ID
|
||||
*/
|
||||
export function getStoredThemeId(): string | null {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除存储的主题 ID
|
||||
*/
|
||||
export function removeStoredThemeId(): void {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.removeItem(THEME_CONFIG.storageKey)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 主题系统类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 主题颜色配置
|
||||
* 包含明亮和暗色两种模式的 CSS 变量映射
|
||||
*/
|
||||
export interface ThemeColors {
|
||||
light: Record<string, string>
|
||||
dark: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题定义
|
||||
*/
|
||||
export interface Theme {
|
||||
/** 主题唯一标识符(CSS 文件名) */
|
||||
id: string
|
||||
/** 主题显示名称 */
|
||||
name: string
|
||||
/** 主题颜色配置 */
|
||||
colors: ThemeColors
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题系统配置
|
||||
*/
|
||||
export interface ThemeConfig {
|
||||
/** CSS 样式文件所在目录 */
|
||||
styleDir: string
|
||||
/** 主题 ID 在 localStorage 中的存储键名 */
|
||||
storageKey: string
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {type ClassValue, clsx} from "clsx"
|
||||
import {twMerge} from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr: string | Date) {
|
||||
try {
|
||||
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}).format(date)
|
||||
} catch {
|
||||
return String(dateStr)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为本地时间字符串(带时区)
|
||||
* @param 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`
|
||||
}
|
||||
|
||||
type Base64Buffer = {
|
||||
from: (input: string, encoding: 'utf-8') => { toString: (encoding: 'base64') => string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 编码
|
||||
* @param value 待编码字符串
|
||||
* @returns Base64 编码后的字符串
|
||||
*/
|
||||
export function encodeBase64(value: string): string {
|
||||
if (typeof globalThis.btoa === 'function') {
|
||||
return globalThis.btoa(value)
|
||||
}
|
||||
|
||||
const bufferConstructor = (globalThis as typeof globalThis & { Buffer?: Base64Buffer }).Buffer
|
||||
if (bufferConstructor) {
|
||||
return bufferConstructor.from(value, 'utf-8').toString('base64')
|
||||
}
|
||||
|
||||
throw new Error('当前环境不支持 Base64 编码')
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成交易缓存的唯一键
|
||||
* @param params 交易查询参数
|
||||
* @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
|
||||
}): 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'
|
||||
|
||||
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 = "/"): string {
|
||||
if (!url) return fallback
|
||||
|
||||
// 1. 拒绝包含控制字符、空白字符或反斜杠的 URL
|
||||
if (/[\u0000-\u001F\u007F-\u009F\s\\]/.test(url)) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// 2. 必须以单个 '/' 开头,且不能以 '//' 开头
|
||||
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++
|
||||
}
|
||||
|
||||
// 检查解码后的内容是否包含控制字符、空白字符或反斜杠
|
||||
if (/[\u0000-\u001F\u007F-\u009F\s\\]/.test(decoded)) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// 检查解码后的内容是否以单个 '/' 开头,且不能以 '//' 开头
|
||||
if (!decoded.startsWith("/") || decoded.startsWith("//")) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// 提取路径部分(即问号 ? 或井号 # 之前的内容),确保其中不含冒号(防止 scheme)、双斜杠或反斜杠
|
||||
const pathPart = decoded.split(/[?#]/)[0]
|
||||
if (pathPart.includes(":") || pathPart.includes("//") || pathPart.includes("\\")) {
|
||||
return fallback
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {isCancelError} from '@/lib/services/core'
|
||||
import {toast} from 'sonner'
|
||||
|
||||
/**
|
||||
* 错误处理选项
|
||||
*/
|
||||
interface HandleContextErrorOptions {
|
||||
/** 是否显示 toast 提示 */
|
||||
showToast?: boolean
|
||||
/** 是否在控制台记录错误 */
|
||||
logError?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一处理 Context 中的错误
|
||||
*
|
||||
* @param error - 捕获的错误对象
|
||||
* @param defaultMessage - 默认错误消息
|
||||
* @param options - 错误处理选项
|
||||
* @returns 标准化的 Error 对象
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* await fetchData()
|
||||
* } catch (error) {
|
||||
* const errorObject = handleContextError(error, '加载数据失败', {
|
||||
* showToast: true,
|
||||
* logError: true
|
||||
* })
|
||||
* setError(errorObject)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function handleContextError(
|
||||
error: unknown,
|
||||
defaultMessage: string,
|
||||
options: HandleContextErrorOptions = {}
|
||||
): Error {
|
||||
const { showToast = false, logError = true } = options
|
||||
|
||||
// 取消的请求不算错误
|
||||
if (isCancelError(error)) {
|
||||
return new Error('请求已取消')
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : defaultMessage
|
||||
const errorObject = error instanceof Error ? error : new Error(defaultMessage)
|
||||
|
||||
if (logError) {
|
||||
console.error(defaultMessage, error)
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
toast.error(defaultMessage, {
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
|
||||
return errorObject
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
|
||||
function getStrictContext<T>(
|
||||
name?: string,
|
||||
): readonly [
|
||||
({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: T;
|
||||
children?: React.ReactNode;
|
||||
}) => React.JSX.Element,
|
||||
() => T,
|
||||
] {
|
||||
const Context = React.createContext<T | undefined>(undefined);
|
||||
|
||||
const Provider = ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: T;
|
||||
children?: React.ReactNode;
|
||||
}) => <Context.Provider value={value}>{children}</Context.Provider>;
|
||||
|
||||
const useSafeContext = () => {
|
||||
const ctx = React.useContext(Context);
|
||||
if (ctx === undefined) {
|
||||
throw new Error(`useContext must be used within ${name ?? 'a Provider'}`);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
return [Provider, useSafeContext] as const;
|
||||
}
|
||||
|
||||
export { getStrictContext };
|
||||
@@ -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,185 @@
|
||||
import PinyinMatch from 'pinyin-match'
|
||||
|
||||
type MatchResult = [number, number] | false;
|
||||
type MatchFunction = (input: string, keys: string) => MatchResult;
|
||||
|
||||
// Handle CJS/ESM interop for pinyin-match
|
||||
const match = ((): MatchFunction | null => {
|
||||
const p = PinyinMatch as unknown;
|
||||
if (!p) return null;
|
||||
|
||||
// Check for .default.match (common in some ESM bundles)
|
||||
const withDefault = p as { default?: { match?: MatchFunction } };
|
||||
if (typeof withDefault.default?.match === 'function') {
|
||||
return withDefault.default.match;
|
||||
}
|
||||
|
||||
// Check for .match (defined in its typings)
|
||||
const withMatch = p as { match?: MatchFunction };
|
||||
if (typeof withMatch.match === 'function') {
|
||||
return withMatch.match;
|
||||
}
|
||||
|
||||
// Check if it's the function itself
|
||||
if (typeof p === 'function') {
|
||||
return p as MatchFunction;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return null;
|
||||
})();
|
||||
|
||||
export interface SearchItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
url: string
|
||||
category: 'page' | 'feature' | 'setting' | 'admin'
|
||||
keywords: string[]
|
||||
icon?: string
|
||||
matchRange?: [number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局搜索数据源
|
||||
* 包含所有可搜索的页面和功能
|
||||
*/
|
||||
export const searchData: SearchItem[] = [
|
||||
// ==================== 总览 ====================
|
||||
{
|
||||
id: 'home',
|
||||
title: '总览',
|
||||
description: '返回控制台总览',
|
||||
url: '/',
|
||||
category: 'page',
|
||||
keywords: ['home', '主页', '首页', 'dashboard', '总览'],
|
||||
},
|
||||
|
||||
// ==================== 文档库 ====================
|
||||
{
|
||||
id: 'docs-api',
|
||||
title: '开发接口文档',
|
||||
description: '查看 RESTful API 接口规格定义',
|
||||
url: '/docs/api',
|
||||
category: 'page',
|
||||
keywords: ['api', 'docs', '文档', '接口', 'specification'],
|
||||
},
|
||||
{
|
||||
id: 'docs-how-to-use',
|
||||
title: '使用帮助文档',
|
||||
description: '查看新手教程和集成示例',
|
||||
url: '/docs/how-to-use',
|
||||
category: 'page',
|
||||
keywords: ['docs', '文档', '使用', 'how to', 'tutorial', '教程', 'help'],
|
||||
},
|
||||
|
||||
// ==================== 个人设置 ====================
|
||||
{
|
||||
id: 'settings',
|
||||
title: '全局设置',
|
||||
description: '配置应用个人偏好选项',
|
||||
url: '/settings',
|
||||
category: 'setting',
|
||||
keywords: ['settings', '设置', '偏好', 'preferences'],
|
||||
},
|
||||
{
|
||||
id: 'settings-profile',
|
||||
title: '我的资料',
|
||||
description: '编辑昵称、头像和个人属性',
|
||||
url: '/settings/profile',
|
||||
category: 'setting',
|
||||
keywords: ['profile', '资料', '个人', '我的', '信息', 'avatar'],
|
||||
},
|
||||
{
|
||||
id: 'settings-appearance',
|
||||
title: '外观设置',
|
||||
description: '配置系统显示主题(亮色/暗色)',
|
||||
url: '/settings/appearance',
|
||||
category: 'setting',
|
||||
keywords: ['appearance', '外观', '主题', 'theme', 'dark', 'light'],
|
||||
},
|
||||
// ==================== 管理员 ====================
|
||||
{
|
||||
id: 'admin-settings',
|
||||
title: '系统设置',
|
||||
description: '管理系统登录注册与认证源配置 (管理员专属)',
|
||||
url: '/admin/settings',
|
||||
category: 'admin',
|
||||
keywords: ['admin', '管理员', '系统设置', '安全', 'security', 'oidc', 'login'],
|
||||
},
|
||||
{
|
||||
id: 'admin-system',
|
||||
title: '系统配置',
|
||||
description: '动态修改平台核心运行时配置 (管理员专属)',
|
||||
url: '/admin/system',
|
||||
category: 'admin',
|
||||
keywords: ['admin', '管理员', '系统', '配置', 'system', 'configurations'],
|
||||
},
|
||||
{
|
||||
id: 'admin-users',
|
||||
title: '用户管理',
|
||||
description: '管理平台注册用户的活跃状态 (管理员专属)',
|
||||
url: '/admin/users',
|
||||
category: 'admin',
|
||||
keywords: ['admin', '管理员', '用户', '管理', 'users', 'status'],
|
||||
},
|
||||
{
|
||||
id: 'admin-tasks',
|
||||
title: '异步任务管理',
|
||||
description: '下发与排查后台异步定时任务 (管理员专属)',
|
||||
url: '/admin/tasks',
|
||||
category: 'admin',
|
||||
keywords: ['admin', '管理员', '任务', '异步', 'tasks', 'scheduler', 'worker'],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 搜索功能
|
||||
* @param query 搜索关键词
|
||||
* @param isAdmin 是否为管理员
|
||||
* @returns 匹配的搜索结果
|
||||
*/
|
||||
export function searchItems(query: string, isAdmin: boolean = false): SearchItem[] {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
// 非管理员不能搜索 admin 类别项
|
||||
const filteredData = isAdmin
|
||||
? searchData
|
||||
: searchData.filter(item => item.category !== 'admin')
|
||||
|
||||
if (!trimmedQuery) {
|
||||
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] }
|
||||
}
|
||||
|
||||
// 匹配描述
|
||||
if (typeof match === 'function' && match(item.description, 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)
|
||||
.sort((a, b) => {
|
||||
// 标题匹配优先
|
||||
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 0
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user