v 0.3 Preview: Update admin
CI / test (push) Canceled after 4m2s

This commit is contained in:
2026-08-08 21:47:29 +08:00
parent 1f98f7152d
commit 43e425edbe
24 changed files with 1408 additions and 31 deletions
+44 -4
View File
@@ -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",)
+3 -2
View File
@@ -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 = "用户与权限"
+11 -2
View File
@@ -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):
+19 -1
View File
@@ -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
+189
View File
@@ -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)
+22 -2
View File
@@ -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"
+71
View File
@@ -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
+8 -2
View File
@@ -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"
+19 -2
View File
@@ -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",)
+3 -2
View File
@@ -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 = "视频与探索内容"
+21 -2
View File
@@ -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)
+3 -2
View File
@@ -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 = "比赛运营"
+3 -2
View File
@@ -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 = "签到与通知"
+3 -2
View File
@@ -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"
+12 -1
View File
@@ -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)
+3 -2
View File
@@ -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"
+3 -2
View File
@@ -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 = "成长与资产"
+719
View File
@@ -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;
}
}
+8
View File
@@ -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; }
+1
View File
@@ -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() {
+26
View File
@@ -0,0 +1,26 @@
{% extends "admin/base.html" %}
{% load static %}
{% block title %}{{ title }} | 葫芦数学后台{% endblock %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'admin/css/hulumath_admin.css' %}">
{% endblock %}
{% block branding %}
<div class="hulu-brand">
<a href="{% url 'admin:index' %}" class="hulu-brand-mark">H</a>
<div>
<a href="{% url 'admin:index' %}" class="hulu-brand-name">葫芦数学</a>
<span>运营管理中心</span>
</div>
</div>
{% endblock %}
{% block nav-global %}
<div class="hulu-global-nav">
<span class="hulu-environment">{{ admin_environment|default:"运营后台" }}</span>
<a href="/" target="_blank" rel="noopener">查看网站</a>
</div>
{% endblock %}
+146
View File
@@ -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 %}
<div class="hulu-dashboard-head">
<div>
<span class="hulu-eyebrow">OPERATIONS OVERVIEW</span>
<h1>今天从哪里开始?</h1>
<p>管理用户、人生内容、比赛和视频,关键异常会集中显示在这里。</p>
</div>
<div class="hulu-head-actions">
<a class="hulu-button primary" href="{% url 'admin:content_contentitem_add' %}">发布内容</a>
<a class="hulu-button" href="{% url 'admin:accounts_invitecode_add' %}">生成邀请码</a>
</div>
</div>
<div class="hulu-metric-grid">
{% for metric in dashboard_metrics %}
<article class="hulu-metric {{ metric.tone }}">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.hint }}</small>
</article>
{% endfor %}
</div>
<div class="hulu-dashboard-layout">
<main class="hulu-modules">
<div class="hulu-section-title">
<div>
<span class="hulu-eyebrow">BUSINESS MODULES</span>
<h2>业务模块</h2>
</div>
<span>按日常运营顺序排列</span>
</div>
{% if app_list %}
<div class="hulu-app-grid">
{% for app in app_list %}
<section class="hulu-app-card module-{{ app.app_label }}">
<header>
<div class="hulu-app-icon">{{ app.name|slice:":1" }}</div>
<div>
<h3>
<a href="{{ app.app_url }}">{{ app.name }}</a>
</h3>
<span>{{ app.models|length }} 个管理入口</span>
</div>
</header>
<div class="hulu-model-list">
{% for model in app.models %}
<div class="hulu-model-row">
{% if model.admin_url %}
<a href="{{ model.admin_url }}">{{ model.name }}</a>
{% else %}
<span>{{ model.name }}</span>
{% endif %}
<div>
{% if model.add_url %}
<a class="hulu-add-link" href="{{ model.add_url }}">新增</a>
{% endif %}
{% if model.admin_url %}
<a class="hulu-view-link" href="{{ model.admin_url }}">管理</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
{% else %}
<div class="hulu-empty">当前账号没有后台管理权限。</div>
{% endif %}
</main>
<aside class="hulu-dashboard-side">
<section class="hulu-side-card hulu-todos">
<div class="hulu-section-title compact">
<div>
<span class="hulu-eyebrow">ATTENTION</span>
<h2>待处理</h2>
</div>
</div>
{% for alert in dashboard_alerts %}
<a class="hulu-alert-row" href="{{ alert.url }}">
<span>{{ alert.label }}</span>
<strong>{{ alert.value }}</strong>
</a>
{% endfor %}
</section>
<section class="hulu-side-card">
<div class="hulu-section-title compact">
<div>
<span class="hulu-eyebrow">QUICK LINKS</span>
<h2>常用操作</h2>
</div>
</div>
<div class="hulu-quick-links">
<a href="{% url 'admin:math_life_story_add' %}">创建数学人生</a>
<a href="{% url 'admin:contest_contest_add' %}">创建比赛</a>
<a href="{% url 'admin:contest_question_add' %}">录入题目</a>
<a href="{% url 'admin:latex_lab_latexcourse_add' %}">创建 LaTeX 课程</a>
<a href="{% url 'admin:accounts_user_changelist' %}">查询用户</a>
</div>
</section>
<section class="hulu-side-card">
<div class="hulu-section-title compact">
<div>
<span class="hulu-eyebrow">RECENT ACTIVITY</span>
<h2>最近操作</h2>
</div>
</div>
{% load log %}
{% get_admin_log 6 as admin_log for_user user %}
{% if admin_log %}
<div class="hulu-recent-list">
{% for entry in admin_log %}
<div class="hulu-recent-row">
<span class="hulu-action-dot {% if entry.is_addition %}add{% elif entry.is_change %}change{% else %}delete{% endif %}"></span>
<div>
{% if entry.is_deletion or not entry.get_admin_url %}
<strong>{{ entry.object_repr }}</strong>
{% else %}
<a href="{{ entry.get_admin_url }}">{{ entry.object_repr }}</a>
{% endif %}
<small>{{ entry.content_type }} · {{ entry.action_time|timesince }}前</small>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="hulu-muted">还没有后台操作记录。</p>
{% endif %}
</section>
</aside>
</div>
{% endblock %}
{% block sidebar %}{% endblock %}
+65
View File
@@ -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 %}
<div class="hulu-login-shell">
<section class="hulu-login-intro">
<span class="hulu-eyebrow">HULU MATH OPERATIONS</span>
<h1>管理数学人生宇宙</h1>
<p>发布内容、维护剧情、组织比赛,并查看真实用户的成长轨迹。</p>
<div class="hulu-login-points">
<span>用户与邀请码</span>
<span>剧情与 MathBTI</span>
<span>比赛与内容运营</span>
</div>
</section>
<section class="hulu-login-card">
<div>
<span class="hulu-eyebrow">STAFF SIGN IN</span>
<h2>后台登录</h2>
<p>仅限已授权的运营和管理员账号。</p>
</div>
{% if form.errors and not form.non_field_errors %}
<p class="errornote">用户名或密码不正确,请重新输入。</p>
{% endif %}
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
<p class="errornote">{{ error }}</p>
{% endfor %}
{% endif %}
<form action="{{ app_path }}" method="post" id="login-form">
{% csrf_token %}
<div class="form-row">
{{ form.username.errors }}
<label for="{{ form.username.id_for_label }}">用户名</label>
{{ form.username }}
</div>
<div class="form-row">
{{ form.password.errors }}
<label for="{{ form.password.id_for_label }}">密码</label>
{{ form.password }}
<input type="hidden" name="next" value="{{ next }}">
</div>
{% url 'admin_password_reset' as password_reset_url %}
{% if password_reset_url %}
<div class="password-reset-link">
<a href="{{ password_reset_url }}">忘记密码?</a>
</div>
{% endif %}
<div class="submit-row">
<input type="submit" value="进入运营后台">
</div>
</form>
<a class="hulu-back-site" href="/">← 返回葫芦数学网站</a>
</section>
</div>
{% endblock %}
+6 -1
View File
@@ -34,7 +34,12 @@
<main class="main">
<header class="topbar">
<p id="eyebrow">今日坐标 · 数学人生宇宙</p>
<div class="user-chip" id="user-chip"><span class="avatar"></span><span>游客</span></div>
<div class="topbar-actions">
<a id="admin-entry" class="admin-entry" href="/admin/" hidden>
<span>运营后台</span><b></b>
</a>
<div class="user-chip" id="user-chip"><span class="avatar"></span><span>游客</span></div>
</div>
</header>
<section class="view active" id="view-home">