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:
2026-04-28 11:33:17 +08:00
parent f63e800f21
commit f5254f6a27
20 changed files with 1830 additions and 7 deletions
+97
View File
@@ -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)),
};
+61
View File
@@ -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
}
}