feat: add A-card operations frontend and backend foundation

This commit is contained in:
郑龙捷
2026-09-02 09:54:03 +08:00
parent 9badd3597f
commit ad71259ba5
106 changed files with 12461 additions and 1796 deletions
+199
View File
@@ -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,
};
}