feat(ops-web): add operations console frontend
Vue 3 + Ant Design Vue SPA scaffold for the internal ops console. Ships the auth, account list, and audit log views that consume the new ops-api endpoints, plus the nginx/vite/typecheck plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import axios, { type AxiosError, type AxiosInstance } from "axios";
|
||||
|
||||
import { clearStoredSession, readStoredSession } from "@/lib/storage";
|
||||
|
||||
export class OpsApiError extends Error {
|
||||
status?: number;
|
||||
code: number;
|
||||
detail?: string;
|
||||
requestId?: string;
|
||||
|
||||
constructor(input: {
|
||||
message: string;
|
||||
code?: number;
|
||||
status?: number;
|
||||
detail?: string;
|
||||
requestId?: string;
|
||||
}) {
|
||||
super(input.message);
|
||||
this.name = "OpsApiError";
|
||||
this.code = input.code ?? 50000;
|
||||
this.status = input.status;
|
||||
this.detail = input.detail;
|
||||
this.requestId = input.requestId;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
detail?: string;
|
||||
data: T;
|
||||
request_id?: string;
|
||||
}
|
||||
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
|
||||
export function bindUnauthorizedHandler(handler: () => void): void {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
const client: AxiosInstance = axios.create({
|
||||
baseURL: "/api/ops",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const session = readStoredSession();
|
||||
if (session.accessToken) {
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers["Authorization"] = `Bearer ${session.accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<ApiEnvelope<unknown>>) => {
|
||||
const status = error.response?.status;
|
||||
const payload = error.response?.data;
|
||||
|
||||
if (status === 401) {
|
||||
clearStoredSession();
|
||||
onUnauthorized?.();
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
new OpsApiError({
|
||||
message: payload?.message ?? error.message ?? "request_failed",
|
||||
code: typeof payload?.code === "number" ? payload.code : undefined,
|
||||
status,
|
||||
detail: typeof payload?.detail === "string" ? payload.detail : undefined,
|
||||
requestId: typeof payload?.request_id === "string" ? payload.request_id : undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
async function unwrap<T>(promise: Promise<{ data: ApiEnvelope<T> }>): Promise<T> {
|
||||
const { data } = await promise;
|
||||
if (data.code !== 0) {
|
||||
throw new OpsApiError({
|
||||
message: data.message,
|
||||
code: data.code,
|
||||
detail: data.detail,
|
||||
requestId: data.request_id,
|
||||
});
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: <T>(url: string, params?: Record<string, unknown>) =>
|
||||
unwrap<T>(client.get(url, { params })),
|
||||
post: <T, B = unknown>(url: string, body?: B) => unwrap<T>(client.post(url, body)),
|
||||
patch: <T, B = unknown>(url: string, body?: B) => unwrap<T>(client.patch(url, body)),
|
||||
delete: <T>(url: string) => unwrap<T>(client.delete(url)),
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
const STORAGE_KEY = "geo.ops-web.session";
|
||||
|
||||
export interface OperatorView {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface OpsSession {
|
||||
accessToken: string | null;
|
||||
expiresAt: number | null;
|
||||
operator: OperatorView | null;
|
||||
}
|
||||
|
||||
function emptySession(): OpsSession {
|
||||
return { accessToken: null, expiresAt: null, operator: null };
|
||||
}
|
||||
|
||||
function hasStorage(): boolean {
|
||||
return typeof window !== "undefined" && "localStorage" in window;
|
||||
}
|
||||
|
||||
export function readStoredSession(): OpsSession {
|
||||
if (!hasStorage()) return emptySession();
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return emptySession();
|
||||
const parsed = JSON.parse(raw) as Partial<OpsSession>;
|
||||
return {
|
||||
accessToken: typeof parsed.accessToken === "string" ? parsed.accessToken : null,
|
||||
expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : null,
|
||||
operator:
|
||||
parsed.operator && typeof parsed.operator === "object"
|
||||
? (parsed.operator as OperatorView)
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return emptySession();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredSession(session: OpsSession): void {
|
||||
if (!hasStorage()) return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
} catch {
|
||||
// ignore quota / serialization errors
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredSession(): void {
|
||||
if (!hasStorage()) return;
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user