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:
co-authored by
Claude Opus 4.8
parent
8fc3870fa3
commit
443023a1a9
@@ -39,6 +39,7 @@ import {
|
||||
} from "./routes";
|
||||
import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config";
|
||||
import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
|
||||
import { AdminApp } from "./routes/admin/admin-app";
|
||||
import { money } from "./routes/stage-config";
|
||||
|
||||
const crumbLabels: Partial<Record<Page, string>> = {
|
||||
@@ -212,9 +213,10 @@ export function App() {
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
// 提到 try 外,便于身份就绪后按 identity.team 决定是否拉团队级数据
|
||||
let identity: Awaited<ReturnType<typeof api.me>> | null = null;
|
||||
try {
|
||||
// 瞬时故障(后端重启/网络抖动)要重试,不能把人踢回登录页;只有 401/403 才清 token
|
||||
let identity: Awaited<ReturnType<typeof api.me>> | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
identity = await api.me();
|
||||
@@ -238,8 +240,11 @@ export function App() {
|
||||
}
|
||||
// ★ 身份就绪即渲染外壳,不等全局数据 —— 商品/项目/余额/未读 后台并行填充,页面骨架先出来。
|
||||
if (!cancelled) setBooting(false);
|
||||
// 全局数据后台加载;失败只记录(token 已验证有效,不踢登录、不阻塞渲染)
|
||||
loadDataWithRetry().catch((dataError) => console.error("[boot] data load failed:", dataError));
|
||||
// 全局数据后台加载;失败只记录(token 已验证有效,不踢登录、不阻塞渲染)。
|
||||
// 无团队的平台超管跳过(团队级接口会报错),其只用 /admin 后台。
|
||||
if (identity?.team) {
|
||||
loadDataWithRetry().catch((dataError) => console.error("[boot] data load failed:", dataError));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -259,6 +264,20 @@ export function App() {
|
||||
return () => window.removeEventListener("popstate", syncRouteFromHistory);
|
||||
}, []);
|
||||
|
||||
// 平台后台 gating(身份就绪后):
|
||||
// - 平台超管且无团队:任何非 admin 路由都送进 /admin(否则普通外壳因 !team 永久卡 loading)
|
||||
// - 非超管访问 /admin/*:纠回工作台
|
||||
useEffect(() => {
|
||||
if (booting || !user) return;
|
||||
if (user.is_platform_admin && !team && route.admin === undefined) {
|
||||
navigateAdmin("", { replace: true });
|
||||
} else if (!user.is_platform_admin && route.admin !== undefined) {
|
||||
navigate("dashboard", { replace: true });
|
||||
}
|
||||
// navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [booting, user, team, route.admin]);
|
||||
|
||||
// Load preferences + sessions when entering settings.
|
||||
useEffect(() => {
|
||||
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
|
||||
@@ -358,6 +377,16 @@ export function App() {
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
// 平台超管后台导航:section="" → /admin(概览),否则 /admin/<section>。
|
||||
function navigateAdmin(section: string, options: { replace?: boolean } = {}) {
|
||||
const path = section ? `/admin/${section}` : "/admin";
|
||||
setRoute({ page: "dashboard", authMode, admin: section });
|
||||
if (`${window.location.pathname}` !== path || window.location.search) {
|
||||
window.history[options.replace ? "replaceState" : "pushState"](null, "", path);
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
async function refreshProjectDetail() {
|
||||
// 取调用时的 id 去拉,但回写前再用 ref 校验「现在」激活的还是不是它 —— 否则新建/切项目后,
|
||||
// 这个晚到的旧项目详情会把刚渲染的新项目详情冲掉,导致 projectDetail.id ≠ activeProjectId、
|
||||
@@ -535,6 +564,11 @@ export function App() {
|
||||
setTeam(payload.team);
|
||||
setBooting(false);
|
||||
setAuthed(true);
|
||||
// 平台超管且无团队:直落后台,不拉团队级数据(否则 products/projects 等接口因无团队报错)
|
||||
if (payload.user.is_platform_admin && !payload.team) {
|
||||
navigateAdmin("", { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate("dashboard", { replace: true });
|
||||
// 首登水合:与 boot 路径一致地重试,失败不再静默(否则页面卡在全 0,要刷新才好)
|
||||
loadDataWithRetry().catch((error) => {
|
||||
@@ -567,6 +601,20 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// 平台超管后台:独立外壳,不依赖团队(超管可无团队),须在 !team 守卫之前分流。
|
||||
if (!booting && user && route.admin !== undefined && user.is_platform_admin) {
|
||||
return (
|
||||
<AdminApp
|
||||
section={route.admin}
|
||||
user={user}
|
||||
team={team}
|
||||
navigateAdmin={navigateAdmin}
|
||||
navigate={navigate}
|
||||
logout={logout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (booting || !user || !team) {
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -899,7 +947,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} />
|
||||
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} logout={logout} onOpenAdmin={() => navigateAdmin("")} />
|
||||
<main>
|
||||
<Decorations />
|
||||
<header className="topbar">
|
||||
|
||||
Reference in New Issue
Block a user