diff --git a/core/backend/apps/accounts/audit.py b/core/backend/apps/accounts/audit.py new file mode 100644 index 0000000..3e45ab7 --- /dev/null +++ b/core/backend/apps/accounts/audit.py @@ -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 diff --git a/core/backend/apps/accounts/management/__init__.py b/core/backend/apps/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/backend/apps/accounts/management/commands/__init__.py b/core/backend/apps/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/backend/apps/accounts/management/commands/create_platform_admin.py b/core/backend/apps/accounts/management/commands/create_platform_admin.py new file mode 100644 index 0000000..db6ec38 --- /dev/null +++ b/core/backend/apps/accounts/management/commands/create_platform_admin.py @@ -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} 已存在,无需变更")) diff --git a/core/backend/apps/accounts/migrations/0004_user_is_platform_admin_adminauditlog.py b/core/backend/apps/accounts/migrations/0004_user_is_platform_admin_adminauditlog.py new file mode 100644 index 0000000..016fa0b --- /dev/null +++ b/core/backend/apps/accounts/migrations/0004_user_is_platform_admin_adminauditlog.py @@ -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')], + }, + ), + ] diff --git a/core/backend/apps/accounts/models.py b/core/backend/apps/accounts/models.py index 388a32a..f58f6b0 100644 --- a/core/backend/apps/accounts/models.py +++ b/core/backend/apps/accounts/models.py @@ -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}" diff --git a/core/backend/apps/accounts/permissions.py b/core/backend/apps/accounts/permissions.py new file mode 100644 index 0000000..dd4c55b --- /dev/null +++ b/core/backend/apps/accounts/permissions.py @@ -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) + ) diff --git a/core/backend/apps/accounts/serializers.py b/core/backend/apps/accounts/serializers.py index b391084..3bf0a92 100644 --- a/core/backend/apps/accounts/serializers.py +++ b/core/backend/apps/accounts/serializers.py @@ -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): diff --git a/core/backend/apps/accounts/tests.py b/core/backend/apps/accounts/tests.py index e84f12b..133d244 100644 --- a/core/backend/apps/accounts/tests.py +++ b/core/backend/apps/accounts/tests.py @@ -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"]) + diff --git a/core/backend/apps/accounts/views.py b/core/backend/apps/accounts/views.py index 512c49a..470b30f 100644 --- a/core/backend/apps/accounts/views.py +++ b/core/backend/apps/accounts/views.py @@ -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, } ) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index a3f935a..e5e35b9 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -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> = { @@ -212,9 +213,10 @@ export function App() { } let cancelled = false; (async () => { + // 提到 try 外,便于身份就绪后按 identity.team 决定是否拉团队级数据 + let identity: Awaited> | null = null; try { // 瞬时故障(后端重启/网络抖动)要重试,不能把人踢回登录页;只有 401/403 才清 token - let identity: Awaited> | 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/
。 + 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 ( + + ); + } + if (booting || !user || !team) { return (
@@ -899,7 +947,7 @@ export function App() { return (
- + navigateAdmin("")} />
diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css new file mode 100644 index 0000000..0a20a86 --- /dev/null +++ b/core/frontend/src/admin-page.css @@ -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; +} diff --git a/core/frontend/src/components/IconKitSvg.tsx b/core/frontend/src/components/IconKitSvg.tsx index 45b47ab..615686e 100644 --- a/core/frontend/src/components/IconKitSvg.tsx +++ b/core/frontend/src/components/IconKitSvg.tsx @@ -16,7 +16,16 @@ const iconPaths: Record = { chevronRight: '', productPlus: '', arrowUp: '', - helpCircle: '' + helpCircle: '', + ticket: '', + building: '', + type: '', + shield: '', + activity: '', + server: '', + gauge: '', + sliders: '', + logOut: '' }; const iconAliases: Record = { diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 4dc00b5..222b7a5 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -219,7 +219,7 @@ const PAGE_TO_NAV: Partial> = { 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 ))} + {user.is_platform_admin && onOpenAdmin && ( + <> +
平台
+ + + )}
); diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx new file mode 100644 index 0000000..ece6b6b --- /dev/null +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -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/ 对应("" = 概览)。 +// 视觉一律复用 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 ( +
+ +
+ +
+
+ { + event.preventDefault(); + navigateAdmin(""); + }} + > + 平台后台 + + {active.slug && ( + <> + / + {active.label} + + )} +
+
+ 超管模式 +
+
+
+ + +
+
+
+ ); +} + +function AdminSectionView({ section, navigateAdmin }: { section: AdminSection; navigateAdmin: (s: string) => void }) { + if (section.slug === "") { + return ; + } + // Phase 1+ 的模块在各自阶段替换此占位为真实页面 + return ; +} + +function AdminOverview({ navigateAdmin }: { navigateAdmin: (s: string) => void }) { + const shortcuts = ADMIN_SECTIONS.filter((s) => s.slug); + return ( + <> +
+
+

平台概览

+
+ // 跨团队后台 · 邀请码、团队用户、内容审核、生成与计费治理 +
+
+
+
+ 欢迎进入平台后台 + 各模块正在分阶段上线 —— 你可从左侧导航进入。已就绪的模块可直接操作,未就绪的会标注上线阶段。 +
+
+ {shortcuts.map((s) => ( + + ))} +
+ + ); +} + +function AdminPlaceholder({ section }: { section: AdminSection }) { + return ( + <> +
+
+

{section.label}

+
+ // admin · {section.slug} +
+
+
+
+
+

模块即将上线

+

// 计划于 Phase {section.phase} 落地

+
+ + ); +} diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index 84392fb..918c28e 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -40,6 +40,8 @@ export type ResolvedRoute = { productId?: string; projectId?: string; hash?: string; + // 平台超管后台:/admin → ""(概览),/admin/
→ 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 }; diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 4c20d45..9ee343f 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -3,6 +3,7 @@ export type User = { username: string; email: string; status: string; + is_platform_admin?: boolean; }; export type Team = { diff --git a/core/qa/visual-parity/_admin-p0.mjs b/core/qa/visual-parity/_admin-p0.mjs new file mode 100644 index 0000000..1510f83 --- /dev/null +++ b/core/qa/visual-parity/_admin-p0.mjs @@ -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); diff --git a/docs/todo/admin-后台-plan.md b/docs/todo/admin-后台-plan.md new file mode 100644 index 0000000..5c36a9e --- /dev/null +++ b/docs/todo/admin-后台-plan.md @@ -0,0 +1,64 @@ +# Plan · 平台 Admin 后台(全量)+ 注册邀请码逻辑 + +> **范围:全做 —— 下面 Phase 0–9 全部,一块不落。** 参考 AirDrama 后台、复用其壳/权限/组件/审计;AirShelf 多商品 + 多资产类型 + 火山审核 + 模型供应商,更重。**视觉一律套 AirShelf restraint(冷灰 + 单橙),不抄 AirDrama 的样子。** + +## 已拍板决策 +- **范围:全铺**(Phase 0–9 全做)。 +- **平台超管:加 `User.is_platform_admin`**;第一个超管 = **`admin` / `admin123`**(管理命令 bootstrap)。 +- **质量词:全平台统一单层**(所有团队共用一套;团队单独覆盖以后再说)。 +- **注册:邀请码优先**(码必填,`kind` 分流 create_team / join_team)。 + +## 复用 AirDrama(别重写,只换皮) +布局壳(AdminLayout 折叠侧栏+分组导航)· 权限类模式(IsSuperAdmin→IsPlatformAdmin)· 表格+行内操作/弹窗/手风琴/**标签编辑 UI**/状态徽章 · `AdminAuditLog` 审计日志 · API 分层(adminApi/teamApi)。 + +--- + +## Phase 0 · 基础(角色 + 壳 + 审计) +- 后端:`User.is_platform_admin` + migration;权限类 `IsPlatformAdmin`;管理命令 `create_platform_admin` 建 **admin/admin123**;`AdminAuditLog` 模型 + 写日志 helper;`/api/auth/me/` 返回 `is_platform_admin`。 +- 前端:Admin 外壳 `routes/admin/`(AdminLayout 折叠侧栏+分组导航,套 restraint);路由 `/admin/*` + 超管 gating;侧栏分组:概览/团队·用户/内容/生成/财务/系统。 + +## Phase 1 · 注册邀请码逻辑(本线驱动 · 最优先) +- 后端:Invitation 加 `kind`(join_team/create_team)+ `team` 可空 + migration;端点 `validate-invite`(免登录验码→{有效,类型,团队名});RegisterSerializer 改**码必填**+ 按 kind 分流;**删「空码=开团队」**。 +- 前端:注册页重做(码优先→验过才展开:加入=只读团队名+账号 / 开团队=团队名+账号),不暴露团队列表;邀请链接自动验;`api.validateInvite`。 + +## Phase 2 · Admin 壳实功能:邀请码发放管理 +- 后端:平台超管发 create_team 码 / 列所有码 / 撤销端点(替掉 Phase 1 的临时命令)。 +- 前端:Admin「邀请码」页(发码弹窗 + 码列表 + 状态 + 复制邀请链接)。 + +## Phase 3 · 平台团队管理 + 平台用户管理 +- 后端:列所有团队(名/owner/成员数/余额/状态)+ 启停 + 详情;列所有用户(跨团队)+ 启停 + 强制改密;全 `IsPlatformAdmin` + 写审计。 +- 前端:Admin「团队」页 + 「用户」页(表格 + 启停 + 详情弹窗,复用 AirDrama 表格/弹窗/徽章)。 + +## Phase 4 · 质量词 Admin(平台单层) +- 后端:模型 `QualityWord`(stage + slot + text + sort,平台级单层无 team)+ 增删改查端点;生成侧(`build_person_frontal_prompt` 等)改读配置、无配置回落现写死值(兼容)。 +- 前端:Admin「质量词」页,**复用 AirDrama 标签编辑 UI**(按 stage 分组 + 行内增删改 + 编辑模式开关防误删)。 + +## Phase 5 · 火山人像审核队列(AirShelf 特有) +- 后端:全局 person 资产按 `review_status`(空/processing/active绿/failed红)筛;批量送审 / 轮询 / 重试,复用现有 `assets/review.py` + `assets_client.py`。 +- 前端:Admin「资产审核」页(队列 + 绿/红盾徽章 + 批量送审/重试)。 + +## Phase 6 · AI 任务监控 + 成本异常 +- 后端:全局 AITask 列表(按 status/team/type 筛)+ 详情(request/response payload)+ 成本 >> 预估告警 + 失败重投端点。 +- 前端:Admin「任务监控」页(可筛表格 + 详情抽屉 + 异常标 + 重投)。 + +## Phase 7 · 计费审计 + 4 层额度策略 +- 后端:全局 CreditLedger 浏览(按 type/team 筛)+ 手动调额(争议/补偿,写审计)+ QuotaPolicy(月/项目/单任务 4 层)编辑端点。 +- 前端:Admin「计费审计」页(流水表 + 调额弹窗)+「额度策略」页。 + +## Phase 8 · 模型供应商管理(从 Django admin 搬前端) +- 后端:ModelProvider / ModelConfig 增删改启停 + 定价 + 默认模型选择端点(热插拔中转站)。 +- 前端:Admin「模型供应商」页(provider/model 表格 + 编辑弹窗 + 启停 + 设默认)。 + +## Phase 9 · 收尾治理(商品 / 通知 / 项目监控 / 数据完整性) +- 商品治理(孤儿商品 / 分类管理)· 通知管理(全局 Notification 筛/批量)· 项目流水线监控(全局项目按 stage/status)· 数据完整性工具(孤儿记录检查 / 缓存)。 + +--- + +## 实施顺序 +**0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9。** 每 Phase 后端先于前端;每块完即真跑验证再进下一块;**不 push**。Phase 0+1 先落(注册逻辑本线驱动、立刻见效)。 + +## 验证(每阶段) +- 注册:admin/admin123 登录→发 create_team 码→新用户凭码开团队当团管;团管发 join 码→成员加入;邀请链接自动验;**无码注册被拒**。 +- 质量词:Admin 改某阶段质量词→重新生成→提示词用新词。 +- 各 Admin 页:超管能进、非超管被拒;表格/弹窗/启停/审核/调额/热插拔真生效 + 写审计。 +- 基线:tsc + build 全绿;既有团管页 / 生成链路零回归。 diff --git a/docs/todo/admin-后台-进度.md b/docs/todo/admin-后台-进度.md new file mode 100644 index 0000000..38ab2d2 --- /dev/null +++ b/docs/todo/admin-后台-进度.md @@ -0,0 +1,40 @@ +# 平台 Admin 后台 + 注册邀请码 · 落地进度 + +> 计划:`C:/Users/Air-work/.claude/plans/gleaming-sleeping-ullman.md`(Phase 0–9 全做)。 +> 每个 Phase 走完「★测试底座」(后端单测 + 无头浏览器 e2e + tsc/build + 回归)才 commit。 +> 全程本地、不 push;测试服 DB 可动。 + +--- + +## 测试基线(回归红线) +- `apps.accounts / apps.billing / apps.products`:全绿(Phase 0 后 15/15 OK)。 +- `apps.projects`:**进 Phase 0 前已有 3 failures + 1 error**(均为 AI provider `image_edit` mock 相关,与本工程无关)——**非本次引入**,Phase 0 改动仅在 accounts + 前端,未触碰 projects/ai。后续 Phase 不得新增 fail。 + +--- + +## ✅ Phase 0 · 基础(角色 + 壳 + 审计)— 完成 2026-06-19 + +**后端** +- `User.is_platform_admin`(bool)+ migration `0004_user_is_platform_admin_adminauditlog`(已 apply 到测试 MySQL)。 +- `apps/accounts/permissions.py` · `IsPlatformAdmin`(超管放行 / 普通拒 / 停用拒)。 +- `apps/accounts/management/commands/create_platform_admin.py` · 建 **admin/admin123**(幂等;`--reset-password` 才重置;也置 `is_staff`)。已对测试 MySQL 执行,真账号可登。 +- `AdminAuditLog` 模型(operator/operator_name/action/target_*/before/after/ip)+ `apps/accounts/audit.py` `log_admin_action()`(失败不阻断主流程)。 +- `me` / `login` 对**无团队的平台超管**优雅返回 `team=null`(新增 `current_team_or_none`);`UserSerializer` 暴露 `is_platform_admin`。 + +**前端** +- 新 `routes/admin/admin-app.tsx`:超管后台外壳(折叠分组侧栏 6 组 11 项 + 顶栏 + 概览 + 占位页),全程复用 restraint 共享类。 +- 路由:`route-config.ts` 解析 `/admin`、`/admin/
`;`App.tsx` 分流渲染 `AdminApp` + gating(超管无团队登录直落 /admin、非超管访问 /admin 纠回工作台)。 +- `app-shell.tsx` 侧栏「平台」入口(仅超管可见)。 +- `admin-page.css`(仅 token,无裸 hex)+ `IconKitSvg` 补 8 个 Lucide-line 图标。 + +**测试底座(全过)** +- 后端单测:`apps.accounts` **11/11 OK**(新增 `PlatformAdminTests`:建超管命令幂等/权限类放行拒绝/登录+me 返回标志且 team 为空/普通注册非超管)。 +- 无头 e2e:`core/qa/visual-parity/_admin-p0.mjs` —— **全部断言通过 + 0 console error**: + - admin/admin123 真登录 → 落 /admin; + - 超管壳:6 分组 / 11 导航 / `[ PLATFORM ADMIN ]` 徽标 / 概览标题 / 点「邀请码」→ /admin/invites 占位「模块即将上线」; + - 普通用户访问 /admin → 纠回 /dashboard、admin 壳不渲染。 + - 截图:`output/admin/p0-*.png`。 +- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。 +- 视觉:概览截图核对 —— 冷灰底 + 单橙 accent + 8px 圆角 + 准星,符合 design.md 克制调性。 + +**测试账号**:平台超管 `admin/admin123`(测试 MySQL 已建);普通 demo `airshelf/Restraint2026`。