diff --git a/backend/accounts/admin.py b/backend/accounts/admin.py index 1b38bf4..c1e8998 100644 --- a/backend/accounts/admin.py +++ b/backend/accounts/admin.py @@ -20,6 +20,9 @@ class HulumathUserAdmin(UserAdmin): ("葫芦数学", {"fields": ("nickname", "track")}), ) list_display = ("username", "nickname", "track", "rating", "is_active", "date_joined") + list_filter = ("is_active", "is_staff", "track", "date_joined") + search_fields = ("username", "nickname", "email") + ordering = ("-date_joined",) @admin.register(InviteCode) @@ -27,9 +30,46 @@ class InviteCodeAdmin(admin.ModelAdmin): list_display = ("code", "group", "used_count", "max_uses", "expires_at", "is_active") list_filter = ("is_active", "group") search_fields = ("code", "group") + readonly_fields = ("used_count", "created_at") + ordering = ("-created_at",) -admin.site.register(InviteCodeUsage) -admin.site.register(UserSession) -admin.site.register(VisitorMigration) -admin.site.register(AuditLog) +@admin.register(InviteCodeUsage) +class InviteCodeUsageAdmin(admin.ModelAdmin): + list_display = ("invite_code", "user", "used_at", "ip_address") + search_fields = ("invite_code__code", "user__username", "user__nickname") + readonly_fields = ("invite_code", "user", "used_at", "ip_address") + ordering = ("-used_at",) + + +@admin.register(UserSession) +class UserSessionAdmin(admin.ModelAdmin): + list_display = ("user", "ip_address", "last_seen_at", "revoked_at") + search_fields = ("user__username", "user__nickname", "ip_address") + list_filter = ("revoked_at",) + readonly_fields = ("user", "session_key", "user_agent", "ip_address", "last_seen_at") + + +@admin.register(VisitorMigration) +class VisitorMigrationAdmin(admin.ModelAdmin): + list_display = ("user", "status", "created_at", "applied_at") + list_filter = ("status",) + search_fields = ("user__username", "user__nickname") + readonly_fields = ("user", "payload", "summary", "created_at", "applied_at") + + +@admin.register(AuditLog) +class AuditLogAdmin(admin.ModelAdmin): + list_display = ("action", "actor", "target_type", "target_id", "created_at") + list_filter = ("action", "target_type") + search_fields = ("actor__username", "action", "target_type", "target_id", "reason") + readonly_fields = ( + "actor", + "action", + "target_type", + "target_id", + "reason", + "metadata", + "created_at", + ) + ordering = ("-created_at",) diff --git a/backend/accounts/apps.py b/backend/accounts/apps.py index 3e3c765..95a5741 100644 --- a/backend/accounts/apps.py +++ b/backend/accounts/apps.py @@ -2,5 +2,6 @@ from django.apps import AppConfig class AccountsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'accounts' + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = "用户与权限" diff --git a/backend/accounts/serializers.py b/backend/accounts/serializers.py index 6d50700..a0d28ba 100644 --- a/backend/accounts/serializers.py +++ b/backend/accounts/serializers.py @@ -11,8 +11,17 @@ from .models import InviteCode, InviteCodeUsage, User, VisitorMigration class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ("id", "username", "nickname", "avatar_url", "bio", "track", "rating") - read_only_fields = ("id", "rating") + fields = ( + "id", + "username", + "nickname", + "avatar_url", + "bio", + "track", + "rating", + "is_staff", + ) + read_only_fields = ("id", "rating", "is_staff") class RegisterSerializer(serializers.Serializer): diff --git a/backend/accounts/test_serializers.py b/backend/accounts/test_serializers.py index 6e40a7a..f02db91 100644 --- a/backend/accounts/test_serializers.py +++ b/backend/accounts/test_serializers.py @@ -5,7 +5,7 @@ from django.utils import timezone from rest_framework import serializers from accounts.models import InviteCode, InviteCodeUsage, User -from accounts.serializers import RegisterSerializer +from accounts.serializers import RegisterSerializer, UserSerializer def registration_data(code="VALID-CODE", username="math_user"): @@ -74,3 +74,21 @@ def test_register_serializer_游客迁移字段类型错误时拒绝(): assert not serializer.is_valid() assert "favorites 必须是数组" in str(serializer.errors["visitor_data"]) + + +@pytest.mark.django_db +def test_user_serializer_返回员工身份且客户端不能提权(): + user = User.objects.create_user( + username="operator", + password="StrongPass_2026", + nickname="运营人员", + is_staff=False, + ) + + serializer = UserSerializer(user, data={"is_staff": True}, partial=True) + + assert serializer.is_valid(), serializer.errors + serializer.save() + user.refresh_from_db() + assert user.is_staff is False + assert UserSerializer(user).data["is_staff"] is False diff --git a/backend/common/admin_site.py b/backend/common/admin_site.py new file mode 100644 index 0000000..6b32c6b --- /dev/null +++ b/backend/common/admin_site.py @@ -0,0 +1,189 @@ +from django.contrib.admin import AdminSite +from django.utils import timezone + + +class HulumathAdminSite(AdminSite): + site_header = "葫芦数学运营后台" + site_title = "葫芦数学后台" + index_title = "运营总览" + site_url = "/" + index_template = "admin/index.html" + login_template = "admin/login.html" + + APP_ORDER = { + "accounts": 10, + "math_life": 20, + "contest": 30, + "content": 40, + "latex_lab": 50, + "progression": 60, + "engagement": 70, + "auth": 90, + } + MODEL_LABELS = { + "accounts.user": "用户账号", + "accounts.invitecode": "邀请码", + "accounts.invitecodeusage": "邀请码使用记录", + "accounts.usersession": "登录设备", + "accounts.visitormigration": "游客数据迁移", + "accounts.auditlog": "管理员审计日志", + "math_life.mathidentity": "数学人格", + "math_life.mathbtiassessment": "MathBTI 测评版本", + "math_life.mathbtiresult": "MathBTI 用户结果", + "math_life.character": "剧情人物", + "math_life.story": "数学人生", + "math_life.storyversion": "人生版本", + "math_life.storyrun": "用户人生存档", + "math_life.storychoice": "用户选择记录", + "math_life.userrelationship": "人物关系", + "math_life.skillpackage": "人物 Skill 包", + "contest.question": "题目", + "contest.questionversion": "题目版本", + "contest.contest": "比赛", + "contest.contestattempt": "答题记录", + "contest.contestanswer": "逐题答案", + "contest.realtimematch": "实时对局", + "contest.ratinghistory": "Rating 变化", + "contest.leaderboardsnapshot": "排行榜快照", + "contest.cheatflag": "异常成绩", + "content.contentitem": "视频与内容", + "content.contentinteraction": "内容互动", + "content.videoprogress": "视频观看进度", + "latex_lab.formuladocument": "用户公式文档", + "latex_lab.formularevision": "公式版本", + "latex_lab.latexcourse": "LaTeX 课程", + "latex_lab.latexexercise": "LaTeX 练习", + "latex_lab.latexattempt": "练习记录", + "latex_lab.handwritingrecognitionjob": "手写识别任务", + "progression.userpet": "数学精灵", + "progression.userability": "五维能力", + "progression.card": "数学人物卡", + "progression.usercard": "用户卡册", + "progression.rewardtransaction": "奖励流水", + "auth.group": "后台角色组", + } + MODEL_ORDER = { + label: index + for index, label in enumerate( + ( + "用户账号", + "邀请码", + "邀请码使用记录", + "登录设备", + "游客数据迁移", + "管理员审计日志", + "数学人生", + "人生版本", + "剧情人物", + "人物 Skill 包", + "数学人格", + "MathBTI 测评版本", + "用户人生存档", + "人物关系", + "比赛", + "题目", + "题目版本", + "实时对局", + "答题记录", + "异常成绩", + "视频与内容", + "视频观看进度", + "内容互动", + "LaTeX 课程", + "LaTeX 练习", + "用户公式文档", + "数学精灵", + "五维能力", + "数学人物卡", + "用户卡册", + ) + ) + } + + def get_app_list(self, request, app_label=None): + app_list = super().get_app_list(request, app_label) + for app in app_list: + app["models"] = list(app["models"]) + for model in app["models"]: + object_name = model["object_name"].lower() + key = f"{app['app_label']}.{object_name}" + model["name"] = self.MODEL_LABELS.get(key, model["name"]) + app["models"].sort( + key=lambda model: ( + self.MODEL_ORDER.get(model["name"], 999), + model["name"], + ) + ) + app_list.sort( + key=lambda app: ( + self.APP_ORDER.get(app["app_label"], 999), + app["name"], + ) + ) + return app_list + + def each_context(self, request): + context = super().each_context(request) + context["admin_environment"] = "生产运营" + return context + + def index(self, request, extra_context=None): + from accounts.models import InviteCode, User + from content.models import ContentItem + from contest.models import CheatFlag, ContestAttempt, RealtimeMatch + from math_life.models import StoryRun + + today = timezone.localdate() + extra_context = { + **(extra_context or {}), + "dashboard_metrics": [ + { + "label": "总用户", + "value": User.objects.filter(is_active=True).count(), + "hint": f"今日新增 {User.objects.filter(date_joined__date=today).count()}", + "tone": "green", + }, + { + "label": "进行中的人生", + "value": StoryRun.objects.filter(status=StoryRun.Status.ACTIVE).count(), + "hint": "用户当前存档", + "tone": "blue", + }, + { + "label": "今日比赛完成", + "value": ContestAttempt.objects.filter( + submitted_at__date=today + ).count(), + "hint": "含实时与异步", + "tone": "orange", + }, + { + "label": "已发布内容", + "value": ContentItem.objects.filter(is_published=True).count(), + "hint": "视频、知识与人物", + "tone": "purple", + }, + ], + "dashboard_alerts": [ + { + "label": "待处理异常成绩", + "value": CheatFlag.objects.filter( + status=CheatFlag.Status.OPEN + ).count(), + "url": "/admin/contest/cheatflag/?status__exact=open", + }, + { + "label": "等待中的实时对局", + "value": RealtimeMatch.objects.filter( + status=RealtimeMatch.Status.WAITING + ).count(), + "url": "/admin/contest/realtimematch/?status__exact=waiting", + }, + { + "label": "可用邀请码", + "value": InviteCode.objects.filter(is_active=True).count(), + "url": "/admin/accounts/invitecode/", + }, + ], + } + return super().index(request, extra_context) diff --git a/backend/common/apps.py b/backend/common/apps.py index 01cca2f..aeaf0e7 100644 --- a/backend/common/apps.py +++ b/backend/common/apps.py @@ -1,6 +1,26 @@ from django.apps import AppConfig +from django.contrib.admin import apps as admin_apps class CommonConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'common' + default = True + default_auto_field = "django.db.models.BigAutoField" + name = "common" + verbose_name = "系统" + + def ready(self): + from django.contrib import admin + + from .admin_site import HulumathAdminSite + + for model in admin.site._registry: + key = f"{model._meta.app_label}.{model._meta.model_name}" + label = HulumathAdminSite.MODEL_LABELS.get(key) + if label: + model._meta.verbose_name = label + model._meta.verbose_name_plural = label + + +class HulumathAdminConfig(admin_apps.AdminConfig): + default = False + default_site = "common.admin_site.HulumathAdminSite" diff --git a/backend/common/test_admin.py b/backend/common/test_admin.py new file mode 100644 index 0000000..55aa730 --- /dev/null +++ b/backend/common/test_admin.py @@ -0,0 +1,71 @@ +import pytest +from django.urls import reverse + +from accounts.models import InviteCode, User +from content.models import ContentItem + + +@pytest.mark.django_db +def test_admin_login_使用自定义运营登录页(client): + response = client.get(reverse("admin:login")) + + assert response.status_code == 200 + content = response.content.decode() + assert "管理数学人生宇宙" in content + assert "进入运营后台" in content + + +@pytest.mark.django_db +def test_admin_index_普通用户不能访问(client): + user = User.objects.create_user( + username="normal_user", + password="StrongPass_2026", + nickname="普通用户", + ) + client.force_login(user) + + response = client.get(reverse("admin:index")) + + assert response.status_code == 302 + assert reverse("admin:login") in response.url + + +@pytest.mark.django_db +def test_admin_index_展示运营指标与中文模块(client): + admin_user = User.objects.create_superuser( + username="dashboard_admin", + password="StrongPass_2026", + nickname="后台管理员", + ) + InviteCode.objects.create(code="ADMIN-TEST", max_uses=10) + ContentItem.objects.create( + slug="admin-content", + title="后台测试内容", + kind=ContentItem.Kind.KNOWLEDGE, + is_published=True, + ) + client.force_login(admin_user) + + response = client.get(reverse("admin:index")) + + assert response.status_code == 200 + content = response.content.decode() + assert "今天从哪里开始?" in content + assert "总用户" in content + assert "用户与权限" in content + assert "用户账号" in content + assert "数学人生与 MathBTI" in content + assert "比赛运营" in content + assert "视频与探索内容" in content + assert "视频与内容" in content + assert "生成邀请码" in content + + +@pytest.mark.django_db +def test_home_包含按员工身份控制的后台入口(client): + response = client.get("/") + + assert response.status_code == 200 + content = response.content.decode() + assert 'id="admin-entry"' in content + assert 'href="/admin/"' in content diff --git a/backend/config/settings.py b/backend/config/settings.py index 6967f44..afeea01 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -19,7 +19,7 @@ ALLOWED_HOSTS = [ ] INSTALLED_APPS = [ - "django.contrib.admin", + "common.apps.HulumathAdminConfig", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", @@ -111,7 +111,13 @@ STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [BASE_DIR / "static"] STORAGES = { "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, - "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, + "staticfiles": { + "BACKEND": ( + "django.contrib.staticfiles.storage.StaticFilesStorage" + if DEBUG + else "whitenoise.storage.CompressedManifestStaticFilesStorage" + ) + }, } DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/backend/content/admin.py b/backend/content/admin.py index 091f003..fca6ac1 100644 --- a/backend/content/admin.py +++ b/backend/content/admin.py @@ -15,7 +15,24 @@ class ContentItemAdmin(admin.ModelAdmin): ) list_filter = ("kind", "ability_dimension", "discipline", "is_published") search_fields = ("title", "summary", "body") + ordering = ("-published_at", "title") + list_editable = ("is_published",) + readonly_fields = ("view_count", "comment_count") -admin.site.register(ContentInteraction) -admin.site.register(VideoProgress) +@admin.register(ContentInteraction) +class ContentInteractionAdmin(admin.ModelAdmin): + list_display = ("user", "content", "action", "created_at") + list_filter = ("action", "content__kind") + search_fields = ("user__username", "user__nickname", "content__title") + readonly_fields = ("user", "content", "action", "created_at") + ordering = ("-created_at",) + + +@admin.register(VideoProgress) +class VideoProgressAdmin(admin.ModelAdmin): + list_display = ("user", "content", "completed", "reward_granted", "updated_at") + list_filter = ("completed", "reward_granted", "content__ability_dimension") + search_fields = ("user__username", "user__nickname", "content__title") + readonly_fields = ("user", "content", "updated_at", "completed_at") + ordering = ("-updated_at",) diff --git a/backend/content/apps.py b/backend/content/apps.py index 273d169..7fd2aed 100644 --- a/backend/content/apps.py +++ b/backend/content/apps.py @@ -2,5 +2,6 @@ from django.apps import AppConfig class ContentConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'content' + default_auto_field = "django.db.models.BigAutoField" + name = "content" + verbose_name = "视频与探索内容" diff --git a/backend/contest/admin.py b/backend/contest/admin.py index e146533..9ba5564 100644 --- a/backend/contest/admin.py +++ b/backend/contest/admin.py @@ -23,6 +23,9 @@ class ContestQuestionInline(admin.TabularInline): class ContestAdmin(admin.ModelAdmin): list_display = ("title", "kind", "track", "status", "starts_at", "ends_at") list_filter = ("kind", "track", "status") + search_fields = ("title", "slug") + list_editable = ("status",) + ordering = ("-starts_at", "title") inlines = [ContestQuestionInline] @@ -37,10 +40,26 @@ class ContestAttemptAdmin(admin.ModelAdmin): class CheatFlagAdmin(admin.ModelAdmin): list_display = ("attempt", "reason", "status", "created_at") list_filter = ("status", "reason") + search_fields = ("attempt__user__username", "attempt__user__nickname", "reason") + list_editable = ("status",) + ordering = ("-created_at",) + + +@admin.register(Question) +class QuestionAdmin(admin.ModelAdmin): + list_display = ("slug", "track", "is_active", "tags") + list_filter = ("track", "is_active") + search_fields = ("slug", "versions__prompt") + list_editable = ("is_active",) + + +@admin.register(QuestionVersion) +class QuestionVersionAdmin(admin.ModelAdmin): + list_display = ("question", "version", "prompt", "created_at") + search_fields = ("question__slug", "prompt", "answer") + ordering = ("question", "-version") -admin.site.register(Question) -admin.site.register(QuestionVersion) admin.site.register(ContestAnswer) admin.site.register(RealtimeMatch) admin.site.register(RatingHistory) diff --git a/backend/contest/apps.py b/backend/contest/apps.py index 6e9d520..1e78e3f 100644 --- a/backend/contest/apps.py +++ b/backend/contest/apps.py @@ -2,5 +2,6 @@ from django.apps import AppConfig class ContestConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'contest' + default_auto_field = "django.db.models.BigAutoField" + name = "contest" + verbose_name = "比赛运营" diff --git a/backend/engagement/apps.py b/backend/engagement/apps.py index b0f3b66..a09dac3 100644 --- a/backend/engagement/apps.py +++ b/backend/engagement/apps.py @@ -2,5 +2,6 @@ from django.apps import AppConfig class EngagementConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'engagement' + default_auto_field = "django.db.models.BigAutoField" + name = "engagement" + verbose_name = "签到与通知" diff --git a/backend/latex_lab/apps.py b/backend/latex_lab/apps.py index 5f75e55..0c96e74 100644 --- a/backend/latex_lab/apps.py +++ b/backend/latex_lab/apps.py @@ -2,5 +2,6 @@ from django.apps import AppConfig class LatexLabConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'latex_lab' + default_auto_field = "django.db.models.BigAutoField" + name = "latex_lab" + verbose_name = "工具箱与 LaTeX" diff --git a/backend/math_life/admin.py b/backend/math_life/admin.py index b949a91..b5b2fa0 100644 --- a/backend/math_life/admin.py +++ b/backend/math_life/admin.py @@ -16,10 +16,20 @@ from .models import ( from .services import validate_story_content +@admin.register(Story) +class StoryAdmin(admin.ModelAdmin): + list_display = ("title", "kind", "estimated_minutes", "is_visible") + list_filter = ("kind", "is_visible") + search_fields = ("title", "summary", "slug") + list_editable = ("is_visible",) + + @admin.register(StoryVersion) class StoryVersionAdmin(admin.ModelAdmin): list_display = ("story", "version", "is_published", "published_at", "created_at") list_filter = ("is_published", "story__kind") + search_fields = ("story__title",) + ordering = ("story", "-version") def save_model(self, request, obj, form, change): errors = validate_story_content(obj.content) @@ -33,13 +43,14 @@ class StoryRunAdmin(admin.ModelAdmin): list_display = ("id", "user", "story_version", "status", "current_node", "updated_at") list_filter = ("status", "story_version__story") readonly_fields = ("started_at", "updated_at", "completed_at") + search_fields = ("user__username", "user__nickname", "story_version__story__title") + ordering = ("-updated_at",) admin.site.register(MathIdentity) admin.site.register(MathBTIAssessment) admin.site.register(MathBTIResult) admin.site.register(Character) -admin.site.register(Story) admin.site.register(StoryChoice) admin.site.register(UserRelationship) admin.site.register(SkillPackage) diff --git a/backend/math_life/apps.py b/backend/math_life/apps.py index 28e87ec..2adf83a 100644 --- a/backend/math_life/apps.py +++ b/backend/math_life/apps.py @@ -2,5 +2,6 @@ from django.apps import AppConfig class MathLifeConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'math_life' + default_auto_field = "django.db.models.BigAutoField" + name = "math_life" + verbose_name = "数学人生与 MathBTI" diff --git a/backend/progression/apps.py b/backend/progression/apps.py index 62395e6..5ed4047 100644 --- a/backend/progression/apps.py +++ b/backend/progression/apps.py @@ -2,5 +2,6 @@ from django.apps import AppConfig class ProgressionConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'progression' + default_auto_field = "django.db.models.BigAutoField" + name = "progression" + verbose_name = "成长与资产" diff --git a/backend/static/admin/css/hulumath_admin.css b/backend/static/admin/css/hulumath_admin.css new file mode 100644 index 0000000..6b64a4d --- /dev/null +++ b/backend/static/admin/css/hulumath_admin.css @@ -0,0 +1,719 @@ +:root { + --hulu-ink: #17211b; + --hulu-green: #196548; + --hulu-green-dark: #124d37; + --hulu-lime: #cce85b; + --hulu-paper: #f3f1e9; + --hulu-panel: #fffefa; + --hulu-line: #dfe2da; + --hulu-muted: #6b756e; + --primary: #196548; + --secondary: #17211b; + --accent: #cce85b; + --header-bg: #17211b; + --header-color: #ffffff; + --breadcrumbs-bg: #235f47; + --breadcrumbs-link-fg: #f3f1e9; + --link-fg: #196548; + --link-hover-color: #124d37; + --button-bg: #196548; + --button-hover-bg: #124d37; + --default-button-bg: #17211b; + --default-button-hover-bg: #2c3931; +} + +body { + background: var(--hulu-paper); + color: var(--hulu-ink); +} + +#header { + min-height: 70px; + padding: 0 28px; + background: + radial-gradient(circle at 80% -30%, rgba(204, 232, 91, .16), transparent 35%), + var(--hulu-ink); + box-sizing: border-box; +} + +#branding { + display: flex; + align-items: center; +} + +.hulu-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.hulu-brand-mark { + display: grid; + width: 40px; + height: 40px; + place-items: center; + border-radius: 12px; + background: var(--hulu-lime); + color: var(--hulu-ink) !important; + font: 700 22px Georgia, serif; + text-decoration: none; +} + +.hulu-brand-name { + display: block; + color: white !important; + font-size: 17px; + font-weight: 700; + letter-spacing: .06em; + text-decoration: none; +} + +.hulu-brand span { + display: block; + margin-top: 2px; + color: #aeb9b1; + font-size: 10px; + letter-spacing: .13em; +} + +.hulu-global-nav { + display: flex; + align-items: center; + gap: 14px; +} + +.hulu-global-nav a { + padding: 8px 12px; + border: 1px solid rgba(255, 255, 255, .18); + border-radius: 9px; + color: white !important; + text-decoration: none; +} + +.hulu-environment { + padding: 5px 9px; + border-radius: 99px; + background: rgba(204, 232, 91, .12); + color: var(--hulu-lime); + font-size: 10px; + letter-spacing: .08em; +} + +#user-tools { + color: #aeb9b1; +} + +#user-tools a { + color: white; +} + +div.breadcrumbs { + padding: 13px 30px; +} + +#content { + padding: 30px; +} + +#content h1 { + font-family: Georgia, "Songti SC", serif; + font-weight: 500; +} + +#nav-sidebar { + border-right: 1px solid var(--hulu-line); + background: #ebeae3; +} + +#nav-sidebar .current-app .section, +#nav-sidebar .current-model { + background: rgba(25, 101, 72, .1); +} + +.module, +.inline-group { + overflow: hidden; + border: 1px solid var(--hulu-line); + border-radius: 14px; + background: var(--hulu-panel); + box-shadow: 0 8px 30px rgba(23, 33, 27, .04); +} + +.module h2, +.module caption, +.inline-group h2 { + background: var(--hulu-ink); +} + +#changelist-filter { + border-left: 1px solid var(--hulu-line); + background: #f8f7f1; +} + +#changelist .actions { + padding: 12px; + border-radius: 10px; + background: #e9eee8; +} + +input, +textarea, +select, +.vTextField { + border-color: #ccd2ca !important; + border-radius: 8px !important; +} + +input:focus, +textarea:focus, +select:focus { + border-color: var(--hulu-green) !important; + box-shadow: 0 0 0 3px rgba(25, 101, 72, .1); + outline: none; +} + +.button, +input[type=submit], +input[type=button], +.submit-row input, +a.button { + border-radius: 9px; +} + +.object-tools a { + border-radius: 9px; + background: var(--hulu-green); +} + +.hulu-dashboard #content { + max-width: 1550px; + margin: auto; +} + +.hulu-dashboard-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 25px; + margin-bottom: 24px; + padding: 32px 36px; + overflow: hidden; + border-radius: 20px; + background: + radial-gradient(circle at 88% 30%, rgba(204, 232, 91, .17), transparent 25%), + var(--hulu-ink); + color: white; +} + +.hulu-eyebrow { + color: var(--hulu-green); + font-size: 9px; + font-weight: 700; + letter-spacing: .2em; +} + +.hulu-dashboard-head .hulu-eyebrow { + color: var(--hulu-lime); +} + +.hulu-dashboard-head h1 { + margin: 12px 0 8px; + color: white; + font: 500 clamp(30px, 4vw, 48px)/1.1 Georgia, "Songti SC", serif; +} + +.hulu-dashboard-head p { + margin: 0; + color: #b9c2bc; + font-size: 13px; +} + +.hulu-head-actions { + display: flex; + flex-wrap: wrap; + gap: 9px; +} + +.hulu-button { + display: inline-block; + padding: 11px 16px; + border: 1px solid rgba(255, 255, 255, .2); + border-radius: 10px; + color: white !important; + text-decoration: none; +} + +.hulu-button.primary { + border-color: var(--hulu-lime); + background: var(--hulu-lime); + color: var(--hulu-ink) !important; + font-weight: 700; +} + +.hulu-metric-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 13px; + margin-bottom: 26px; +} + +.hulu-metric { + position: relative; + overflow: hidden; + min-height: 125px; + padding: 20px; + border: 1px solid var(--hulu-line); + border-radius: 16px; + background: var(--hulu-panel); +} + +.hulu-metric::after { + position: absolute; + width: 70px; + height: 70px; + right: -25px; + top: -25px; + border-radius: 50%; + background: var(--metric-color); + content: ""; + opacity: .16; +} + +.hulu-metric.green { --metric-color: #2f936c; } +.hulu-metric.blue { --metric-color: #5d73e8; } +.hulu-metric.orange { --metric-color: #dc823c; } +.hulu-metric.purple { --metric-color: #8667b5; } + +.hulu-metric span, +.hulu-metric strong, +.hulu-metric small { + display: block; +} + +.hulu-metric span { + color: var(--hulu-muted); + font-size: 11px; +} + +.hulu-metric strong { + margin: 10px 0 6px; + color: var(--hulu-ink); + font: 34px Georgia, serif; +} + +.hulu-metric small { + color: #8b938e; +} + +.hulu-dashboard-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 310px; + gap: 20px; +} + +.hulu-section-title { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.hulu-section-title h2 { + margin: 5px 0 0; + color: var(--hulu-ink); + font: 25px Georgia, "Songti SC", serif; +} + +.hulu-section-title > span { + color: var(--hulu-muted); + font-size: 11px; +} + +.hulu-section-title.compact h2 { + font-size: 20px; +} + +.hulu-app-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.hulu-app-card, +.hulu-side-card { + overflow: hidden; + border: 1px solid var(--hulu-line); + border-radius: 16px; + background: var(--hulu-panel); +} + +.hulu-app-card header { + display: flex; + align-items: center; + gap: 12px; + padding: 18px; + border-bottom: 1px solid var(--hulu-line); +} + +.hulu-app-icon { + display: grid; + flex: 0 0 auto; + width: 42px; + height: 42px; + place-items: center; + border-radius: 12px; + background: rgba(25, 101, 72, .1); + color: var(--hulu-green); + font: 700 17px Georgia, serif; +} + +.module-contest .hulu-app-icon { background: rgba(220, 130, 60, .12); color: #a45d27; } +.module-content .hulu-app-icon { background: rgba(93, 115, 232, .1); color: #4d60c2; } +.module-latex_lab .hulu-app-icon { background: rgba(134, 103, 181, .1); color: #7655a8; } + +.hulu-app-card h3 { + margin: 0; + font-size: 15px; +} + +.hulu-app-card h3 a { + color: var(--hulu-ink); + text-decoration: none; +} + +.hulu-app-card header span { + display: block; + margin-top: 4px; + color: var(--hulu-muted); + font-size: 10px; +} + +.hulu-model-list { + padding: 5px 16px 12px; +} + +.hulu-model-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 39px; + border-bottom: 1px solid #edeee8; +} + +.hulu-model-row:last-child { + border-bottom: 0; +} + +.hulu-model-row > a, +.hulu-model-row > span { + color: #334039; + font-size: 12px; + text-decoration: none; +} + +.hulu-model-row > div { + display: flex; + gap: 8px; +} + +.hulu-add-link, +.hulu-view-link { + font-size: 10px; + text-decoration: none; +} + +.hulu-add-link { + color: var(--hulu-green); +} + +.hulu-view-link { + color: #7d8680; +} + +.hulu-dashboard-side { + display: grid; + align-content: start; + gap: 14px; +} + +.hulu-side-card { + padding: 18px; +} + +.hulu-alert-row { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 42px; + border-bottom: 1px solid #edeee8; + color: #334039; + text-decoration: none; +} + +.hulu-alert-row strong { + display: grid; + min-width: 29px; + height: 25px; + padding: 0 5px; + place-items: center; + border-radius: 99px; + background: #f0e5dc; + color: #a65034; +} + +.hulu-quick-links { + display: grid; + gap: 7px; +} + +.hulu-quick-links a { + padding: 10px 12px; + border-radius: 9px; + background: #f1f3ed; + color: #334039; + text-decoration: none; +} + +.hulu-quick-links a:hover { + background: #e4ebe3; + color: var(--hulu-green); +} + +.hulu-recent-list { + display: grid; + gap: 12px; +} + +.hulu-recent-row { + display: flex; + gap: 9px; +} + +.hulu-action-dot { + flex: 0 0 auto; + width: 8px; + height: 8px; + margin-top: 5px; + border-radius: 50%; + background: #d46c55; +} + +.hulu-action-dot.add { background: #45a879; } +.hulu-action-dot.change { background: #6480d0; } + +.hulu-recent-row a, +.hulu-recent-row strong { + color: #334039; + font-size: 11px; + text-decoration: none; +} + +.hulu-recent-row small { + display: block; + margin-top: 3px; + color: #909791; + font-size: 9px; +} + +.hulu-muted, +.hulu-empty { + color: var(--hulu-muted); +} + +.hulu-empty { + padding: 35px; + border: 1px dashed #c8cdc6; + border-radius: 14px; + text-align: center; +} + +.hulu-admin-login { + background: + radial-gradient(circle at 12% 20%, rgba(204, 232, 91, .12), transparent 28%), + var(--hulu-paper); +} + +.hulu-admin-login #header { + background: transparent; +} + +.hulu-admin-login #branding .hulu-brand-name, +.hulu-admin-login #branding .hulu-brand span { + color: var(--hulu-ink) !important; +} + +.hulu-admin-login #content { + max-width: 1040px; + margin: 4vh auto 0; +} + +.hulu-login-shell { + display: grid; + grid-template-columns: 1.1fr .9fr; + overflow: hidden; + min-height: 535px; + border: 1px solid var(--hulu-line); + border-radius: 24px; + background: var(--hulu-panel); + box-shadow: 0 30px 80px rgba(23, 33, 27, .12); +} + +.hulu-login-intro { + padding: 60px; + background: + radial-gradient(circle at 75% 80%, rgba(204, 232, 91, .15), transparent 30%), + var(--hulu-ink); + color: white; +} + +.hulu-login-intro .hulu-eyebrow { + color: var(--hulu-lime); +} + +.hulu-login-intro h1 { + max-width: 380px; + margin: 28px 0 20px; + color: white; + font: 500 48px/1.08 Georgia, "Songti SC", serif; +} + +.hulu-login-intro p { + max-width: 430px; + color: #b9c2bc; + line-height: 1.8; +} + +.hulu-login-points { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 55px; +} + +.hulu-login-points span { + padding: 7px 10px; + border: 1px solid rgba(255, 255, 255, .14); + border-radius: 99px; + color: #d5dbd7; + font-size: 10px; +} + +.hulu-login-card { + display: flex; + flex-direction: column; + justify-content: center; + padding: 50px; +} + +.hulu-login-card h2 { + margin: 8px 0; + color: var(--hulu-ink); + font: 30px Georgia, "Songti SC", serif; +} + +.hulu-login-card > div > p { + margin: 0 0 25px; + color: var(--hulu-muted); +} + +.hulu-login-card .form-row { + padding: 0 0 18px; +} + +.hulu-login-card .form-row label { + display: block; + margin-bottom: 7px; + color: #47534c; + font-weight: 600; +} + +.hulu-login-card .form-row input { + width: 100%; + box-sizing: border-box; + padding: 13px; +} + +.hulu-login-card .submit-row { + padding: 0; + background: transparent; +} + +.hulu-login-card .submit-row input { + width: 100%; + padding: 13px; + font-weight: 700; +} + +.hulu-back-site { + margin-top: 22px; + color: var(--hulu-muted); + text-align: center; + text-decoration: none; +} + +@media (max-width: 1100px) { + .hulu-dashboard-layout { + grid-template-columns: 1fr; + } + + .hulu-dashboard-side { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 800px) { + #header { + padding: 10px 16px; + } + + #content { + padding: 18px; + } + + .hulu-global-nav, + .hulu-environment { + display: none; + } + + .hulu-dashboard-head { + display: block; + padding: 26px; + } + + .hulu-head-actions { + margin-top: 20px; + } + + .hulu-metric-grid, + .hulu-app-grid, + .hulu-dashboard-side { + grid-template-columns: 1fr 1fr; + } + + .hulu-login-shell { + grid-template-columns: 1fr; + } + + .hulu-login-intro { + display: none; + } +} + +@media (max-width: 520px) { + .hulu-metric-grid, + .hulu-app-grid, + .hulu-dashboard-side { + grid-template-columns: 1fr; + } + + .hulu-model-row { + align-items: start; + padding: 9px 0; + } + + .hulu-login-card { + padding: 30px 24px; + } +} diff --git a/backend/static/css/app.css b/backend/static/css/app.css index 4e78682..abbbd6a 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -56,6 +56,14 @@ button { color: inherit; } .main { padding: 0 46px 70px; min-width: 0; } .topbar { height: 82px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); } .topbar p { color: var(--muted); font-size: 11px; letter-spacing: .08em; } +.topbar-actions { display: flex; align-items: center; gap: 12px; } +.admin-entry { + display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid rgba(25,101,72,.22); + border-radius: 10px; background: rgba(25,101,72,.06); color: var(--green); font-size: 11px; font-weight: 700; text-decoration: none; +} +.admin-entry[hidden] { display: none; } +.admin-entry:hover { background: var(--green); color: white; } +.admin-entry b { font-size: 14px; } .user-chip { display: flex; align-items: center; gap: 9px; font-size: 13px; } .avatar { width: 31px; height: 31px; border-radius: 50%; display: grid; place-items: center; background: var(--ink); color: white; font-size: 11px; } .view { display: none; animation: rise .35s ease; } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 8359daf..d14f6c1 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -74,6 +74,7 @@ function updateUserUI() { name.textContent = state.user ? `${state.user.nickname} · ${state.user.rating}` : "游客"; chip.append(avatar, name); $("#auth-button").textContent = state.user ? "退出登录" : "登录 / 注册"; + $("#admin-entry").hidden = !state.user?.is_staff; } async function loadUser() { diff --git a/backend/templates/admin/base_site.html b/backend/templates/admin/base_site.html new file mode 100644 index 0000000..b5ab5c6 --- /dev/null +++ b/backend/templates/admin/base_site.html @@ -0,0 +1,26 @@ +{% extends "admin/base.html" %} +{% load static %} + +{% block title %}{{ title }} | 葫芦数学后台{% endblock %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock %} + +{% block branding %} +
+ H +
+ 葫芦数学 + 运营管理中心 +
+
+{% endblock %} + +{% block nav-global %} +
+ {{ admin_environment|default:"运营后台" }} + 查看网站 +
+{% endblock %} diff --git a/backend/templates/admin/index.html b/backend/templates/admin/index.html new file mode 100644 index 0000000..6178bf1 --- /dev/null +++ b/backend/templates/admin/index.html @@ -0,0 +1,146 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block bodyclass %}{{ block.super }} dashboard hulu-dashboard{% endblock %} +{% block breadcrumbs %}{% endblock %} +{% block content_title %}{% endblock %} + +{% block content %} +
+
+ OPERATIONS OVERVIEW +

