Files
model-platform/frontend/app/features/operations/OperationsDataContext.tsx
T
郑龙捷 74dd97428f feat: 接入运维真实数据与三角色权限链路
- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

- 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。

- 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。

- 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
2026-09-02 15:02:47 +08:00

167 lines
6.2 KiB
TypeScript

import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { RefreshCw, TriangleAlert } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton";
import { useAuth } from "~/context/AuthContext";
import {
getMonthlyMonitoringResult,
getOperationsModel,
getOperationsWorkbench,
listOperationsModels,
OperationsApiError,
type OperationsWorkbenchDto,
operationsApiMode,
type OperationsApiMode,
} from "~/services/operationsApi";
import type { ModelRecord, MonitoringRow } from "./modelData";
type OperationsDataContextValue = {
models: ModelRecord[];
workbench: OperationsWorkbenchDto;
loading: boolean;
error: string | null;
source: OperationsApiMode;
reload: () => Promise<void>;
};
const OperationsDataContext = createContext<OperationsDataContextValue | null>(null);
const EMPTY_WORKBENCH: OperationsWorkbenchDto = {
role: "model_team",
alerts: [],
kpis: [],
todos: [],
watches: [],
activities: [],
};
export function OperationsDataProvider({ children }: { children: ReactNode }) {
const { currentWorkspace } = useAuth();
const workspaceId = currentWorkspace?.workspace_id;
const [models, setModels] = useState<ModelRecord[]>([]);
const [workbench, setWorkbench] = useState<OperationsWorkbenchDto>(EMPTY_WORKBENCH);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError(null);
try {
const [modelsResult, workbenchResult] = await Promise.allSettled([
listOperationsModels({ workspaceId, signal }),
getOperationsWorkbench(workspaceId, signal),
]);
if (modelsResult.status === "rejected") throw modelsResult.reason;
setModels(modelsResult.value);
setWorkbench(workbenchResult.status === "fulfilled" ? workbenchResult.value : EMPTY_WORKBENCH);
} catch (cause) {
if (cause instanceof DOMException && cause.name === "AbortError") return;
setModels([]);
setError(cause instanceof Error ? cause.message : "模型数据加载失败");
} finally {
if (!signal?.aborted) setLoading(false);
}
}, [workspaceId]);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
const value = useMemo<OperationsDataContextValue>(() => ({
models,
workbench,
loading,
error,
source: operationsApiMode,
reload: () => load(),
}), [error, load, loading, models, workbench]);
return <OperationsDataContext.Provider value={value}>{children}</OperationsDataContext.Provider>;
}
export function useOperationsData(): OperationsDataContextValue {
const context = useContext(OperationsDataContext);
if (!context) throw new Error("useOperationsData 必须在 OperationsDataProvider 内使用");
return context;
}
export function useOperationsModelDetail(modelId: string, month: string) {
const { currentWorkspace } = useAuth();
const workspaceId = currentWorkspace?.workspace_id;
const [model, setModel] = useState<ModelRecord | null>(null);
const [monitoringResult, setMonitoringResult] = useState<MonitoringRow | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
void Promise.all([
getOperationsModel(modelId, workspaceId, controller.signal),
getMonthlyMonitoringResult(modelId, month, workspaceId, controller.signal).catch((cause) => {
if (cause instanceof OperationsApiError && cause.code === "MONITOR_RESULT_NOT_FOUND") {
return null;
}
throw cause;
}),
]).then(([modelValue, resultValue]) => {
setModel(modelValue);
setMonitoringResult(resultValue);
}).catch((cause) => {
if (cause instanceof DOMException && cause.name === "AbortError") return;
setError(cause instanceof Error ? cause.message : "模型监控详情加载失败");
}).finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [modelId, month, reloadKey, workspaceId]);
return {
model,
monitoringResult,
loading,
error,
reload: () => setReloadKey((value) => value + 1),
};
}
export function OperationsDataBoundary({ children }: { children: ReactNode }) {
const { loading, error, reload } = useOperationsData();
if (loading) {
return (
<section className="h-full overflow-auto bg-bg p-6" aria-busy="true" aria-label="正在加载运维数据">
<div className="w-full space-y-6">
<div className="space-y-3"><Skeleton className="h-3 w-48" /><Skeleton className="h-8 w-80" /><Skeleton className="h-4 w-[36rem]" /></div>
<Skeleton className="h-40 w-full rounded-4xl" />
<div className="grid grid-cols-4 gap-4">{Array.from({ length: 4 }, (_, index) => <Skeleton className="h-32 rounded-4xl" key={index} />)}</div>
<div className="grid grid-cols-2 gap-6"><Skeleton className="h-72 rounded-4xl" /><Skeleton className="h-72 rounded-4xl" /></div>
</div>
</section>
);
}
if (error) {
return (
<section className="grid h-full place-items-center bg-bg p-6">
<Card className="w-full max-w-xl">
<CardContent className="flex flex-col items-center py-12 text-center">
<span className="grid size-12 place-items-center rounded-2xl bg-danger-soft text-danger"><TriangleAlert className="size-6" /></span>
<h2 className="mt-4 text-xl font-bold text-foreground">运维数据加载失败</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">{error}</p>
<p className="mt-1 text-xs text-muted-foreground">当前数据源:真实接口</p>
<Button className="mt-5" onClick={() => void reload()}><RefreshCw />重新加载</Button>
</CardContent>
</Card>
</section>
);
}
return children;
}