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">
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/* 平台超管后台 · 仅补充 admin 专属的少量样式;外壳/导航/卡片全部复用 design-restraint.css 共享类。
|
||||
遵守铁律:只用 token,不写裸 hex,8px 圆角,单橙 accent。 */
|
||||
|
||||
/* 品牌下方的超管标记(mono 品牌签名) */
|
||||
.admin-badge {
|
||||
margin: 2px 0 14px;
|
||||
padding: 0 4px;
|
||||
font-size: 11px;
|
||||
color: var(--black-alpha-48);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.admin-nav-group {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* 侧栏底部:返回工作台 + 超管身份 + 退出 */
|
||||
.admin-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-back {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.admin-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.admin-user .av {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
background: var(--heat-12);
|
||||
color: var(--heat);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-user-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.admin-user-meta .nm {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-black);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.admin-user-meta .rl {
|
||||
font-size: 11px;
|
||||
color: var(--black-alpha-48);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.admin-logout {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 概览页快捷入口网格(复用 .shortcut 卡片) */
|
||||
.admin-tip {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.admin-shortcut-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.admin-shortcut-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 顶栏面包屑链接可点 */
|
||||
.admin-app .topbar .crumbs a {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -16,7 +16,16 @@ const iconPaths: Record<string, string> = {
|
||||
chevronRight: '<path d="m9 18 6-6-6-6"/>',
|
||||
productPlus: '<path d="M12 22V12"/><path d="M16 17h6"/><path d="M19 14v6"/><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.3 9 5.1"/>',
|
||||
arrowUp: '<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>',
|
||||
helpCircle: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4"/><path d="M12 17h.01"/>'
|
||||
helpCircle: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4"/><path d="M12 17h.01"/>',
|
||||
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 11v2"/><path d="M13 17v2"/>',
|
||||
building: '<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/>',
|
||||
type: '<path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/>',
|
||||
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/>',
|
||||
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||
server: '<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01"/><path d="M6 17h.01"/>',
|
||||
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
|
||||
sliders: '<line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="2" x2="6" y1="14" y2="14"/><line x1="10" x2="14" y1="8" y2="8"/><line x1="18" x2="22" y1="16" y2="16"/>',
|
||||
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>'
|
||||
};
|
||||
|
||||
const iconAliases: Record<string, string> = {
|
||||
|
||||
@@ -219,7 +219,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
||||
settingsNotify: "settings"
|
||||
};
|
||||
|
||||
export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal, logout }: {
|
||||
export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal, logout, onOpenAdmin }: {
|
||||
page: Page;
|
||||
navigate: Navigate;
|
||||
user: User;
|
||||
@@ -231,6 +231,8 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
||||
// 退出登录。父级(App.tsx)有完整 logout 闭包时传入;未传时用自带兜底
|
||||
// (best-effort 调登出接口 + 清 token + 跳 /login),保证侧栏账户菜单的"退出"今天就可用。
|
||||
logout?: () => void;
|
||||
// 平台超管入口:仅 user.is_platform_admin 时显示,点了进 /admin 后台
|
||||
onOpenAdmin?: () => void;
|
||||
}) {
|
||||
const activeNav = PAGE_TO_NAV[page];
|
||||
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
|
||||
@@ -330,6 +332,22 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
{user.is_platform_admin && onOpenAdmin && (
|
||||
<>
|
||||
<div className="nav-section">平台</div>
|
||||
<nav>
|
||||
<a
|
||||
href="/admin"
|
||||
title="平台后台"
|
||||
aria-label="平台后台"
|
||||
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); onOpenAdmin(); }}
|
||||
>
|
||||
<IconKitSvg name="shield" />
|
||||
<span>平台后台</span>
|
||||
</a>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
<div className="aside-foot">
|
||||
<div
|
||||
className="user"
|
||||
|
||||
@@ -14,5 +14,6 @@ import "./settings-page.css";
|
||||
import "./ai-tools-page.css";
|
||||
import "./product-create-page.css";
|
||||
import "./project-wizard-page.css";
|
||||
import "./admin-page.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -3,6 +3,7 @@ export type User = {
|
||||
username: string;
|
||||
email: string;
|
||||
status: string;
|
||||
is_platform_admin?: boolean;
|
||||
};
|
||||
|
||||
export type Team = {
|
||||
|
||||
Reference in New Issue
Block a user