今天从哪里开始?

+

管理用户、人生内容、比赛和视频,关键异常会集中显示在这里。

+
+
+ 发布内容 + 生成邀请码 +
+
+ +
+ {% for metric in dashboard_metrics %} +
+ {{ metric.label }} + {{ metric.value }} + {{ metric.hint }} +
+ {% endfor %} +
+ +
+
+
+
+ BUSINESS MODULES +

业务模块

+
+ 按日常运营顺序排列 +
+ + {% if app_list %} +
+ {% for app in app_list %} +
+
+
{{ app.name|slice:":1" }}
+
+

+ {{ app.name }} +

+ {{ app.models|length }} 个管理入口 +
+
+
+ {% for model in app.models %} +
+ {% if model.admin_url %} + {{ model.name }} + {% else %} + {{ model.name }} + {% endif %} +
+ {% if model.add_url %} + 新增 + {% endif %} + {% if model.admin_url %} + 管理 + {% endif %} +
+
+ {% endfor %} +
+
+ {% endfor %} +
+ {% else %} +
当前账号没有后台管理权限。
+ {% endif %} +
+ + +
+{% endblock %} + +{% block sidebar %}{% endblock %} diff --git a/backend/templates/admin/login.html b/backend/templates/admin/login.html new file mode 100644 index 0000000..d4ded5b --- /dev/null +++ b/backend/templates/admin/login.html @@ -0,0 +1,65 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block bodyclass %}{{ block.super }} login hulu-admin-login{% endblock %} +{% block nav-sidebar %}{% endblock %} +{% block breadcrumbs %}{% endblock %} +{% block nav-global %}{% endblock %} +{% block content_title %}{% endblock %} + +{% block content %} +
+
+ HULU MATH OPERATIONS +

管理数学人生宇宙

+

发布内容、维护剧情、组织比赛,并查看真实用户的成长轨迹。

+ +
+ +
+
+ STAFF SIGN IN +

后台登录

+

仅限已授权的运营和管理员账号。

+
+ + {% if form.errors and not form.non_field_errors %} +

用户名或密码不正确,请重新输入。

+ {% endif %} + {% if form.non_field_errors %} + {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} + {% endif %} + +
+ {% csrf_token %} +
+ {{ form.username.errors }} + + {{ form.username }} +
+
+ {{ form.password.errors }} + + {{ form.password }} + +
+ {% url 'admin_password_reset' as password_reset_url %} + {% if password_reset_url %} + + {% endif %} +
+ +
+
+ ← 返回葫芦数学网站 +
+
+{% endblock %} diff --git a/backend/templates/index.html b/backend/templates/index.html index 5cacfc0..0f9efa0 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -34,7 +34,12 @@

今日坐标 · 数学人生宇宙

-
游客
+
+ +
游客
+