feat(admin): Phase 9 收尾治理 — 全局项目监控 + 数据完整性体检

后端:adminpanel projects(全局项目流水线监控+状态筛)+ integrity(数据体检:无账户团队/无图商品/过期邀请码/
无文件资产/失败项目/卡 reserved 计数 + totals);AdminProjectSerializer;IsPlatformAdmin。
前端:adminApi adminProjects/integrity;Admin 治理页(体检卡片超0高亮 + 项目监控表状态筛)。
测试:adminpanel 53 单测过(项目列/筛/体检报告);无头 e2e _admin-p9.mjs 4 断言过 + 0 console error(全只读);tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 22:51:49 +08:00
co-authored by Claude Opus 4.8
parent 4ce7e957d2
commit cdfcffcecd
11 changed files with 362 additions and 0 deletions
+22
View File
@@ -269,3 +269,25 @@
margin-top: 4px;
}
.admin-switch-row input { width: 14px; height: 14px; accent-color: var(--heat); }
/* ── 治理:数据体检卡 + 项目监控 ── */
.gov-integrity {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 4px;
}
.gov-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 18px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
}
.gov-card.warn { border-color: var(--heat-20); background: var(--heat-12); }
.gov-count { font-size: 28px; font-weight: 500; color: var(--accent-black); font-variant-numeric: tabular-nums; line-height: 1.1; }
.gov-card.warn .gov-count { color: var(--heat); }
.gov-label { font-size: 12px; color: var(--black-alpha-56); }
.gov-total { margin-left: auto; font-size: 12px; color: var(--black-alpha-48); }
+14
View File
@@ -1,6 +1,8 @@
import type {
AdminIntegrity,
AdminLedger,
AdminModel,
AdminProjectRow,
AdminProvider,
AdminQualityWord,
AdminQuotaPolicy,
@@ -681,5 +683,17 @@ export const adminApi = {
},
setDefaultModel(id: string) {
return request<AdminModel>(`/api/admin/models/${id}/set-default/`, { method: "POST" });
},
adminProjects(params?: { status?: string; search?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.search) qs.set("search", params.search);
if (params?.page) qs.set("page", String(params.page));
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<AdminProjectRow>>(`/api/admin/projects/${q ? `?${q}` : ""}`);
},
integrity() {
return request<AdminIntegrity>("/api/admin/integrity/");
}
};
@@ -4,6 +4,7 @@ import { CornerMarks, Decorations, ToastLike } from "../../components/app-shell"
import type { Team, User } from "../../types";
import type { NavigateFn } from "../route-config";
import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing";
import { AdminGovernancePage } from "./admin-governance";
import { AdminInvitesPage } from "./admin-invites";
import { AdminModelsPage } from "./admin-models";
import { AdminQualityPage } from "./admin-quality";
@@ -197,6 +198,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
if (section.slug === "providers") {
return <AdminModelsPage notify={notify} />;
}
if (section.slug === "governance") {
return <AdminGovernancePage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />;
}
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useState } from "react";
import { adminApi } from "../../api";
import { IconKitSvg } from "../../components/IconKitSvg";
import type { AdminIntegrity, AdminProjectRow } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
const STATUS_TABS = [
{ key: "", label: "全部" },
{ key: "scripting", label: "脚本" },
{ key: "asseting", label: "资产" },
{ key: "storyboarding", label: "故事板" },
{ key: "videoing", label: "视频" },
{ key: "completed", label: "完成" },
{ key: "failed", label: "失败" }
];
function projPill(status: string) {
if (status === "completed") return <span className="pill ok"><span className="dot" />完成</span>;
if (status === "failed") return <span className="pill err"><span className="dot" />失败</span>;
if (status === "draft") return <span className="pill neutral"><span className="dot" />草稿</span>;
return <span className="pill info"><span className="dot" />{status}</span>;
}
function fmtDate(iso: string) {
if (!iso) return "—";
const d = new Date(iso);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
export function AdminGovernancePage({ notify }: { notify: Notify }) {
const [projects, setProjects] = useState<AdminProjectRow[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState("");
const [integrity, setIntegrity] = useState<AdminIntegrity | null>(null);
const [checking, setChecking] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.adminProjects({ status: tab || undefined, page_size: 60 });
setProjects(res.results);
setCount(res.count);
} catch {
notify("error", "加载项目失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
useEffect(() => { void load(); }, [load]);
async function runCheck() {
if (checking) return;
setChecking(true);
try {
setIntegrity(await adminApi.integrity());
notify("success", "体检完成");
} catch {
notify("error", "体检失败");
} finally {
setChecking(false);
}
}
return (
<>
<div className="page-head">
<div>
<h1>治理</h1>
<div className="sub"><span className="mono">// 项目监控 + 数据完整性</span> · 跨团队项目流水线 · 孤儿记录体检</div>
</div>
<div className="actions">
<button className="btn" type="button" disabled={checking} onClick={() => void runCheck()}>
{checking ? "体检中…" : "运行数据体检"}
</button>
</div>
</div>
{integrity && (
<div className="gov-integrity">
{integrity.checks.map((c) => (
<div key={c.key} className={`gov-card${c.count > 0 ? " warn" : ""}`}>
<span className="gov-count">{c.count}</span>
<span className="gov-label">{c.label}</span>
</div>
))}
</div>
)}
<div className="admin-detail-subhead" style={{ marginTop: integrity ? 24 : 0 }}>项目流水线监控</div>
<div className="admin-toolbar">
<div className="tabs-sub">
{STATUS_TABS.map((t) => (
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => setTab(t.key)}>{t.label}</button>
))}
</div>
<span className="mono gov-total">{count} 个项目</span>
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3>加载中…</h3><p>// fetching projects</p></div>
) : projects.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3>暂无项目</h3><p>// no projects</p></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
<thead><tr><th>项目</th><th>团队</th><th>商品</th><th>状态</th><th>当前阶段</th><th>创建时间</th></tr></thead>
<tbody>
{projects.map((p) => (
<tr key={p.id}>
<td>{p.name}</td>
<td>{p.team_name || <span className="muted">—</span>}</td>
<td>{p.product_title || <span className="muted">—</span>}</td>
<td>{projPill(p.status)}</td>
<td className="mono">{p.current_stage}</td>
<td className="mono col-time">{fmtDate(p.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
+14
View File
@@ -164,6 +164,20 @@ export type AdminModel = {
metadata: Record<string, unknown>;
created_at: string;
};
export type AdminProjectRow = {
id: string;
name: string;
team: string;
team_name: string | null;
product_title: string | null;
status: string;
current_stage: string;
created_at: string;
};
export type AdminIntegrity = {
checks: { key: string; label: string; count: number }[];
totals: Record<string, number>;
};
export type Paginated<T> = {
count: number;