feat: add A-card operations frontend and backend foundation
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
MODELS,
|
||||
monitoringRows,
|
||||
type ModelCategoryId,
|
||||
type ModelRecord,
|
||||
type ModelStatus,
|
||||
type MonitoringRow,
|
||||
type RankingStatus,
|
||||
} from "~/features/operations/modelData";
|
||||
|
||||
export type OperationsApiMode = "mock" | "api";
|
||||
|
||||
export type OperationsModelListParams = {
|
||||
workspaceId?: string;
|
||||
bank?: string;
|
||||
category?: ModelCategoryId;
|
||||
status?: ModelStatus;
|
||||
keyword?: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type OperationsModelDto = {
|
||||
model_instance_id: string;
|
||||
bank_name: string;
|
||||
model_category: ModelCategoryId;
|
||||
model_name: string;
|
||||
model_id: string;
|
||||
model_version: string;
|
||||
model_status: ModelStatus;
|
||||
last_iteration_date: string;
|
||||
ranking_result: RankingStatus;
|
||||
ks: number;
|
||||
psi: number;
|
||||
ks_mom_drop: number;
|
||||
secondary_hits_6m: number;
|
||||
is_wuji_bank: boolean;
|
||||
last_processed_at: string | null;
|
||||
previous_advice: string;
|
||||
common_model_name: string | null;
|
||||
};
|
||||
|
||||
export type MonthlyMonitoringResultDto = {
|
||||
model_instance_id: string;
|
||||
monitor_month: string;
|
||||
ranking_result: RankingStatus;
|
||||
ks: number;
|
||||
psi: number;
|
||||
ks_mom_drop: number;
|
||||
secondary_hits_6m: number;
|
||||
};
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
data: T;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export class OperationsApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message);
|
||||
this.name = "OperationsApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const configuredMode = import.meta.env.VITE_OPERATIONS_API_MODE;
|
||||
export const operationsApiMode: OperationsApiMode = configuredMode === "api" ? "api" : "mock";
|
||||
const API_BASE = "/api/v1/operations";
|
||||
|
||||
function requireWorkspaceId(workspaceId?: string): string {
|
||||
if (workspaceId) return workspaceId;
|
||||
throw new OperationsApiError("请先选择项目空间", 400, "WORKSPACE_REQUIRED");
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({})) as ApiEnvelope<T> & {
|
||||
detail?: string | { code?: string; message?: string };
|
||||
};
|
||||
if (!response.ok) {
|
||||
const message = typeof payload.detail === "string"
|
||||
? payload.detail
|
||||
: payload.detail?.message ?? `请求失败(HTTP ${response.status})`;
|
||||
throw new OperationsApiError(message, response.status, typeof payload.detail === "object" ? payload.detail.code : undefined);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
function toModelRecord(dto: OperationsModelDto): ModelRecord {
|
||||
return {
|
||||
bank: dto.bank_name,
|
||||
category: dto.model_category,
|
||||
name: dto.model_name,
|
||||
modelId: dto.model_id,
|
||||
version: dto.model_version,
|
||||
status: dto.model_status,
|
||||
iteratedAt: dto.last_iteration_date,
|
||||
ranking: dto.ranking_result,
|
||||
ks: dto.ks,
|
||||
psi: dto.psi,
|
||||
ksDrop: dto.ks_mom_drop,
|
||||
secondaryHits: dto.secondary_hits_6m,
|
||||
wuji: dto.is_wuji_bank,
|
||||
processedAt: dto.last_processed_at,
|
||||
previousAdvice: dto.previous_advice,
|
||||
commonModel: dto.common_model_name,
|
||||
};
|
||||
}
|
||||
|
||||
function mockDelay(signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(resolve, 160);
|
||||
signal?.addEventListener("abort", () => {
|
||||
window.clearTimeout(timer);
|
||||
reject(new DOMException("Request aborted", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function listOperationsModels(params: OperationsModelListParams = {}): Promise<ModelRecord[]> {
|
||||
if (operationsApiMode === "mock") {
|
||||
await mockDelay(params.signal);
|
||||
const keyword = params.keyword?.trim().toLowerCase();
|
||||
return MODELS
|
||||
.filter((model) => !params.bank || model.bank === params.bank)
|
||||
.filter((model) => !params.category || model.category === params.category)
|
||||
.filter((model) => !params.status || model.status === params.status)
|
||||
.filter((model) => !keyword || `${model.bank} ${model.name} ${model.modelId} ${model.version}`.toLowerCase().includes(keyword))
|
||||
.map((model) => ({ ...model }));
|
||||
}
|
||||
|
||||
const query = new URLSearchParams();
|
||||
query.set("workspace_id", requireWorkspaceId(params.workspaceId));
|
||||
if (params.bank) query.set("bank", params.bank);
|
||||
if (params.category) query.set("category", params.category);
|
||||
if (params.status) query.set("status", params.status);
|
||||
if (params.keyword) query.set("keyword", params.keyword);
|
||||
const suffix = query.size ? `?${query.toString()}` : "";
|
||||
const data = await request<OperationsModelDto[]>(`/models${suffix}`, { signal: params.signal });
|
||||
return data.map(toModelRecord);
|
||||
}
|
||||
|
||||
export async function getOperationsModel(
|
||||
modelId: string,
|
||||
workspaceId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ModelRecord> {
|
||||
if (operationsApiMode === "mock") {
|
||||
await mockDelay(signal);
|
||||
const model = MODELS.find((item) => item.modelId === modelId);
|
||||
if (!model) throw new OperationsApiError("模型不存在", 404, "MODEL_NOT_FOUND");
|
||||
return { ...model };
|
||||
}
|
||||
const query = new URLSearchParams({ workspace_id: requireWorkspaceId(workspaceId) });
|
||||
const data = await request<OperationsModelDto>(`/models/${encodeURIComponent(modelId)}?${query.toString()}`, { signal });
|
||||
return toModelRecord(data);
|
||||
}
|
||||
|
||||
export async function getMonthlyMonitoringResult(
|
||||
modelId: string,
|
||||
month: string,
|
||||
workspaceId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<MonitoringRow> {
|
||||
if (operationsApiMode === "mock") {
|
||||
await mockDelay(signal);
|
||||
const result = monitoringRows(MODELS).find((item) => item.modelId === modelId && item.monitorMonth === month);
|
||||
if (!result) throw new OperationsApiError("该月份暂无监控结果", 404, "MONITOR_RESULT_NOT_FOUND");
|
||||
return { ...result };
|
||||
}
|
||||
const model = await getOperationsModel(modelId, workspaceId, signal);
|
||||
const query = new URLSearchParams({
|
||||
month,
|
||||
workspace_id: requireWorkspaceId(workspaceId),
|
||||
});
|
||||
const data = await request<MonthlyMonitoringResultDto>(
|
||||
`/models/${encodeURIComponent(modelId)}/monitor-results?${query.toString()}`,
|
||||
{ signal },
|
||||
);
|
||||
return {
|
||||
...model,
|
||||
monitorMonth: data.monitor_month,
|
||||
ranking: data.ranking_result,
|
||||
ks: data.ks,
|
||||
psi: data.psi,
|
||||
ksDrop: data.ks_mom_drop,
|
||||
secondaryHits: data.secondary_hits_6m,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user