2026/10/24 21:28:42

Django实战:随机塔罗抽牌、3D翻牌与CMS后台

Django实战:随机塔罗抽牌、3D翻牌与CMS后台 简介这是一套面向 Python 与 Django 初学者及小型内容站点开发者的塔罗主题实战项目以“随机塔罗牌生成器 基础 CMS”为核心点击“随机”按钮即可从22张塔罗牌中抽取一张详情页展示牌面图片、牌名、占星与炼金术属性、希伯来字母及象征释义并配有登陆/门户、关于页面与论文内容模块可作为练习模板继承、路由、静态资源组织与数据展示的参考案例。压缩包共281个文件约10.62MB包含53个py源码、25个html模板、10个css样式表以及95张jpg、27个gif、18个png等牌面与界面素材另有sqlite数据库及备份、说明文档目录按应用拆分便于对照阅读。项目已有1965人学习下载适合拿来拆解Django应用划分、CSS页面美化与图片资源管理也能在此基础上改造成个人占卜或内容展示站点。1. 朋友要一个抽牌页面我以为半小时能收工需求很朴素点一下出三张牌正逆位随机牌面和释义能在后台改。tarot_juicer 就是那次需求的成品形态——一个 Python Django 写的随机塔罗牌生成器外面套一套基础 Django CMS。前台负责洗牌、抽牌、渲染牌阵后台负责维护 78 张牌的编号、牌名、正位与逆位释义、牌面图。它适合两类人想拿一个真实小项目把 Django 的模型、视图、模板、静态文件、admin 全链路走一遍的练手者以及想在自家站点里塞个占卜小挂件、又不愿意为此引入一整套前端脚手架的开发者。抽牌逻辑和展示层是分开的改牌阵不用碰算法。2. 目录骨架与数据模型一张塔罗牌在库里长什么样拿到一个包我第一件事不是读 README是tree一遍根目录。目录规整的项目八成能直接跑起来目录里散着一堆test2.py、final_final.py的后面踩坑的概率大得多。tarot_juicer 属于前一种业务只有一个 app配置和业务切得干净。2.1 目录结构一个 app 装下全部业务tarot_juicer/ ├── manage.py ├── requirements.txt ├── tarot_juicer/ # 项目配置包 │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── cards/ # 唯一业务 app │ ├── models.py │ ├── views.py │ ├── urls.py │ ├── admin.py │ ├── migrations/ │ ├── management/commands/seed_cards.py │ ├── templates/cards/ │ │ ├── base.html │ │ ├── draw.html │ │ └── card_detail.html │ └── static/cards/ │ ├── css/main.css │ └── img/back.webp ├── media/cards/ # 后台上传的牌面运行时产物 └── staticfiles/ # collectstatic 产物部署用模板和静态文件都放在cards/内部而不是甩到项目根的templates/、static/下。这么放的好处是 app 自包含以后想把抽牌功能摘出来塞进另一个项目整个cards/拷走就能用。代价是模板查找顺序靠INSTALLED_APPS决定同名模板会互相覆盖——这个坑在第 5 章会细说。media/和staticfiles/的区别值得单独记一下media是用户这里是后台管理员上传的内容会随数据增长staticfiles是collectstatic把各个 app 下的static/收集到一处的产物部署时交给 Web 服务器直接吐。很多人部署后图片 404就是把这两个目录的职责搞混了。2.2 模型字段78 张牌需要哪些列# cards/models.py from django.db import models class TarotCard(models.Model): class Arcana(models.TextChoices): MAJOR major, 大阿卡纳 MINOR minor, 小阿卡纳 class Suit(models.TextChoices): WANDS wands, 权杖 CUPS cups, 圣杯 SWORDS swords, 宝剑 PENTACLES pentacles, 星币 NONE none, 无 number models.PositiveSmallIntegerField(编号, uniqueTrue) # 0-77愚者到星币十 name models.CharField(牌名, max_length64) arcana models.CharField(大类, max_length8, choicesArcana.choices, defaultArcana.MAJOR) suit models.CharField(花色, max_length16, choicesSuit.choices, defaultSuit.NONE) upright models.TextField(正位释义) reversed models.TextField(逆位释义, blankTrue) image models.ImageField(牌面, upload_tocards/, blankTrue) is_active models.BooleanField(启用, defaultTrue) class Meta: ordering [number] verbose_name 塔罗牌 verbose_name_plural 塔罗牌 def __str__(self): return f{self.number:02d} {self.name}number上加了uniqueTrue同时用Meta.ordering固定排序。这是个不起眼但很实用的设计抽牌算法里一旦需要「可复现」的随机同一个 seed 抽同一组牌牌的遍历顺序必须是稳定的否则数据库返回顺序一变结果就对不上。unique顺带把灌数据脚本的重复执行挡在了数据库层这条会在第 5 章变成一条具体的报错。arcana和suit用的是TextChoices而不是两张关联表。花色只有 5 个取值、大类只有 2 个建表除了多两次 join 没有任何收益。释义用TextField存长文比塞进JSONField好在后台编辑体验admin 直接给你一个多行文本框非技术同事也能改。提示ImageField依赖 Pillowrequirements.txt里必须写上否则makemigrations阶段就会报Cannot use ImageField because Pillow is not installed。2.3 后台就是那套 CMS# cards/admin.py from django.contrib import admin from django.utils.html import format_html from .models import TarotCard admin.register(TarotCard) class TarotCardAdmin(admin.ModelAdmin): list_display (number, name, arcana, suit, is_active, thumb) list_filter (arcana, suit, is_active) search_fields (name, upright, reversed) list_editable (is_active,) ordering (number,) list_per_page 30 admin.display(description缩略图) def thumb(self, obj): if not obj.image: return - return format_html(img src{} styleheight:48px;border-radius:4px, obj.image.url)list_editable让「启用」这一列能在列表页直接勾选78 张牌里想临时下架某几张不用一张张点进去。thumb用format_html拼 HTML 而不是字符串拼接是因为 Django 模板会自动转义直接返回裸字符串的话img会被当成文本显示出来。search_fields把释义也算进去找「哪张牌提到过旅行」这种模糊需求时很省事。2.4 一次性灌 78 张牌management command手点 78 次后台不现实项目里带了个自定义命令。核心是update_or_create而不是create# cards/management/commands/seed_cards.py from django.core.management.base import BaseCommand from cards.models import TarotCard # 实际文件里是完整的 78 条这里只留三条示意 CARDS [ {number: 0, name: 愚者, arcana: major, suit: none, upright: 新的开始放手一搏。, reversed: 鲁莽准备不足。}, {number: 1, name: 魔术师, arcana: major, suit: none, upright: 资源齐备主动出击。, reversed: 空有想法执行走样。}, {number: 22, name: 权杖首牌, arcana: minor, suit: wands, upright: 灵感落地行动力强。, reversed: 动力不足方向摇摆。}, ] class Command(BaseCommand): help 写入或更新 78 张基础塔罗牌数据 def add_arguments(self, parser): parser.add_argument(--force, actionstore_true, help覆盖已有释义) def handle(self, *args, **options): created updated 0 for item in CARDS: obj, is_created TarotCard.objects.update_or_create( numberitem[number], defaults{**item, is_active: True} if options[force] else {k: v for k, v in item.items() if k ! upright and k ! reversed}, ) created is_created updated (not is_created) self.stdout.write(self.style.SUCCESS(f新增 {created} 张更新 {updated} 张))update_or_create以number为查找键重复执行不会炸unique约束这对本地反复初始化特别友好。--force控制是否覆盖已经人工润色过的释义默认只补元数据牌名、花色、图片路径之外的字段手动改过的文案保住加--force才是全量覆盖。这个「默认保守、显式覆盖」的思路在数据初始化脚本里我基本都会带上。update_or_create不接受 kwargs 里混着pk或带__的查找表达式defaults里也别塞主键这是新手最容易翻的地方。3. 抽牌链路从 URL 到三张牌落到模板整条链路只有两个 URL、一个视图函数。Django 的 MTV 在这个项目里体现得特别干净Model 管数据Template 管展示View 只做一件事——把随机结果组装成模板能直接吃的结构。3.1 路由两个 URL 就够# tarot_juicer/urls.py from django.contrib import admin from django.urls import include, path from django.conf import settings from django.conf.urls.static import static urlpatterns [ path(admin/, admin.site.urls), path(, include(cards.urls)), ] if settings.DEBUG: # 只在开发期由 Django 自己吐 media上线交给 Nginx urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)# cards/urls.py from django.urls import path from . import views app_name cards urlpatterns [ path(, views.draw, namedraw), path(card/int:number/, views.card_detail, namedetail), ]app_name配name构成命名空间模板里写{% url cards:draw %}就能反向解析。好处是以后要给抽牌页换个路径比如从/挪到/tarot/模板一行都不用改。card/int:number/用编号而不是自增id做路径参数是为了让链接可读/card/22/比/card/47/直观也方便直接分享某张牌。注意static()那一行被if settings.DEBUG包着。这行只在开发时有用DEBUGFalse之后 Django 不再代理media请求图片必须由前置的 Web 服务器接管——部署后图片全裂九成是这里没配。3.2 视图random.sample 与正逆位# cards/views.py import random from django.shortcuts import render, get_object_or_404 from .models import TarotCard POSITIONS (过去, 现在, 未来) def draw(request): pool list(TarotCard.objects.filter(is_activeTrue)) # 先落成 list count min(len(POSITIONS), len(pool)) picked random.sample(pool, count) if count else [] cards [] for position, card in zip(POSITIONS, picked): is_reversed random.random() 0.5 cards.append({ position: position, obj: card, reversed: is_reversed, meaning: card.reversed if is_reversed else card.upright, }) return render(request, cards/draw.html, {cards: cards}) def card_detail(request, number): card get_object_or_404(TarotCard, numbernumber, is_activeTrue) return render(request, cards/card_detail.html, {card: card})几个关键点值得逐条过一遍random.sample而不是random.choices。前者保证一次抽出的三张牌互不重复后者是「有放回」抽样同一张牌可能连出三次。塔罗牌阵里抽到重复牌在语义上讲不通所以必须是sample。list(...)这一层转换不是多余。random.sample要求传入的pool支持len()和下标访问QuerySet恰好都支持表面上能跑通但每次下标访问都可能触发一次新的 SQL三张牌抽查下来数据库被打了十几次。先落成 list只查一次后面全在内存里操作。count min(len(POSITIONS), len(pool))是防御性写法。牌库刚建、还没灌数据的时候random.sample(pool, 3)会直接抛ValueError: Sample larger than population页面变 500。这里把张数压到实际可用数量配合模板里的{% empty %}分支空库时至少能给出「先去后台加牌」的提示而不是一屏调试信息。正逆位用random.random() 0.5独立判定和牌本身解耦。也就是说「抽到哪几张」和「哪几张逆位」是两次独立随机这符合常规玩法。想让逆位概率低一点比如 30%只改这一个阈值就行不用动抽牌逻辑。zip(POSITIONS, picked)把位置和牌绑定。zip的语义是取短的那一个前面已经用min压过长度所以不会出现「有牌没位置」的错位。提示如果要做「同一个链接每次刷新结果一样」的分享功能把random换成rnd random.Random(seed)seed从 query string 或 session 里取后面所有rnd.sample、rnd.random都用这个实例。这是第 6 章主要改造方向之一。3.3 模板把牌交给 CSS{# cards/templates/cards/draw.html #} {% extends cards/base.html %} {% load static %} {% block content %} section classspread {% for item in cards %} article classcard-slot {% if item.reversed %}is-reversed{% endif %} style--i: {{ forloop.counter0 }} div classcard-inner div classcard-face card-back img src{% static cards/img/back.webp %} alt牌背 /div div classcard-face card-front {% if item.obj.image %} img src{{ item.obj.image.url }} alt{{ item.obj.name }} {% endif %} h3{{ item.position }} · {{ item.obj.name }}/h3 p{{ item.meaning|linebreaksbr }}/p /div /div /article {% empty %} p classempty牌库还是空的先去后台加几张牌。/p {% endfor %} /section a classagain href{% url cards:draw %}重新洗牌/a {% endblock %}模板本身几乎没有逻辑只有三个位置在「传参」style--i: {{ forloop.counter0 }}把循环下标写成内联 CSS 变量第 4 章的动画就靠它做错峰延迟。用内联变量而不是给三张牌分别写.card-slot:nth-child(1)是因为牌阵张数以后可能从 3 改成 5写死 nth-child 就得跟着改样式。{% if item.reversed %}只往 DOM 上挂一个is-reversed类具体怎么表现图片转 180 度、还是加一层暗色蒙版全交给 CSS。视图里判断的是「语义」模板里输出的是「标记」样式在 CSS 里决定三层各管各的。{{ item.meaning|linebreaksbr }}处理释义里的换行。后台 textarea 里写的换行在 HTML 里会被折叠成空格加这个过滤器才能让「关键词/ 解读」这种分行排版的释义正常显示。{% empty %}分支配合前面的min()把空库场景兜住了。很多人写循环只想着有数据的情况真跑起来发现牌库没灌数据页面一片空白还查不出原因。4. CSS 牌阵3D 翻牌怎么翻才不穿帮塔罗牌这种视觉型项目CSS 的地位不亚于后端。翻牌动画看着玄乎拆开就是三行属性的组合perspective给父级建立透视preserve-3d让子元素保留三维空间backface-visibility: hidden让背面在转到一半之后消失。4.1 static 与 media 的配置边界# tarot_juicer/settings.py 片段 import os from pathlib import Path BASE_DIR Path(__file__).resolve().parent.parent STATIC_URL static/ STATICFILES_DIRS [BASE_DIR / static] # 项目级公共静态文件可为空 STATIC_ROOT BASE_DIR / staticfiles # collectstatic 输出目录 MEDIA_URL media/ MEDIA_ROOT BASE_DIR / media # 后台上传落地目录STATIC_URL和MEDIA_URL都以斜杠结尾、都不带前导斜杠这是 Django 的约定写错了模板里{% static %}拼出来的路径会多一个或漏一个/浏览器 404 但页面不报错排查起来纯靠肉眼。STATICFILES_DIRS和STATIC_ROOT不能指向同一个目录Django 启动时会直接抛ImproperlyConfigured这是新手常见的第一次翻车。app 内部的cards/static/cards/css/main.css不需要在STATICFILES_DIRS里登记Django 的AppDirectoriesFinder会自动找到。所以模板里写{% static cards/css/main.css %}——注意路径里那层cards/是 app 内的命名空间目录不是 app 名两者同名只是巧合改 app 名时这层目录可以不改。开发期runserver会自动服务 static改了 CSS 刷新就生效一旦DEBUGFalseDjango 立刻罢工必须collectstatic之后由 Web 服务器托管。这个切换点是最容易「本地好好的、上线样式全丢」的地方。4.2 翻牌动画三个属性加一段 keyframes/* cards/static/cards/css/main.css */ :root { --card-ratio: 1.65; --flip: 900ms; --bg: #14121c; --gold: #c9a227; } body { margin: 0; background: radial-gradient(circle at 50% 0%, #241d38 0%, var(--bg) 60%); color: #e8e3f3; font-family: system-ui, -apple-system, Segoe UI, sans-serif; } .spread { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; max-width: 900px; margin: 56px auto; padding: 0 16px; } .card-slot { aspect-ratio: 1 / var(--card-ratio); perspective: 1200px; /* 透视距离越小越夸张 */ } .card-inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; animation: flip-in var(--flip) cubic-bezier(.35, .1, .25, 1) both; animation-delay: calc(var(--i, 0) * 180ms); } keyframes flip-in { from { transform: rotateY(0deg); } to { transform: rotateY(180deg); } } .card-face { position: absolute; inset: 0; border-radius: 12px; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; box-shadow: 0 12px 30px rgba(0, 0, 0, .45); } .card-front { transform: rotateY(180deg); /* 正面预先转过去翻完正好朝向用户 */ background: #1f1b2e; border: 1px solid rgba(201, 162, 39, .35); padding: 12px; box-sizing: border-box; } .card-front img { width: 100%; display: block; border-radius: 8px; transition: transform 300ms ease; } /* 逆位只转牌面图不转整张牌文字仍然正着读 */ .is-reversed .card-front img { transform: rotate(180deg); }both这个animation-fill-mode是关键。没有它动画结束的瞬间元素会弹回rotateY(0)用户看到的是「翻开又盖回去」。both让动画结束后停在最后一帧也就是rotateY(180deg)的状态。animation-delay: calc(var(--i, 0) * 180ms)配合模板里的--i三张牌依次翻间隔 180 毫秒。变量有个0兜底万一哪张牌忘了传--i动画也不会整段失效。.card-front上那个transform: rotateY(180deg)一开始很容易漏。漏了的表现是牌翻过来了但背面朝外用户看到的是镜像的文字。因为正面在三维空间里和背面共面翻 180 度之后朝向用户的是背面的背面必须先给正面预转 180 度。逆位处理放在.is-reversed .card-front img上只转图片。如果把rotate(180deg)加在.card-inner或.card-slot上整张牌连内容带布局一起转标题和释义会变成倒着的——这个坑我在第 5 章单列了一条。perspective: 1200px放在父级.card-slot而不是.card-inner上。放在元素自身上透视原点会跟着元素走转动时产生的水波纹畸变特别诡异放在父级透视才像是「观察者在看」。4.3 牌阵布局与移动端降级media (max-width: 720px) { .spread { grid-template-columns: 1fr; gap: 16px; margin: 24px auto; max-width: 340px; } .card-slot { aspect-ratio: 1 / 1.4; /* 单列时略扁避免一屏塞不下一张 */ } } media (prefers-reduced-motion: reduce) { .card-inner { animation: none; transform: rotateY(180deg); /* 直接呈现翻好的状态 */ } }宽屏三列、窄屏单列靠minmax(0, 1fr)防止 grid 项被内容撑爆——1fr的默认最小值是auto牌面图一宽三列就会溢出容器。把最小宽度钉成0才是安全的。prefers-reduced-motion这个媒体查询不是可有可无的装饰。系统里开了「减少动态效果」的用户前庭功能敏感人群翻了 900 毫秒的三张牌会让人不适。降级方案不是把动画调快而是直接跳过动画、给出终态同时保留transform保证牌还是翻好的那一面。aspect-ratio的兼容性现在已经不用操心但如果要照顾老一点的浏览器退化方案是给.card-slot一个padding-top: 165%的占位盒子再用绝对定位把内容塞进去。我一般会两种都写先写aspect-ratio再写padding-top兜底。5. 避坑与排查五条踩过的记录这一节按「现象、原因、解决」记都是跑这个项目时真实撞过的照着对号入座能省掉不少时间。第一条后台上传的牌面前台全是裂图现象admin 里上传成功列表页缩略图也能看到但前台抽牌页的img src/media/cards/01.webp返回 404。原因MEDIA_URL/MEDIA_ROOT配了但urls.py里没挂static(settings.MEDIA_URL, ...)或者挂了但DEBUGFalse那行代码根本不生效而前置的 Web 服务器又没配media的 location。解决开发期确认if settings.DEBUG:那段存在上线时把media目录交给 Nginx 或对象存储Django 进程不负责吐文件。顺便检查文件权限MEDIA_ROOT目录对运行用户不可写时上传会静默失败但表单不报错。第二条一次抽三张抽出了两张一样的牌现象刷新十次里有两次出现重复牌用户一眼就看出来是假随机。原因视图里用了random.choices(pool, k3)或者对同一个QuerySet连续调两次random.choice甚至有人写了random.sample(TarotCard.objects.all(), 3)后又用filter重新取了一遍。解决统一成「先list()落内存再random.sample」。sample是「不放回」choices是「放回」塔罗牌阵必须用前者。另外别在sample之后再对结果做filter(is_activeTrue)——那等于先抽后过滤抽三张过滤掉两张页面就只剩一张牌了。第三条逆位牌的标题和释义跟着倒过来了现象牌翻过来之后牌面图是倒的正常但下面的牌名和解读文字也一起倒了手机上看着特别扭。原因is-reversed的旋转加在了.card-slot或.card-inner上整张牌 180 度转DOM 里所有内容跟着转。解决旋转只作用在.is-reversed .card-front img上。视图层的reversed字段表达的是「这张牌是逆位」不是「这个块要转」表现层自己决定转什么。另外backface-visibility在部分老版本 Safari 上需要-webkit-前缀不加会出现翻转过程中背面「透出」正面的重影。第四条改了 CSS 刷新没反应或者上线后样式全丢现象本地改main.css保存刷新样式纹丝不动或者DEBUGFalse部署后页面裸奔只有 HTML 没有样式。原因本地是浏览器缓存尤其是runserver返回的 304上线是没执行collectstatic或者STATIC_ROOT里是旧文件而 Web 服务器指到了错误的目录。还有一种隐蔽情况两个 app 下有同名static/cards/css/main.cssDjango 按INSTALLED_APPS顺序取第一个你以为改的是生效的那份。解决本地先强制刷新或者开 DevTools 的 Disable cache部署流程里把python manage.py collectstatic --noinput写成固定步骤app 内静态目录统一加一层命名空间static/cards/...杜绝跨 app 撞名。第五条灌数据脚本跑第二遍就报 IntegrityError现象python manage.py seed_cards第一次成功第二次直接抛UNIQUE constraint failed: cards_tarotcard.number。原因脚本里用的是TarotCard.objects.create(...)或者先get()再create()中间没有任何「已存在则更新」的分支。解决改成update_or_create(number..., defaults{...})。注意defaults里不要放主键、不要放带__的查找表达式update_or_create会把它们当普通字段处理然后报错。还有一个变体坑如果defaults里的字段包含auto_now每次跑都会刷新时间戳用--force之外的路径时留意一下。提示这五条里有四条的表现都发生在「页面层」但根因分别在配置、算法、样式作用域和数据脚本。排查时先看浏览器 Network 面板的状态码能直接把范围砍掉一半。6. 二次开发从「能抽」到「能分享、能扩展」这套代码留的扩展口比看上去多因为它把随机、渲染、数据三层切开了。想加点东西基本都能找到对应的单点去改不用动全局。最值得做的一个改造是可复现抽牌。现在每次刷新结果都变想做「把这个牌阵分享给朋友」就没法实现。做法是把种子从 URL 里读出来塞进一个独立的Random实例# cards/views.py 改造片段 import random from django.shortcuts import render from .models import TarotCard POSITIONS (过去, 现在, 未来) def draw(request): seed request.GET.get(seed) rnd random.Random(seed) if seed else random.Random() pool list(TarotCard.objects.filter(is_activeTrue)) count min(len(POSITIONS), len(pool)) picked rnd.sample(pool, count) if count else [] cards [] for position, card in zip(POSITIONS, picked): is_reversed rnd.random() 0.5 cards.append({ position: position, obj: card, reversed: is_reversed, meaning: card.reversed if is_reversed else card.upright, }) return render(request, cards/draw.html, { cards: cards, seed: seed or rnd.random(), # 没带种子时回填一个供「复制链接」用 })改完之后/?seedabc123无论谁打开、打开多少次抽到的三张牌和正逆位都一样。验证方法很直接本地起服务同一个 seed 刷五次肉眼比对再写个TestCase断言两次渲染的 context 里牌名列表相等。这类「只有随机」的逻辑最适合补测试因为它不依赖数据库状态之外的任何东西。另外两条改造路线可以按需选一是加牌阵把POSITIONS抽成SPREADS {three: (过去, 现在, 未来), cross: (...)}这种字典URL 加个slug:spread参数视图里按 key 取模板不用改二是把抽牌结果做成 JSON 接口给小程序或前端用复用同一个draw里的组装逻辑抽出_pick_cards(seed)函数视图和接口各取所需。加接口时记得把 CSRF 和限流补上抽牌是纯读操作用GET加缓存头就够别做成POST。改造点涉及文件验证方式可复现抽牌cards/views.py、draw.html同 seed 多次请求比对牌名新增牌阵views.py、urls.py、模板切 URL 看张数与位置文案JSON 接口views.py、urls.pycurl 看返回结构牌面换 WebPadmin.py上传、模板看 Network 里的 content-type最后说个习惯问题。我最早做这类项目习惯在视图里直接render一坨 context 完事后来接手的人想复用抽牌逻辑只能整段复制。从那以后我每次写带随机或带业务规则的视图都强制走一遍「把纯逻辑抽成不依赖 request 的函数」这一步哪怕只有十行。这个项目里的_pick_cards(seed)就该这么抽抽完测试好写接口也好加。希望帮到你。本文还有配套的精品资源点击获取