0d056a45e9
Vue 3 + Vuetify 4 + TypeScript + Vue Router 4 + Pinia frontend, replacing the old Tailwind+Alpine.js static pages. Infrastructure (8 new files): - composables/useSnackbar.ts — centralized notifications - composables/useServerList.ts — server dropdown data - composables/useServerPagination.ts — paginated server table - types/api.ts — 14 typed API response interfaces - types/global.d.ts — Window augmentation - utils/status.ts — server status display helpers - utils/validation.ts — 8 form validation rules - components/MonacoEditor.vue — fullscreen code editor 14 pages rebuilt: - LoginPage, DashboardPage, ServersPage, TerminalPage - FilesPage, PushPage, ScriptsPage, CredentialsPage - SchedulesPage, RetriesPage, CommandsPage - AlertsPage, AuditPage, SettingsPage Critical fixes (from audit): - Files upload JWT auth (was missing Authorization header) - API Key reveal password verification dialog - 6 silent catch blocks → snackbar error feedback - 33 as any → 0 (typed interfaces + global.d.ts) - Dashboard: alerts/summary/categories/audit data loading - WebSocket reconnect timer cleanup + auth failure handling - Terminal ResizeObserver leak fix - PushPage timer cleanup on unmount - FilesPage path double-slash fix (joinPath helper) - Filter changes reset pagination to page 1 Features added: - Servers: batch operations, CSV export/import, detail panel - Push: sync modes, ZIP upload, WebSocket real-time progress, cancel - Scripts: quick execute panel, execution status tracking - Files: Monaco editor, context menu, batch delete, rename, chmod - Terminal: right-click menu, server sidebar, tab persistence - Settings: batch save, TOTP manual key, password TOTP verification - Dashboard: category distribution, recent audit, WS refresh Quality: - vue-tsc --noEmit zero errors - vite build passes (1613 modules) - Formal 8-step security audit passed (0 FINDING) - .gitignore: dist/ → **/dist/ to cover web/app/dist/ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
3.2 KiB
TypeScript
118 lines
3.2 KiB
TypeScript
/**
|
|
* api/index.ts
|
|
*
|
|
* Axios-free HTTP client for Nexus backend.
|
|
* - JWT Bearer token auto-attach
|
|
* - 401 → auto-logout via auth store
|
|
* - 202 → TOTP required (not an error)
|
|
* - Base URL from VITE_API_BASE or relative /api
|
|
*/
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
|
|
|
|
/** Raw fetch wrapper with JSON + JWT */
|
|
export async function api<T = any>(
|
|
path: string,
|
|
opts: RequestInit & { params?: Record<string, any> } = {},
|
|
): Promise<T> {
|
|
const { params, ...init } = opts
|
|
|
|
// Build URL with query params
|
|
let url = `${BASE_URL}${path}`
|
|
if (params) {
|
|
const sp = new URLSearchParams()
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v !== undefined && v !== null) sp.set(k, String(v))
|
|
}
|
|
const qs = sp.toString()
|
|
if (qs) url += `?${qs}`
|
|
}
|
|
|
|
// Attach JWT
|
|
const auth = useAuthStore()
|
|
const headers: Record<string, string> = {
|
|
...(init.headers as Record<string, string> || {}),
|
|
}
|
|
// Only set Content-Type for non-FormData bodies (FormData needs browser-generated boundary)
|
|
if (!(init.body instanceof FormData)) {
|
|
headers['Content-Type'] = 'application/json'
|
|
}
|
|
if (auth.token) {
|
|
headers['Authorization'] = `Bearer ${auth.token}`
|
|
}
|
|
|
|
const res = await fetch(url, { ...init, headers })
|
|
|
|
// 401 → force logout
|
|
if (res.status === 401) {
|
|
auth.forceLogout()
|
|
throw new ApiError(401, '登录已过期,请重新登录')
|
|
}
|
|
|
|
// 202 → TOTP required (not an error, return the response)
|
|
if (res.status === 202) {
|
|
const text = await res.text()
|
|
let data: any
|
|
try { data = JSON.parse(text) } catch { data = { message: text } }
|
|
throw new TotpRequiredError(data.detail || data.message || '请输入 TOTP 验证码')
|
|
}
|
|
|
|
// 429 → Account locked
|
|
if (res.status === 429) {
|
|
throw new ApiError(429, '登录尝试过多,账户已锁定 15 分钟')
|
|
}
|
|
|
|
// 204 No Content
|
|
if (res.status === 204) return undefined as T
|
|
|
|
// Try JSON
|
|
const text = await res.text()
|
|
let data: any
|
|
try {
|
|
data = JSON.parse(text)
|
|
} catch {
|
|
data = text
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const msg = data?.detail || data?.message || res.statusText
|
|
throw new ApiError(res.status, msg)
|
|
}
|
|
|
|
return data as T
|
|
}
|
|
|
|
/** Convenience helpers */
|
|
export const http = {
|
|
get: <T = any>(path: string, params?: Record<string, any>) =>
|
|
api<T>(path, { method: 'GET', params }),
|
|
|
|
post: <T = any>(path: string, body?: any) =>
|
|
api<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
|
|
|
put: <T = any>(path: string, body?: any) =>
|
|
api<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
|
|
|
delete: <T = any>(path: string) =>
|
|
api<T>(path, { method: 'DELETE' }),
|
|
|
|
/** Upload FormData (multipart, auto Content-Type with boundary) */
|
|
upload: <T = any>(path: string, formData: FormData) =>
|
|
api<T>(path, { method: 'POST', body: formData }),
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(public status: number, message: string) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
}
|
|
}
|
|
|
|
export class TotpRequiredError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'TotpRequiredError'
|
|
}
|
|
}
|