mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-07-20 04:03:07 +08:00
first commit
This commit is contained in:
59
web/src/services/auth.ts
Normal file
59
web/src/services/auth.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { http } from './http'
|
||||
|
||||
export interface SetupPayload {
|
||||
username: string
|
||||
password: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
token: string
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
export async function fetchSetupStatus() {
|
||||
const response = await http.get<{ code: string; message: string; data: { initialized: boolean } }>('/auth/setup/status')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function setup(payload: SetupPayload) {
|
||||
const response = await http.post<{ code: string; message: string; data: AuthResult }>('/auth/setup', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function login(payload: LoginPayload) {
|
||||
const response = await http.post<{ code: string; message: string; data: AuthResult }>('/auth/login', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function fetchProfile() {
|
||||
const response = await http.get<{ code: string; message: string; data: UserInfo }>('/auth/profile')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export interface ChangePasswordPayload {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
export async function changePassword(payload: ChangePasswordPayload) {
|
||||
const response = await http.put<{ code: string; message: string; data: { changed: boolean } }>('/auth/password', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const response = await http.post<{ code: string; message: string; data: { loggedOut: boolean } }>('/auth/logout')
|
||||
return response.data.data
|
||||
}
|
||||
155
web/src/services/backup-records.ts
Normal file
155
web/src/services/backup-records.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { http, getAccessToken, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { BackupLogEvent, BackupRecordDetail, BackupRecordListFilter, BackupRecordSummary } from '../types/backup-records'
|
||||
import { resolveErrorMessage } from '../utils/error'
|
||||
|
||||
interface RecordLogStreamHandlers {
|
||||
onEvent: (event: BackupLogEvent) => void
|
||||
onDone?: () => void
|
||||
onError?: (message: string) => void
|
||||
}
|
||||
|
||||
function buildRecordQuery(filter: BackupRecordListFilter) {
|
||||
const query: Record<string, string | number> = {}
|
||||
if (filter.taskId) {
|
||||
query.taskId = filter.taskId
|
||||
}
|
||||
if (filter.status) {
|
||||
query.status = filter.status
|
||||
}
|
||||
if (filter.dateFrom) {
|
||||
query.dateFrom = filter.dateFrom
|
||||
}
|
||||
if (filter.dateTo) {
|
||||
query.dateTo = filter.dateTo
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
function parseContentDisposition(value?: string) {
|
||||
if (!value) {
|
||||
return 'backup-artifact.bin'
|
||||
}
|
||||
const match = value.match(/filename="?([^";]+)"?/i)
|
||||
return match?.[1] ?? 'backup-artifact.bin'
|
||||
}
|
||||
|
||||
function parseLogEvent(chunk: string) {
|
||||
const payloadLine = chunk
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('data:'))
|
||||
|
||||
if (!payloadLine) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload = payloadLine.slice(5).trim()
|
||||
if (!payload) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JSON.parse(payload) as BackupLogEvent
|
||||
}
|
||||
|
||||
async function resolveStreamError(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: string }
|
||||
return payload.message ?? '连接日志流失败'
|
||||
} catch {
|
||||
return `连接日志流失败(HTTP ${response.status})`
|
||||
}
|
||||
}
|
||||
|
||||
export async function listBackupRecords(filter: BackupRecordListFilter = {}) {
|
||||
const response = await http.get<ApiEnvelope<BackupRecordSummary[]>>('/backup/records', { params: buildRecordQuery(filter) })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function getBackupRecord(id: number) {
|
||||
const response = await http.get<ApiEnvelope<BackupRecordDetail>>(`/backup/records/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function downloadBackupRecord(id: number) {
|
||||
const response = await http.get<Blob>(`/backup/records/${id}/download`, { responseType: 'blob' })
|
||||
return {
|
||||
blob: response.data,
|
||||
fileName: parseContentDisposition(response.headers['content-disposition']),
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreBackupRecord(id: number) {
|
||||
const response = await http.post<ApiEnvelope<{ restored: boolean }>>(`/backup/records/${id}/restore`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function deleteBackupRecord(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/backup/records/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export function streamBackupRecordLogs(recordId: number, handlers: RecordLogStreamHandlers) {
|
||||
const controller = new AbortController()
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const token = getAccessToken()
|
||||
const response = await fetch(`/api/backup/records/${recordId}/logs/stream`, {
|
||||
method: 'GET',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await resolveStreamError(response))
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('日志流不可用')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n')
|
||||
const chunk = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
|
||||
const event = parseLogEvent(chunk)
|
||||
if (!event) {
|
||||
continue
|
||||
}
|
||||
handlers.onEvent(event)
|
||||
if (event.completed) {
|
||||
handlers.onDone?.()
|
||||
controller.abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
const event = parseLogEvent(buffer)
|
||||
if (event) {
|
||||
handlers.onEvent(event)
|
||||
}
|
||||
}
|
||||
handlers.onDone?.()
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
handlers.onError?.(resolveErrorMessage(error, '日志流连接失败'))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
38
web/src/services/backup-tasks.ts
Normal file
38
web/src/services/backup-tasks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskSummary, BackupTaskTogglePayload } from '../types/backup-tasks'
|
||||
import type { BackupRecordDetail } from '../types/backup-records'
|
||||
|
||||
export async function listBackupTasks() {
|
||||
const response = await http.get<ApiEnvelope<BackupTaskSummary[]>>('/backup/tasks')
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function getBackupTask(id: number) {
|
||||
const response = await http.get<ApiEnvelope<BackupTaskDetail>>(`/backup/tasks/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function createBackupTask(payload: BackupTaskPayload) {
|
||||
const response = await http.post<ApiEnvelope<BackupTaskDetail>>('/backup/tasks', payload)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function updateBackupTask(id: number, payload: BackupTaskPayload) {
|
||||
const response = await http.put<ApiEnvelope<BackupTaskDetail>>(`/backup/tasks/${id}`, payload)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function deleteBackupTask(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/backup/tasks/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function toggleBackupTask(id: number, payload: BackupTaskTogglePayload) {
|
||||
const response = await http.put<ApiEnvelope<BackupTaskSummary>>(`/backup/tasks/${id}/toggle`, payload)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function runBackupTask(id: number) {
|
||||
const response = await http.post<ApiEnvelope<BackupRecordDetail>>(`/backup/tasks/${id}/run`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
12
web/src/services/dashboard.ts
Normal file
12
web/src/services/dashboard.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { BackupTimelinePoint, DashboardStats } from '../types/dashboard'
|
||||
|
||||
export async function fetchDashboardStats() {
|
||||
const response = await http.get<ApiEnvelope<DashboardStats>>('/dashboard/stats')
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function fetchDashboardTimeline(days = 30) {
|
||||
const response = await http.get<ApiEnvelope<BackupTimelinePoint[]>>('/dashboard/timeline', { params: { days } })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
48
web/src/services/http.ts
Normal file
48
web/src/services/http.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
code: string | number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
let accessToken = ''
|
||||
let unauthorizedHandler: (() => void) | null = null
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
export function setAccessToken(token: string) {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return accessToken
|
||||
}
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
export function unwrapApiEnvelope<T>(response: ApiEnvelope<T>) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && unauthorizedHandler) {
|
||||
unauthorizedHandler()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
27
web/src/services/nodes.ts
Normal file
27
web/src/services/nodes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { NodeSummary, DirEntry } from '../types/nodes'
|
||||
|
||||
export async function listNodes() {
|
||||
const response = await http.get<ApiEnvelope<NodeSummary[]>>('/nodes')
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function getNode(id: number) {
|
||||
const response = await http.get<ApiEnvelope<NodeSummary>>(`/nodes/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function createNode(name: string) {
|
||||
const response = await http.post<ApiEnvelope<{ token: string }>>('/nodes', { name })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function deleteNode(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<null>>(`/nodes/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function listNodeDirectory(nodeId: number, path: string) {
|
||||
const response = await http.get<ApiEnvelope<DirEntry[]>>(`/nodes/${nodeId}/fs/list`, { params: { path } })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
37
web/src/services/notifications.ts
Normal file
37
web/src/services/notifications.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { NotificationDetail, NotificationPayload, NotificationSummary } from '../types/notifications'
|
||||
|
||||
export async function listNotifications() {
|
||||
const response = await http.get<ApiEnvelope<NotificationSummary[]>>('/notifications')
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function getNotification(id: number) {
|
||||
const response = await http.get<ApiEnvelope<NotificationDetail>>(`/notifications/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function createNotification(payload: NotificationPayload) {
|
||||
const response = await http.post<ApiEnvelope<NotificationDetail>>('/notifications', payload)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function updateNotification(id: number, payload: NotificationPayload) {
|
||||
const response = await http.put<ApiEnvelope<NotificationDetail>>(`/notifications/${id}`, payload)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function deleteNotification(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/notifications/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function testNotification(payload: NotificationPayload) {
|
||||
const response = await http.post<ApiEnvelope<{ success: boolean }>>('/notifications/test', payload, { timeout: 30000 })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function testSavedNotification(id: number) {
|
||||
const response = await http.post<ApiEnvelope<{ success: boolean }>>(`/notifications/${id}/test`, undefined, { timeout: 30000 })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
80
web/src/services/storage-targets.ts
Normal file
80
web/src/services/storage-targets.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { http } from './http'
|
||||
import type {
|
||||
GoogleDriveAuthStartResult,
|
||||
GoogleDriveCallbackResult,
|
||||
StorageConnectionTestResult,
|
||||
StorageTargetDetail,
|
||||
StorageTargetPayload,
|
||||
StorageTargetSummary,
|
||||
} from '../types/storage-targets'
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: string | number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
function unwrap<T>(response: ApiEnvelope<T>) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function listStorageTargets() {
|
||||
const response = await http.get<ApiEnvelope<StorageTargetSummary[]>>('/storage-targets')
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function getStorageTarget(id: number) {
|
||||
const response = await http.get<ApiEnvelope<StorageTargetDetail>>(`/storage-targets/${id}`)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function createStorageTarget(payload: StorageTargetPayload) {
|
||||
const response = await http.post<ApiEnvelope<StorageTargetDetail>>('/storage-targets', payload)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function updateStorageTarget(id: number, payload: StorageTargetPayload) {
|
||||
const response = await http.put<ApiEnvelope<StorageTargetDetail>>(`/storage-targets/${id}`, payload)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function deleteStorageTarget(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/storage-targets/${id}`)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function testStorageTarget(payload: StorageTargetPayload) {
|
||||
const response = await http.post<ApiEnvelope<StorageConnectionTestResult>>('/storage-targets/test', payload, { timeout: 30000 })
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function testSavedStorageTarget(id: number) {
|
||||
const response = await http.post<ApiEnvelope<StorageConnectionTestResult>>(`/storage-targets/${id}/test`, undefined, { timeout: 30000 })
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function startGoogleDriveAuth(payload: StorageTargetPayload, targetId?: number) {
|
||||
const response = await http.post<ApiEnvelope<GoogleDriveAuthStartResult>>('/storage-targets/google-drive/auth-url', {
|
||||
...payload,
|
||||
targetId,
|
||||
})
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function completeGoogleDriveAuth(queryString: string) {
|
||||
const suffix = queryString.startsWith('?') ? queryString : `?${queryString}`
|
||||
const response = await http.get<ApiEnvelope<GoogleDriveCallbackResult>>(`/storage-targets/google-drive/callback${suffix}`)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export interface StorageTargetUsage {
|
||||
targetId: number
|
||||
targetName: string
|
||||
recordCount: number
|
||||
totalSize: number
|
||||
}
|
||||
|
||||
export async function getStorageTargetUsage(id: number) {
|
||||
const response = await http.get<ApiEnvelope<StorageTargetUsage>>(`/storage-targets/${id}/usage`)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
27
web/src/services/system.ts
Normal file
27
web/src/services/system.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { http } from './http'
|
||||
|
||||
export interface SystemInfo {
|
||||
version: string
|
||||
mode: string
|
||||
startedAt: string
|
||||
uptimeSeconds: number
|
||||
databasePath: string
|
||||
diskTotal: number
|
||||
diskFree: number
|
||||
diskUsed: number
|
||||
}
|
||||
|
||||
export async function fetchSystemInfo() {
|
||||
const response = await http.get<{ code: string; message: string; data: SystemInfo }>('/system/info')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function fetchSettings() {
|
||||
const response = await http.get<{ code: string; message: string; data: Record<string, string> }>('/settings')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function updateSettings(settings: Record<string, string>) {
|
||||
const response = await http.put<{ code: string; message: string; data: Record<string, string> }>('/settings', settings)
|
||||
return response.data.data
|
||||
}
|
||||
Reference in New Issue
Block a user