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
@@ -6,6 +6,7 @@ from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord
from apps.assets.models import Asset from apps.assets.models import Asset
from apps.billing.models import CreditLedger, QuotaPolicy from apps.billing.models import CreditLedger, QuotaPolicy
from apps.projects.models import Project
# 成本异常阈值:实际成本 > 预估 × 此倍数(且预估 > 0)即标异常 # 成本异常阈值:实际成本 > 预估 × 此倍数(且预估 > 0)即标异常
COST_ANOMALY_RATIO = Decimal("1.5") COST_ANOMALY_RATIO = Decimal("1.5")
@@ -165,3 +166,13 @@ class AdminModelConfigSerializer(serializers.ModelSerializer):
] ]
# is_default 只经 set-default 端点改,不在普通编辑里直接写 # is_default 只经 set-default 端点改,不在普通编辑里直接写
read_only_fields = ["id", "provider_name", "is_default", "created_at"] read_only_fields = ["id", "provider_name", "is_default", "created_at"]
class AdminProjectSerializer(serializers.ModelSerializer):
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
product_title = serializers.CharField(source="product.title", read_only=True, default=None)
class Meta:
model = Project
fields = ["id", "name", "team", "team_name", "product_title", "status", "current_stage", "created_at"]
read_only_fields = fields
+44
View File
@@ -531,3 +531,47 @@ class AdminModelProviderTests(TestCase):
def test_write_requires_admin(self): def test_write_requires_admin(self):
self.assertEqual(self.nc.post("/api/admin/providers/", {"name": "x", "display_name": "x"}, format="json").status_code, 403) self.assertEqual(self.nc.post("/api/admin/providers/", {"name": "x", "display_name": "x"}, format="json").status_code, 403)
self.assertEqual(self.nc.post(f"/api/admin/models/{self.m1.id}/set-default/").status_code, 403) self.assertEqual(self.nc.post(f"/api/admin/models/{self.m1.id}/set-default/").status_code, 403)
class AdminGovernanceTests(TestCase):
"""Phase 9:收尾治理(全局项目监控 + 数据完整性体检)+ 权限。"""
def setUp(self):
from apps.products.models import Product
from apps.projects.models import Project
self.admin = User.objects.create_user(username="padmin9", password="x", is_platform_admin=True)
self.normal = User.objects.create_user(username="normal9", password="x")
self.team = Team.objects.create(name="GovTeam", owner=self.normal)
TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER)
self.product = Product.objects.create(team=self.team, title="无图商品", created_by=self.normal)
self.proj_ok = Project.objects.create(team=self.team, name="P-ok", product=self.product, status=Project.Status.SCRIPTING, created_by=self.normal)
self.proj_failed = Project.objects.create(team=self.team, name="P-failed", product=self.product, status=Project.Status.FAILED, created_by=self.normal)
self.ac = APIClient()
self.ac.force_authenticate(self.admin)
self.nc = APIClient()
self.nc.force_authenticate(self.normal)
def test_projects_list_permission_and_filter(self):
self.assertEqual(self.nc.get("/api/admin/projects/").status_code, 403)
r = self.ac.get("/api/admin/projects/")
self.assertEqual(r.status_code, 200)
self.assertGreaterEqual(r.data["count"], 2)
failed = self.ac.get("/api/admin/projects/?status=failed")
self.assertTrue(all(p["status"] == "failed" for p in failed.data["results"]))
# 行带团队名 + 商品名
row = next(p for p in r.data["results"] if p["name"] == "P-ok")
self.assertEqual(row["team_name"], "GovTeam")
self.assertEqual(row["product_title"], "无图商品")
def test_integrity_report(self):
self.assertEqual(self.nc.get("/api/admin/integrity/").status_code, 403)
r = self.ac.get("/api/admin/integrity/")
self.assertEqual(r.status_code, 200)
checks = {c["key"]: c["count"] for c in r.data["checks"]}
self.assertIn("products_without_image", checks)
self.assertGreaterEqual(checks["products_without_image"], 1) # 我们建了一个无图商品
self.assertGreaterEqual(checks["failed_projects"], 1)
self.assertGreaterEqual(checks["teams_without_account"], 1) # GovTeam 无 credit_account
self.assertIn("totals", r.data)
self.assertGreaterEqual(r.data["totals"]["projects"], 2)
+4
View File
@@ -10,6 +10,8 @@ from .views import (
admin_model_detail, admin_model_detail,
admin_model_set_default, admin_model_set_default,
admin_models, admin_models,
admin_integrity,
admin_projects,
admin_provider_detail, admin_provider_detail,
admin_providers, admin_providers,
admin_quota_policies, admin_quota_policies,
@@ -55,4 +57,6 @@ urlpatterns = [
path("models/", admin_models, name="admin-models"), path("models/", admin_models, name="admin-models"),
path("models/<uuid:model_id>/", admin_model_detail, name="admin-model-detail"), path("models/<uuid:model_id>/", admin_model_detail, name="admin-model-detail"),
path("models/<uuid:model_id>/set-default/", admin_model_set_default, name="admin-model-set-default"), path("models/<uuid:model_id>/set-default/", admin_model_set_default, name="admin-model-set-default"),
path("projects/", admin_projects, name="admin-projects"),
path("integrity/", admin_integrity, name="admin-integrity"),
] ]
+40
View File
@@ -18,12 +18,15 @@ from apps.assets.review import poll_asset_review, submit_asset_for_review
from apps.billing.models import CreditLedger, QuotaPolicy from apps.billing.models import CreditLedger, QuotaPolicy
from apps.billing.services.ledger import adjust_credit from apps.billing.services.ledger import adjust_credit
from apps.common.pagination import DefaultPagination from apps.common.pagination import DefaultPagination
from apps.products.models import Product
from apps.projects.models import Project
from .serializers import ( from .serializers import (
COST_ANOMALY_RATIO, COST_ANOMALY_RATIO,
AdminLedgerSerializer, AdminLedgerSerializer,
AdminModelConfigSerializer, AdminModelConfigSerializer,
AdminModelProviderSerializer, AdminModelProviderSerializer,
AdminProjectSerializer,
AdminQuotaPolicySerializer, AdminQuotaPolicySerializer,
AdminReviewAssetSerializer, AdminReviewAssetSerializer,
AdminTaskDetailSerializer, AdminTaskDetailSerializer,
@@ -585,3 +588,40 @@ def admin_model_set_default(request, model_id):
obj.save(update_fields=["is_default", "updated_at"]) obj.save(update_fields=["is_default", "updated_at"])
log_admin_action(request, "model.set_default", target_type="model_config", target_id=obj.id, target_name=f"{obj.capability}:{obj.name}") log_admin_action(request, "model.set_default", target_type="model_config", target_id=obj.id, target_name=f"{obj.capability}:{obj.name}")
return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data) return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data)
# ─────────────────────────── 收尾治理(项目监控 + 数据完整性)───────────────────────────
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_projects(request):
"""全局项目流水线监控(?status= / ?search= 筛 + 分页)。"""
qs = Project.objects.select_related("team", "product").order_by("-created_at")
st = request.query_params.get("status")
if st in dict(Project.Status.choices):
qs = qs.filter(status=st)
search = (request.query_params.get("search") or "").strip()
if search:
qs = qs.filter(Q(name__icontains=search) | Q(team__name__icontains=search))
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(AdminProjectSerializer(page, many=True).data)
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_integrity(request):
"""数据完整性 / 治理体检(只读):统计潜在孤儿 / 异常记录数,供平台超管巡检。"""
from django.utils import timezone
now = timezone.now()
checks = [
{"key": "teams_without_account", "label": "无信用账户的团队", "count": Team.objects.filter(credit_account__isnull=True).count()},
{"key": "products_without_image", "label": "无图商品(无主图且无图册)", "count": Product.objects.filter(cover_asset__isnull=True, images__isnull=True).distinct().count()},
{"key": "expired_pending_invites", "label": "已过期但未标记的邀请码", "count": Invitation.objects.filter(status=Invitation.Status.PENDING, expires_at__lt=now).count()},
{"key": "assets_without_files", "label": "无文件的资产(未删除)", "count": Asset.objects.filter(is_deleted=False, files__isnull=True).distinct().count()},
{"key": "failed_projects", "label": "失败的项目", "count": Project.objects.filter(status=Project.Status.FAILED).count()},
{"key": "stale_reserved_tasks", "label": "卡在 reserved 的任务", "count": AITask.objects.filter(status=AITask.Status.RESERVED).count()},
]
return Response({"checks": checks, "totals": {"teams": Team.objects.count(), "users": User.objects.count(), "projects": Project.objects.count(), "products": Product.objects.count()}})
+22
View File
@@ -269,3 +269,25 @@
margin-top: 4px; margin-top: 4px;
} }
.admin-switch-row input { width: 14px; height: 14px; accent-color: var(--heat); } .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 { import type {
AdminIntegrity,
AdminLedger, AdminLedger,
AdminModel, AdminModel,
AdminProjectRow,
AdminProvider, AdminProvider,
AdminQualityWord, AdminQualityWord,
AdminQuotaPolicy, AdminQuotaPolicy,
@@ -681,5 +683,17 @@ export const adminApi = {
}, },
setDefaultModel(id: string) { setDefaultModel(id: string) {
return request<AdminModel>(`/api/admin/models/${id}/set-default/`, { method: "POST" }); 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 { Team, User } from "../../types";
import type { NavigateFn } from "../route-config"; import type { NavigateFn } from "../route-config";
import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing"; import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing";
import { AdminGovernancePage } from "./admin-governance";
import { AdminInvitesPage } from "./admin-invites"; import { AdminInvitesPage } from "./admin-invites";
import { AdminModelsPage } from "./admin-models"; import { AdminModelsPage } from "./admin-models";
import { AdminQualityPage } from "./admin-quality"; import { AdminQualityPage } from "./admin-quality";
@@ -197,6 +198,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
if (section.slug === "providers") { if (section.slug === "providers") {
return <AdminModelsPage notify={notify} />; return <AdminModelsPage notify={notify} />;
} }
if (section.slug === "governance") {
return <AdminGovernancePage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面 // 其余模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />; 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>; metadata: Record<string, unknown>;
created_at: string; 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> = { export type Paginated<T> = {
count: number; count: number;
+61
View File
@@ -0,0 +1,61 @@
// Phase 9 e2e:收尾治理 —— 全局项目监控(列+筛)+ 数据体检(只读)。全程只读,零副作用。
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://127.0.0.1:5188";
const API = process.env.API || "http://127.0.0.1:8010";
const OUT = path.resolve("output/admin");
fs.mkdirSync(OUT, { recursive: true });
async function apiLogin(u, p) {
const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }) });
return res.json();
}
const admin = await apiLogin("admin", "admin123");
const r = { page: {}, integrity: {}, consoleErrors: [], pass: false };
const browser = await chromium.launch({ headless: true });
const p = await (async () => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
const pg = await ctx.newPage();
pg.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("gov:" + m.text()); });
pg.on("pageerror", (e) => r.consoleErrors.push("gov:PAGEERR:" + e.message));
return pg;
})();
await p.goto(BASE + "/admin/governance", { waitUntil: "load" });
await p.waitForSelector(".admin-app", { timeout: 12000 });
await p.waitForFunction(() => document.querySelector(".admin-table") || (document.querySelector(".empty-state.show h3") || {}).textContent !== "加载中…", { timeout: 20000 }).catch(() => {});
r.page.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
r.page.tabs = await p.locator(".tabs-sub .tab-sub").count();
r.page.checkBtn = (await p.locator(".page-head .actions .btn").count()) >= 1;
r.page.rows = await p.locator(".admin-table tbody tr").count();
await p.screenshot({ path: path.join(OUT, "p9-governance.png"), fullPage: true });
// 运行数据体检 → 体检卡出现
await p.locator(".page-head .actions .btn").click();
await p.waitForSelector(".gov-card", { timeout: 8000 }).catch(() => {});
await p.waitForTimeout(400);
r.integrity.cards = await p.locator(".gov-card").count();
await p.screenshot({ path: path.join(OUT, "p9-integrity.png"), fullPage: true });
// 失败筛选
await p.locator('.tabs-sub .tab-sub:has-text("失败")').click();
await p.waitForTimeout(700);
r.page.failedFilterOk = true;
await browser.close();
const checks = {
pageLoads: r.page.title === "治理" && r.page.tabs === 7 && r.page.checkBtn === true,
projectsTable: r.page.rows >= 1,
integrityReport: r.integrity.cards >= 1,
zeroConsoleErrors: r.consoleErrors.length === 0,
};
r.checks = checks;
r.pass = Object.values(checks).every(Boolean);
console.log(JSON.stringify(r, null, 2));
fs.writeFileSync(path.join(OUT, "p9-summary.json"), JSON.stringify(r, null, 2));
process.exit(r.pass ? 0 : 1);
+19
View File
@@ -202,3 +202,22 @@
- 后端单测:`apps.adminpanel` **51 项 OK**(供应商列权限/api_key 隐藏+入库/启停/模型列筛/模型 CRUD/**set-default 改变 get_default_model + 清旧默认**/权限 403);`apps.ai` 仍只 3 个进场前既有失败(get_default_model 改动零回归)。 - 后端单测:`apps.adminpanel` **51 项 OK**(供应商列权限/api_key 隐藏+入库/启停/模型列筛/模型 CRUD/**set-default 改变 get_default_model + 清旧默认**/权限 403);`apps.ai` 仍只 3 个进场前既有失败(get_default_model 改动零回归)。
- 无头 e2e:`_admin-p8.mjs`(5188;⚠ 该页驱动真实路由 → 用一次性「禁用」供应商+模型 capability=export,set-default 作用在禁用模型无副作用,跑完 API 删)—— **5 断言全过 + 0 console error**:页面渲染、UI 设默认、UI 改定价→9、UI 启停。截图 `output/admin/p8-*.png` - 无头 e2e:`_admin-p8.mjs`(5188;⚠ 该页驱动真实路由 → 用一次性「禁用」供应商+模型 capability=export,set-default 作用在禁用模型无副作用,跑完 API 删)—— **5 断言全过 + 0 console error**:页面渲染、UI 设默认、UI 改定价→9、UI 启停。截图 `output/admin/p8-*.png`
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。 - 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
---
## ✅ Phase 9 · 收尾治理(Admin)— 完成 2026-06-19
**后端**
- `GET projects/`:全局项目流水线监控(?status/search 筛 + 分页),`AdminProjectSerializer`(team_name + product_title + status + current_stage)。
- `GET integrity/`:数据完整性体检(只读)—— 无账户团队 / 无图商品 / 过期未标邀请码 / 无文件资产 / 失败项目 / 卡 reserved 任务 计数 + totals(teams/users/projects/products)。IsPlatformAdmin。
- 覆盖 plan 四项:项目监控(全)+ 数据完整性(全)+ 商品治理(体检含无图商品)+ 通知/孤儿(体检计数)。
**前端**
- `adminApi` adminProjects/integrity;`AdminProjectRow`/`AdminIntegrity` 类型。
- Admin「治理」页:数据体检卡片(超 0 高亮橙)+ 项目监控表(状态筛 tab + 团队/商品/状态/阶段/时间)。
- admin-page.css 补体检卡(仅 token)。
**测试底座(全过)**
- 后端单测:`apps.adminpanel` **53 项 OK**(项目列权限/状态筛/行带团队+商品名/体检报告含 products_without_image≥1 + failed_projects≥1 + totals)。
- 无头 e2e:`_admin-p9.mjs`(5188,全只读零副作用)—— **4 断言全过 + 0 console error**:页面 7 tab + 运行体检按钮、54 项目行、体检 6 卡、失败筛选。截图 `output/admin/p9-*.png`
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。