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
+42
View File
@@ -0,0 +1,42 @@
"""平台超管审计写入 helper。失败绝不阻断主流程(审计是旁路)。"""
def _client_ip(request):
if request is None:
return None
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.META.get("REMOTE_ADDR") or None
def log_admin_action(
request,
action,
*,
target_type="",
target_id="",
target_name="",
before=None,
after=None,
):
"""记录一条平台超管操作审计。action 用动词短语(如 'invite.issue' / 'team.disable')。
operator_name 取当前登录用户名快照。任何异常吞掉,不影响业务返回。"""
from .models import AdminAuditLog
try:
user = getattr(request, "user", None)
operator = user if (user is not None and getattr(user, "is_authenticated", False)) else None
AdminAuditLog.objects.create(
operator=operator,
operator_name=(getattr(operator, "username", "") or ""),
action=action,
target_type=target_type or "",
target_id=str(target_id or ""),
target_name=target_name or "",
before=before,
after=after,
ip_address=_client_ip(request),
)
except Exception: # noqa: BLE001 — 审计失败不应阻断主流程
pass
@@ -0,0 +1,61 @@
"""创建/确保平台超级管理员账号。
默认 admin / admin123(本期定调:用户第一个超管)。平台超管不属于任何团队 —— 登录后
前端直落 /admin 后台。重复执行幂等:已存在则只补 is_platform_admin 标志(可选 --reset-password 重置密码)。
python manage.py create_platform_admin
python manage.py create_platform_admin --username admin --password admin123 --reset-password
"""
from django.core.management.base import BaseCommand
from django.db import transaction
class Command(BaseCommand):
help = "创建或确保平台超级管理员(默认 admin/admin123)"
def add_arguments(self, parser):
parser.add_argument("--username", default="admin", help="超管用户名(默认 admin)")
parser.add_argument("--password", default="admin123", help="超管密码(默认 admin123)")
parser.add_argument(
"--reset-password",
action="store_true",
help="账号已存在时也强制重置为给定密码",
)
@transaction.atomic
def handle(self, *args, **options):
from apps.accounts.models import User
username = options["username"]
password = options["password"]
reset = options["reset_password"]
user, created = User.objects.get_or_create(
username=username,
defaults={"email": f"{username}@airshelf.local"},
)
changed = []
if not user.is_platform_admin:
user.is_platform_admin = True
changed.append("is_platform_admin")
# 也给 Django staff 标志,便于需要时进 Django admin;不影响业务权限(业务只看 is_platform_admin)
if not user.is_staff:
user.is_staff = True
changed.append("is_staff")
if user.status != User.Status.ACTIVE:
user.status = User.Status.ACTIVE
changed.append("status")
if created or reset:
user.set_password(password)
changed.append("password")
user.save()
if created:
self.stdout.write(self.style.SUCCESS(f"已创建平台超管 {username}(密码 {password})"))
elif changed:
self.stdout.write(
self.style.SUCCESS(f"平台超管 {username} 已更新:{', '.join(changed)}")
)
else:
self.stdout.write(self.style.WARNING(f"平台超管 {username} 已存在,无需变更"))
@@ -0,0 +1,42 @@
# Generated by Django 5.1.15 on 2026-06-19 12:35
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_invitation'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_platform_admin',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='AdminAuditLog',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('operator_name', models.CharField(blank=True, max_length=150)),
('action', models.CharField(max_length=48)),
('target_type', models.CharField(blank=True, max_length=32)),
('target_id', models.CharField(blank=True, max_length=64)),
('target_name', models.CharField(blank=True, max_length=200)),
('before', models.JSONField(blank=True, null=True)),
('after', models.JSONField(blank=True, null=True)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('operator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='admin_audit_logs', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['action'], name='accounts_ad_action_46dc9d_idx'), models.Index(fields=['-created_at'], name='accounts_ad_created_1ae9d4_idx')],
},
),
]
+31
View File
@@ -15,6 +15,9 @@ class User(AbstractUser):
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
phone = models.CharField(max_length=32, blank=True)
avatar_url = models.URLField(blank=True)
# 平台超级管理员:凌驾于团队 owner 之上的跨团队后台权限(发开团队码 / 管所有团队 / 治理)。
# 与团队解耦 —— 平台超管可不属于任何团队(get_current_team 对其返回 None,前端登录直落 /admin)。
is_platform_admin = models.BooleanField(default=False)
@property
def is_disabled(self) -> bool:
@@ -145,3 +148,31 @@ class Invitation(TimeStampedModel):
def __str__(self) -> str:
return f"{self.team} / {self.code} / {self.status}"
class AdminAuditLog(TimeStampedModel):
"""平台超管操作审计:谁(operator)在什么时间对哪个对象(target)做了什么(action),
可选 before/after 快照。operator_name 单独存档,即使账号被删审计仍可读。所有跨团队
后台写操作(发码 / 启停团队 / 改密 / 调额 / 改模型等)都应经 log_admin_action() 落一条。"""
operator = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True, related_name="admin_audit_logs"
)
operator_name = models.CharField(max_length=150, blank=True)
action = models.CharField(max_length=48)
target_type = models.CharField(max_length=32, blank=True)
target_id = models.CharField(max_length=64, blank=True)
target_name = models.CharField(max_length=200, blank=True)
before = models.JSONField(null=True, blank=True)
after = models.JSONField(null=True, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["action"]),
models.Index(fields=["-created_at"]),
]
def __str__(self) -> str:
return f"{self.operator_name} / {self.action} / {self.target_type}:{self.target_id}"
+21
View File
@@ -0,0 +1,21 @@
"""平台级权限类。团队级权限仍走 apps.common.api.can_manage_team(owner/admin)。
平台超管(User.is_platform_admin)= 跨团队后台权限,凌驾团队 owner 之上;所有 /api/admin/*
端点统一挂 IsPlatformAdmin,非超管一律 403。"""
from rest_framework.permissions import BasePermission
class IsPlatformAdmin(BasePermission):
"""仅平台超级管理员放行。被停用账号即便有标志也拒绝。"""
message = "需要平台超级管理员权限"
def has_permission(self, request, view) -> bool:
user = getattr(request, "user", None)
return bool(
user
and user.is_authenticated
and getattr(user, "is_platform_admin", False)
and not getattr(user, "is_disabled", False)
)
+5 -2
View File
@@ -8,8 +8,11 @@ from .models import Invitation, LoginSession, Team, TeamMember, User, UserPrefer
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username", "first_name", "last_name", "email", "phone", "avatar_url", "status"]
read_only_fields = ["id", "status"]
fields = [
"id", "username", "first_name", "last_name", "email", "phone",
"avatar_url", "status", "is_platform_admin",
]
read_only_fields = ["id", "status", "is_platform_admin"]
class UserPreferenceSerializer(serializers.ModelSerializer):
+83
View File
@@ -116,3 +116,86 @@ class InvitationFlowTests(TestCase):
gen2 = joiner_client.post("/api/auth/team/invitations/", {"role": "member"}, format="json")
self.assertEqual(gen2.status_code, 403)
class PlatformAdminTests(TestCase):
"""Phase 0:平台超管基础 —— 管理命令 / 权限类 / 登录 me 返回标志且团队为空。"""
def test_create_platform_admin_command(self):
from django.core.management import call_command
call_command("create_platform_admin")
admin = User.objects.get(username="admin")
self.assertTrue(admin.is_platform_admin)
self.assertTrue(admin.is_staff)
self.assertEqual(admin.status, User.Status.ACTIVE)
self.assertTrue(admin.check_password("admin123"))
def test_create_platform_admin_idempotent_no_password_reset(self):
from django.core.management import call_command
call_command("create_platform_admin", "--username", "root", "--password", "rootpass1")
# 二次执行不带 --reset-password:不应覆盖已设密码
call_command("create_platform_admin", "--username", "root", "--password", "different2")
admin = User.objects.get(username="root")
self.assertTrue(admin.is_platform_admin)
self.assertTrue(admin.check_password("rootpass1"))
# 带 --reset-password 才重置
call_command("create_platform_admin", "--username", "root", "--password", "fresh3pass", "--reset-password")
admin.refresh_from_db()
self.assertTrue(admin.check_password("fresh3pass"))
def test_login_returns_flag_and_null_team_for_teamless_admin(self):
from django.core.management import call_command
call_command("create_platform_admin")
client = APIClient()
r = client.post("/api/auth/login/", {"username": "admin", "password": "admin123"}, format="json")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.data["user"]["is_platform_admin"])
self.assertIsNone(r.data["team"])
def test_me_returns_platform_admin_flag(self):
from django.core.management import call_command
call_command("create_platform_admin")
client = APIClient()
login = client.post("/api/auth/login/", {"username": "admin", "password": "admin123"}, format="json")
client.credentials(HTTP_AUTHORIZATION=f"Token {login.data['token']}")
me = client.get("/api/auth/me/")
self.assertEqual(me.status_code, 200)
self.assertTrue(me.data["user"]["is_platform_admin"])
self.assertIsNone(me.data["team"])
def test_is_platform_admin_permission(self):
from rest_framework.test import APIRequestFactory
from apps.accounts.permissions import IsPlatformAdmin
admin = User.objects.create_user(username="padmin", password="strong-pass-1", is_platform_admin=True)
normal = User.objects.create_user(username="normal-user", password="strong-pass-1")
perm = IsPlatformAdmin()
factory = APIRequestFactory()
request = factory.get("/api/admin/ping/")
request.user = admin
self.assertTrue(perm.has_permission(request, None))
request.user = normal
self.assertFalse(perm.has_permission(request, None))
# 被停用的超管也拒绝
admin.status = User.Status.DISABLED
admin.save(update_fields=["status"])
request.user = admin
self.assertFalse(perm.has_permission(request, None))
def test_normal_register_user_is_not_platform_admin(self):
client = APIClient()
r = client.post(
"/api/auth/register/",
{"username": "regular-owner", "password": "strong-password", "team_name": "Reg Team"},
format="json",
)
self.assertEqual(r.status_code, 201)
self.assertFalse(r.data["user"]["is_platform_admin"])
+15 -4
View File
@@ -29,10 +29,21 @@ def auth_payload(user, team, token):
return {
"token": token.key,
"user": UserSerializer(user).data,
"team": TeamSerializer(team).data,
"team": TeamSerializer(team).data if team is not None else None,
}
def current_team_or_none(user):
"""平台超管可不属于任何团队;此时取团队返回 None(而非抛 PermissionDenied)。
普通用户无团队仍属异常,但后台账号体系下登录/资料接口不应因此 500。"""
from rest_framework.exceptions import PermissionDenied
try:
return get_current_team(user)
except PermissionDenied:
return None
def _client_ip(request):
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
if forwarded:
@@ -84,7 +95,7 @@ def login(request):
)
if user is None or user.is_disabled:
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
team = get_current_team(user)
team = current_team_or_none(user)
token, _ = Token.objects.get_or_create(user=user)
record_login_session(request, user)
return Response(auth_payload(user, team, token))
@@ -110,11 +121,11 @@ def me(request):
if email:
user.email = email
user.save(update_fields=["first_name", "phone", "email"])
team = get_current_team(user)
team = current_team_or_none(user)
return Response(
{
"user": UserSerializer(user).data,
"team": TeamSerializer(team).data,
"team": TeamSerializer(team).data if team is not None else None,
}
)
+52 -4
View File
@@ -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">
+86
View File
@@ -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;
}
+10 -1
View File
@@ -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> = {
+19 -1
View File
@@ -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"
+1
View File
@@ -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>
</>
);
}
+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 };
+1
View File
@@ -3,6 +3,7 @@ export type User = {
username: string;
email: string;
status: string;
is_platform_admin?: boolean;
};
export type Team = {
+120
View File
@@ -0,0 +1,120 @@
// Phase 0 e2e:平台超管后台基础壳 + 路由 gating。
// 沿用 _wave35-auth.mjs 模式:headless 启动 → token 注入 → 真点击走查 → locator 断言 + 截图 + 抓 console/pageerror。
// 跑法:cd core/qa/visual-parity && node _admin-p0.mjs (需 vite 5173 + backend 8010 在跑)
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://localhost:5173";
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(username, password) {
const res = await fetch(`${API}/api/auth/login/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error(`login ${username} -> ${res.status}`);
return res.json();
}
const r = { realLogin: {}, adminShell: {}, gating: {}, consoleErrors: [], pass: false };
const browser = await chromium.launch({ headless: true });
// 取 token(超管 / 普通)
const admin = await apiLogin("admin", "admin123");
const normal = await apiLogin("airshelf", "Restraint2026");
r.tokens = { adminFlag: admin.user.is_platform_admin, adminTeam: admin.team, normalFlag: normal.user.is_platform_admin };
// ── A. 真登录:admin/admin123 → 应跳 /admin ──
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("realLogin:" + m.text()); });
p.on("pageerror", (e) => r.consoleErrors.push("realLogin:PAGEERR:" + e.message));
await p.goto(BASE + "/login", { waitUntil: "load" });
await p.waitForTimeout(400);
await p.fill("#auth-username", "admin");
await p.fill("#auth-pwd", "admin123");
await p.click("button.btn-cta");
try {
await p.waitForFunction(() => location.pathname.startsWith("/admin"), { timeout: 12000 });
r.realLogin.landedOnAdmin = true;
} catch { r.realLogin.landedOnAdmin = false; }
await p.waitForTimeout(500);
r.realLogin.url = p.url();
r.realLogin.adminAppShown = (await p.locator(".admin-app").count()) === 1;
await p.screenshot({ path: path.join(OUT, "p0-real-login-admin.png") });
await ctx.close();
}
// ── B. token 注入超管 → /admin 后台壳走查 ──
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
const p = await ctx.newPage();
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("adminShell:" + m.text()); });
p.on("pageerror", (e) => r.consoleErrors.push("adminShell:PAGEERR:" + e.message));
await p.goto(BASE + "/admin", { waitUntil: "load" });
await p.waitForSelector(".admin-app", { timeout: 12000 });
await p.waitForTimeout(400);
r.adminShell.appShown = (await p.locator(".admin-app").count()) === 1;
r.adminShell.sidebarShown = (await p.locator(".admin-sidebar").count()) === 1;
r.adminShell.badge = (await p.locator(".admin-badge").innerText().catch(() => "")).trim();
r.adminShell.navGroups = await p.locator(".admin-nav-group").count();
r.adminShell.navLinks = await p.locator(".admin-sidebar nav a").count();
r.adminShell.overviewTitle = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
r.adminShell.shortcuts = await p.locator(".admin-shortcut-grid .shortcut").count();
await p.screenshot({ path: path.join(OUT, "p0-admin-overview.png"), fullPage: true });
// 点「邀请码」section → 应到 /admin/invites,占位「模块即将上线」
await p.locator('.admin-sidebar nav a[href="/admin/invites"]').click();
await p.waitForFunction(() => location.pathname === "/admin/invites", { timeout: 6000 }).catch(() => {});
await p.waitForTimeout(300);
r.adminShell.sectionUrl = new URL(p.url()).pathname;
r.adminShell.placeholderShown = (await p.locator(".empty-state.show").count()) >= 1;
r.adminShell.placeholderText = (await p.locator(".empty-state h3").innerText().catch(() => "")).trim();
await p.screenshot({ path: path.join(OUT, "p0-admin-section-placeholder.png") });
await ctx.close();
}
// ── C. token 注入普通用户 → /admin 应被纠回工作台 ──
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), normal.token);
const p = await ctx.newPage();
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("gating:" + m.text()); });
p.on("pageerror", (e) => r.consoleErrors.push("gating:PAGEERR:" + e.message));
await p.goto(BASE + "/admin", { waitUntil: "load" });
await p.waitForTimeout(1500);
r.gating.url = p.url();
r.gating.pathname = new URL(p.url()).pathname;
r.gating.redirectedAway = !new URL(p.url()).pathname.startsWith("/admin");
r.gating.adminAppAbsent = (await p.locator(".admin-app").count()) === 0;
await p.screenshot({ path: path.join(OUT, "p0-gating-normal-user.png") });
await ctx.close();
}
await browser.close();
// 判定
const checks = {
adminFlagTrue: r.tokens.adminFlag === true,
adminTeamNull: r.tokens.adminTeam === null,
normalFlagNotTrue: !r.tokens.normalFlag,
realLoginLandedAdmin: r.realLogin.landedOnAdmin === true && r.realLogin.adminAppShown === true,
shellShown: r.adminShell.appShown && r.adminShell.sidebarShown,
navComplete: r.adminShell.navGroups === 6 && r.adminShell.navLinks === 11,
overviewTitle: r.adminShell.overviewTitle === "平台概览",
placeholderWorks: r.adminShell.sectionUrl === "/admin/invites" && r.adminShell.placeholderShown,
gatingRedirect: r.gating.redirectedAway === true && r.gating.adminAppAbsent === true,
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, "p0-summary.json"), JSON.stringify(r, null, 2));
process.exit(r.pass ? 0 : 1);