first commit

This commit is contained in:
Awuqing
2026-03-17 13:29:09 +08:00
commit eadd3f8961
219 changed files with 22394 additions and 0 deletions

59
web/src/services/auth.ts Normal file
View 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
}

View 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()
}

View 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)
}

View 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
View 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
View 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)
}

View 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)
}

View 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)
}

View 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
}