feat: 接入运维真实数据与三角色权限链路

- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

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

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

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

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
This commit is contained in:
郑龙捷
2026-09-02 15:02:47 +08:00
parent 6d044784ec
commit 74dd97428f
51 changed files with 2142 additions and 1062 deletions
@@ -1,5 +1,5 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Database, RefreshCw, TriangleAlert } from "lucide-react";
import { RefreshCw, TriangleAlert } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
@@ -8,7 +8,10 @@ import { useAuth } from "~/context/AuthContext";
import {
getMonthlyMonitoringResult,
getOperationsModel,
getOperationsWorkbench,
listOperationsModels,
OperationsApiError,
type OperationsWorkbenchDto,
operationsApiMode,
type OperationsApiMode,
} from "~/services/operationsApi";
@@ -16,6 +19,7 @@ import type { ModelRecord, MonitoringRow } from "./modelData";
type OperationsDataContextValue = {
models: ModelRecord[];
workbench: OperationsWorkbenchDto;
loading: boolean;
error: string | null;
source: OperationsApiMode;
@@ -24,10 +28,20 @@ type OperationsDataContextValue = {
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);
@@ -35,7 +49,13 @@ export function OperationsDataProvider({ children }: { children: ReactNode }) {
setLoading(true);
setError(null);
try {
setModels(await listOperationsModels({ workspaceId, signal }));
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([]);
@@ -53,11 +73,12 @@ export function OperationsDataProvider({ children }: { children: ReactNode }) {
const value = useMemo<OperationsDataContextValue>(() => ({
models,
workbench,
loading,
error,
source: operationsApiMode,
reload: () => load(),
}), [error, load, loading, models]);
}), [error, load, loading, models, workbench]);
return <OperationsDataContext.Provider value={value}>{children}</OperationsDataContext.Provider>;
}
@@ -83,7 +104,12 @@ export function useOperationsModelDetail(modelId: string, month: string) {
setError(null);
void Promise.all([
getOperationsModel(modelId, workspaceId, controller.signal),
getMonthlyMonitoringResult(modelId, month, 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);
@@ -106,11 +132,11 @@ export function useOperationsModelDetail(modelId: string, month: string) {
}
export function OperationsDataBoundary({ children }: { children: ReactNode }) {
const { loading, error, models, reload, source } = useOperationsData();
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="mx-auto max-w-screen-2xl space-y-6">
<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>
@@ -128,7 +154,7 @@ export function OperationsDataBoundary({ children }: { children: ReactNode }) {
<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">{source === "api" ? "真实接口" : "前端 Mock"}</p>
<p className="mt-1 text-xs text-muted-foreground"></p>
<Button className="mt-5" onClick={() => void reload()}><RefreshCw /></Button>
</CardContent>
</Card>
@@ -136,13 +162,5 @@ export function OperationsDataBoundary({ children }: { children: ReactNode }) {
);
}
if (!models.length) {
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-brand-soft text-primary"><Database className="size-6" /></span><h2 className="mt-4 text-xl font-bold text-foreground"></h2><p className="mt-2 text-sm text-muted-foreground"></p><Button className="mt-5" variant="outline" onClick={() => void reload()}><RefreshCw /></Button></CardContent></Card>
</section>
);
}
return children;
}