feat(admin): Phase 0 平台超管基础 — is_platform_admin + IsPlatformAdmin + create_platform_admin(admin/admin123) + AdminAuditLog + Admin 后台外壳与路由 gating

后端:User.is_platform_admin + migration;权限类 IsPlatformAdmin;管理命令建 admin/admin123(幂等);
AdminAuditLog 模型 + log_admin_action() helper;me/login 对无团队超管优雅返回 team=null;UserSerializer 暴露标志。
前端:routes/admin 后台外壳(分组侧栏 + 概览 + 占位)、/admin 路由解析与 gating(超管直落、非超管纠回)、
侧栏平台入口、admin-page.css(仅 token)、IconKitSvg 补图标。
测试:accounts 11/11 单测过;无头 e2e _admin-p0.mjs 全断言过 + 0 console error;tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 20:49:34 +08:00
co-authored by Claude Opus 4.8
parent 8fc3870fa3
commit 443023a1a9
21 changed files with 907 additions and 12 deletions
@@ -0,0 +1,207 @@
import { useEffect } from "react";
import { IconKitSvg } from "../../components/IconKitSvg";
import { CornerMarks, Decorations } from "../../components/app-shell";
import type { Team, User } from "../../types";
import type { NavigateFn } from "../route-config";
// 平台超管后台 · 分阶段上线。section slug 与 URL /admin/<slug> 对应("" = 概览)。
// 视觉一律复用 restraint 外壳类(.app/.sidebar/.topbar/.content/.nav-section),只加极少 admin 专属样式。
export type AdminSection = {
slug: string;
label: string;
icon: string;
group: string;
// 该模块计划落地的阶段;未到则页面显示「即将上线」占位(避免空手承诺功能)。
phase: number;
};
export const ADMIN_SECTIONS: AdminSection[] = [
{ slug: "", label: "概览", icon: "dashboard", group: "概览", phase: 0 },
{ slug: "invites", label: "邀请码", icon: "ticket", group: "团队 · 用户", phase: 2 },
{ slug: "teams", label: "团队", icon: "building", group: "团队 · 用户", phase: 3 },
{ slug: "users", label: "用户", icon: "users", group: "团队 · 用户", phase: 3 },
{ slug: "quality", label: "质量词", icon: "type", group: "内容", phase: 4 },
{ slug: "reviews", label: "资产审核", icon: "shield", group: "内容", phase: 5 },
{ slug: "tasks", label: "任务监控", icon: "activity", group: "生成", phase: 6 },
{ slug: "providers", label: "模型供应商", icon: "server", group: "生成", phase: 8 },
{ slug: "billing", label: "计费审计", icon: "creditCard", group: "财务", phase: 7 },
{ slug: "quota", label: "额度策略", icon: "gauge", group: "财务", phase: 7 },
{ slug: "governance", label: "治理", icon: "sliders", group: "系统", phase: 9 }
];
const SECTION_GROUPS = ["概览", "团队 · 用户", "内容", "生成", "财务", "系统"];
type AdminAppProps = {
section: string;
user: User;
team: Team | null;
navigateAdmin: (section: string, options?: { replace?: boolean }) => void;
navigate: NavigateFn;
logout: () => void;
};
export function AdminApp({ section, user, team, navigateAdmin, navigate, logout }: AdminAppProps) {
const active = ADMIN_SECTIONS.find((s) => s.slug === section) || ADMIN_SECTIONS[0];
// 进后台任一页都滚到顶,行为与主壳 navigate 一致
useEffect(() => {
window.scrollTo({ top: 0, behavior: "auto" });
}, [section]);
return (
<div className="app admin-app">
<aside className="sidebar admin-sidebar">
<div className="sidebar-head">
<a
className="brand"
href="/admin"
aria-label="平台后台"
onClick={(event) => {
event.preventDefault();
navigateAdmin("");
}}
>
<span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span>
</a>
</div>
<div className="admin-badge mono">[ PLATFORM ADMIN ]</div>
{SECTION_GROUPS.map((group) => {
const items = ADMIN_SECTIONS.filter((s) => s.group === group);
if (items.length === 0) return null;
return (
<div key={group} className="admin-nav-group">
<div className="nav-section">{group}</div>
<nav>
{items.map((item) => (
<a
key={item.slug || "overview"}
href={item.slug ? `/admin/${item.slug}` : "/admin"}
className={active.slug === item.slug ? "active" : ""}
title={item.label}
aria-label={item.label}
onClick={(event) => {
event.preventDefault();
navigateAdmin(item.slug);
}}
>
<IconKitSvg name={item.icon} />
<span>{item.label}</span>
</a>
))}
</nav>
</div>
);
})}
<div className="aside-foot admin-foot">
{team && (
<button
type="button"
className="btn btn-ghost btn-sm admin-back"
onClick={() => navigate("dashboard")}
>
<IconKitSvg name="chevronLeft" size={14} />
</button>
)}
<div className="admin-user">
<div className="av">{(user.username || "A").slice(0, 1).toUpperCase()}</div>
<div className="admin-user-meta">
<span className="nm">{user.username}</span>
<span className="rl mono">// 平台超管</span>
</div>
<button type="button" className="icon-btn admin-logout" title="退出登录" aria-label="退出登录" onClick={logout}>
<IconKitSvg name="logOut" size={16} />
</button>
</div>
</div>
</aside>
<main>
<Decorations />
<header className="topbar">
<div className="crumbs">
<a
href="/admin"
onClick={(event) => {
event.preventDefault();
navigateAdmin("");
}}
>
</a>
{active.slug && (
<>
<span className="sep">/</span>
<span className="here">{active.label}</span>
</>
)}
</div>
<div className="right">
<span className="pill pill-l2 pill-info"><span className="dot" /></span>
</div>
</header>
<div className="content" id="page-content">
<CornerMarks />
<AdminSectionView section={active} navigateAdmin={navigateAdmin} />
</div>
</main>
</div>
);
}
function AdminSectionView({ section, navigateAdmin }: { section: AdminSection; navigateAdmin: (s: string) => void }) {
if (section.slug === "") {
return <AdminOverview navigateAdmin={navigateAdmin} />;
}
// Phase 1+ 的模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />;
}
function AdminOverview({ navigateAdmin }: { navigateAdmin: (s: string) => void }) {
const shortcuts = ADMIN_SECTIONS.filter((s) => s.slug);
return (
<>
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 跨团队后台</span> · 邀请码、团队用户、内容审核、生成与计费治理
</div>
</div>
</div>
<div className="tip admin-tip">
<strong></strong>
线 ,线
</div>
<div className="admin-shortcut-grid">
{shortcuts.map((s) => (
<button key={s.slug} type="button" className="shortcut" onClick={() => navigateAdmin(s.slug)}>
<span className="ic"><IconKitSvg name={s.icon} size={16} /></span>
<span className="admin-shortcut-text">
<span className="t">{s.label}</span>
<span className="d">// {s.slug}</span>
</span>
</button>
))}
</div>
</>
);
}
function AdminPlaceholder({ section }: { section: AdminSection }) {
return (
<>
<div className="page-head">
<div>
<h1>{section.label}</h1>
<div className="sub">
<span className="mono">// admin · {section.slug}</span>
</div>
</div>
</div>
<div className="empty-state show">
<div className="ic-empty"><IconKitSvg name={section.icon} size={24} /></div>
<h3>线</h3>
<p>// 计划于 Phase {section.phase} 落地</p>
</div>
</>
);
}
+7
View File
@@ -40,6 +40,8 @@ export type ResolvedRoute = {
productId?: string;
projectId?: string;
hash?: string;
// 平台超管后台:/admin → ""(概览),/admin/<section> → section slug。undefined = 非 admin 路由。
admin?: string;
};
export type NavigateOptions = {
productId?: string;
@@ -113,6 +115,11 @@ export function resolveRoute(): ResolvedRoute {
if (path === "/register" || hash === "register") return { page: "dashboard", authMode: "register", hash };
if (path === "/login") return { page: "dashboard", authMode: "login", hash };
// 平台超管后台:独立外壳,page 仅占位(渲染由 route.admin 接管)
if (path === "/admin") return { page: "dashboard", authMode: "login", admin: "", hash };
if (path.startsWith("/admin/")) {
return { page: "dashboard", authMode: "login", admin: path.slice("/admin/".length), hash };
}
if (path === "/" && isPage(hash)) return { page: hash, authMode: "login", hash };
if (path === "/" || path === "/dashboard") return { page: "dashboard", authMode: "login", hash };
if (path === "/products") return { page: "products", authMode: "login", hash };