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:
co-authored by
Claude Opus 4.8
parent
4ce7e957d2
commit
cdfcffcecd
@@ -6,6 +6,7 @@ from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditLedger, QuotaPolicy
|
||||
from apps.projects.models import Project
|
||||
|
||||
# 成本异常阈值:实际成本 > 预估 × 此倍数(且预估 > 0)即标异常
|
||||
COST_ANOMALY_RATIO = Decimal("1.5")
|
||||
@@ -165,3 +166,13 @@ class AdminModelConfigSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
# is_default 只经 set-default 端点改,不在普通编辑里直接写
|
||||
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
|
||||
|
||||
@@ -531,3 +531,47 @@ class AdminModelProviderTests(TestCase):
|
||||
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(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)
|
||||
|
||||
@@ -10,6 +10,8 @@ from .views import (
|
||||
admin_model_detail,
|
||||
admin_model_set_default,
|
||||
admin_models,
|
||||
admin_integrity,
|
||||
admin_projects,
|
||||
admin_provider_detail,
|
||||
admin_providers,
|
||||
admin_quota_policies,
|
||||
@@ -55,4 +57,6 @@ urlpatterns = [
|
||||
path("models/", admin_models, name="admin-models"),
|
||||
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("projects/", admin_projects, name="admin-projects"),
|
||||
path("integrity/", admin_integrity, name="admin-integrity"),
|
||||
]
|
||||
|
||||
@@ -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.services.ledger import adjust_credit
|
||||
from apps.common.pagination import DefaultPagination
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import Project
|
||||
|
||||
from .serializers import (
|
||||
COST_ANOMALY_RATIO,
|
||||
AdminLedgerSerializer,
|
||||
AdminModelConfigSerializer,
|
||||
AdminModelProviderSerializer,
|
||||
AdminProjectSerializer,
|
||||
AdminQuotaPolicySerializer,
|
||||
AdminReviewAssetSerializer,
|
||||
AdminTaskDetailSerializer,
|
||||
@@ -585,3 +588,40 @@ def admin_model_set_default(request, model_id):
|
||||
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}")
|
||||
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()}})
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -202,3 +202,22 @@
|
||||
- 后端单测:`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`。
|
||||
- 类型/构建:`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` 全绿。
|
||||
|
||||
Reference in New Issue
Block a user