diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index 3b4afd8..9350f9d 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -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 diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 75ff8ee..c8be02b 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -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) diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index 97015bc..d97d8fc 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -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//", admin_model_detail, name="admin-model-detail"), path("models//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"), ] diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 662c529..08de406 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -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()}}) diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index d743ad8..5926998 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -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); } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 105f752..3f8340b 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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(`/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>(`/api/admin/projects/${q ? `?${q}` : ""}`); + }, + integrity() { + return request("/api/admin/integrity/"); } }; diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx index 23e8cf1..74532c3 100644 --- a/core/frontend/src/routes/admin/admin-app.tsx +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -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 ; } + if (section.slug === "governance") { + return ; + } // 其余模块在各自阶段替换此占位为真实页面 return ; } diff --git a/core/frontend/src/routes/admin/admin-governance.tsx b/core/frontend/src/routes/admin/admin-governance.tsx new file mode 100644 index 0000000..bbc1e94 --- /dev/null +++ b/core/frontend/src/routes/admin/admin-governance.tsx @@ -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 完成; + if (status === "failed") return 失败; + if (status === "draft") return 草稿; + return {status}; +} + +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([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState(""); + const [integrity, setIntegrity] = useState(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 ( + <> +
+
+

治理

+
// 项目监控 + 数据完整性 · 跨团队项目流水线 · 孤儿记录体检
+
+
+ +
+
+ + {integrity && ( +
+ {integrity.checks.map((c) => ( +
0 ? " warn" : ""}`}> + {c.count} + {c.label} +
+ ))} +
+ )} + +
项目流水线监控
+
+
+ {STATUS_TABS.map((t) => ( + + ))} +
+ {count} 个项目 +
+ + {loading ? ( +

加载中…

// fetching projects

+ ) : projects.length === 0 ? ( +

暂无项目

// no projects

+ ) : ( +
+ + + + {projects.map((p) => ( + + + + + + + + + ))} + +
项目团队商品状态当前阶段创建时间
{p.name}{p.team_name || }{p.product_title || }{projPill(p.status)}{p.current_stage}{fmtDate(p.created_at)}
+
+ )} + + ); +} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index bbd4865..215eac3 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -164,6 +164,20 @@ export type AdminModel = { metadata: Record; 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; +}; export type Paginated = { count: number; diff --git a/core/qa/visual-parity/_admin-p9.mjs b/core/qa/visual-parity/_admin-p9.mjs new file mode 100644 index 0000000..2f0599f --- /dev/null +++ b/core/qa/visual-parity/_admin-p9.mjs @@ -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); diff --git a/docs/todo/admin-后台-进度.md b/docs/todo/admin-后台-进度.md index 1840427..79c6c3f 100644 --- a/docs/todo/admin-后台-进度.md +++ b/docs/todo/admin-后台-进度.md @@ -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` 全绿。