@@ -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",)
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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",)
|
||||
|
||||
@@ -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 = "视频与探索内容"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = "比赛运营"
|
||||
|
||||
@@ -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 = "签到与通知"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "成长与资产"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -34,7 +34,12 @@
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<p id="eyebrow">今日坐标 · 数学人生宇宙</p>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user