├── .gitattributes ├── .gitignore ├── README.md ├── album ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── db.sqlite3 ├── manage.py ├── md ├── 10-前言.md ├── 100-云存储.md ├── 110-部署网站.md ├── 120-无限滚动.md ├── 130-结语.md ├── 20-搭建开发环境.md ├── 30-你好世界.md ├── 40-数据存储.md ├── 50-MTV模式.md ├── 60-模态与动画.md ├── 70-登录与登出.md ├── 80-批量上传图片.md └── 90-分页.md ├── media ├── photo │ ├── 20210722 │ │ └── coffee.jpg │ ├── 20210723 │ │ ├── italy-6349105_640.jpg │ │ ├── ortahisar-5678553_1280.jpg │ │ ├── portrait-5601950_1280.jpg │ │ ├── sea-6406047_640.jpg │ │ ├── sup-6421284_640.jpg │ │ ├── versailles-6469580_640.jpg │ │ ├── woman-6373424_1280.jpg │ │ └── woman-6466382_640.jpg │ └── 20210726 │ │ ├── carousel-6402074_640.jpg │ │ ├── children-moc-chau-2099536_1280.jpg │ │ ├── desert-5720527_1280.jpg │ │ ├── engineer-4922781_1280.jpg │ │ ├── eye-6399571_1280.jpg │ │ ├── fashion-6251535_640.jpg │ │ ├── flower-4774929_1280.jpg │ │ ├── mountain-6241333_1280.jpg │ │ ├── palmtrees-6388901_1280.jpg │ │ └── wheat-6329586_640.jpg └── repo │ ├── readme-1.gif │ ├── readme-2.gif │ └── readme-3.gif ├── photo ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_photo_options.py │ └── __init__.py ├── models.py ├── tests.py ├── urls.py └── views.py ├── requirements.txt ├── static ├── bounds.js └── hover.css └── templates ├── base.html ├── footer.html ├── header.html └── photo ├── endless_list.html ├── list.html └── oss_list.html /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js linguist-language=python 2 | *.css linguist-language=python 3 | *.html linguist-language=python -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | 5 | .env 6 | .venv 7 | env/ 8 | venv/ 9 | ENV/ 10 | env.bak/ 11 | venv.bak/ 12 | 13 | .vscode/ 14 | 15 | md/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/badge/python-3.8.10-orange.svg)](https://www.python.org/downloads/release/python-370/) 2 | [![](https://img.shields.io/badge/django-3.2.5-green.svg)](https://docs.djangoproject.com/en/2.1/releases/2.1/) 3 | [![](https://img.shields.io/badge/bootstrap-5.0.2-blue.svg)](https://getbootstrap.com/docs/4.1/getting-started/introduction/) 4 | [![](https://img.shields.io/badge/license-CC_BY_NC_4.0-000000.svg)](https://creativecommons.org/licenses/by-nc/4.0/) 5 | 6 | # Django搭建网络相册教程 7 | 8 | 这是面向新人的**Django搭建网络相册教程**。 9 | 10 | **教程为零基础的小白准备,目的是快速搭建一个相册网站。** 11 | 12 | 教程传送门: 13 | 14 | - [GitHub](/md) 15 | 16 | > 文章位于 `/md` 目录中。 17 | 18 | ## 什么是 Django 19 | 20 | **Django** 是一个由 **Python** 写成、非常流行且重量级的开源 Web 应用框架。你可以用它以更高的效率、更少的代码,轻松搭建一个高性能的网站。 21 | 22 | 如果你以前从未接触过 web 开发,并且想快速上线自己的个性化网站,Django 是你的绝佳选择。 23 | 24 | 在本教程中,你会见识到 Django 如何用非常少量、简单易懂的代码,完成功能强大的网站。 25 | 26 | ## 教程特点 27 | 28 | - 零基础、免费、中文 29 | - 基于 Python 3.8.10、Django 3.2.5 和 Bootstrap 5.0.2 30 | - 完全开源的文章和代码 31 | 32 | ## 适合人群 33 | 34 | - 拥有一台能开机的电脑 35 | - 具有基础的python编程知识 36 | - 每天能抽出一个小时学习 37 | 38 | **后端知识**具有 `Python` 和 `Django` 基础就足够了。 39 | 40 | 如果本教程你看着非常吃力,那么可以先看看我的 Django 博客入门教程: 41 | 42 | - [博客传送门](https://www.dusaiphoto.com/article/2/) 43 | - [GitHub传送门](https://github.com/stacklens/django_blog_tutorial/tree/master/md) 44 | 45 | > 微信公众号也同步更新,搜“杜赛说编程”即可。 46 | 47 | **前端知识**具有 `html/css/javascript` 基础即可,教程中不会涉及高深的前端知识。 48 | 49 | 不要犹豫,现在立刻开始Django的学习吧! 50 | 51 | ## 教程快照 52 | 53 | ![](/media/repo/readme-1.gif) 54 | 55 | ![](/media/repo/readme-2.gif) 56 | 57 | ![](/media/repo/readme-3.gif) 58 | 59 | ## 知识点 60 | 61 | 你将在本教程中学到的知识: 62 | 63 | - 搭建开发环境 64 | - Django 代码结构 65 | - 数据存储 66 | - MTV 模式 67 | - 模态与动画 68 | - 登入与登出 69 | - 批量上传图片 70 | - 分页 71 | - 云存储 72 | - 部署 73 | 74 | 共十个章节,都是浓缩的知识点,勤奋的你只需要奋斗十个晚上就足够看完了。 75 | 76 | ## 资源列表 77 | 78 | 本教程的代码托管在 GitHub:[Django-album-tutorial](https://github.com/stacklens/django-album-tutorial) 79 | 80 | Django 的官方网站:[Django](https://www.djangoproject.com/) 81 | 82 | 项目开发完毕后使用 Git/GitHub 分布式管理:[Windows环境下使用Git和GitHub](https://www.dusaiphoto.com/article/article-detail/13/) 83 | 84 | ## 遇到困难时怎么办 85 | 86 | - 认真检查代码拼写、缩进是否正确。一个标点符号的错误可能会导致难以发现的问题 87 | - 较简单的问题直接询问百度;若无法得到满意的答案请尝试 Google 以英文关键字搜索。要坚信全世界这么多学习 Django 的人,你遇到的问题别人早就遇到过了 88 | - [Django官方网站](https://www.djangoproject.com/)是最权威的学习文档,英语不佳的同学,要有耐心仔细阅读 89 | - 在本教程下留言,博主会尽量帮忙解决;也可以私信我:dusaiphoto@foxmail.com 90 | - 实在无法处理的问题,可以暂时跳过。待到技术水平上升台阶,再回头来解决问题 91 | - 若以上办法均不能解决你的问题,请在[StackOverflow](https://stackoverflow.com/)等技术网站上求助,那里有海量的热心程序员在等着你的问题 92 | 93 | ## 关于版本 94 | 95 | 本教程基于 Python 3.8.10、Django 3.2.5 和 Bootstrap 5.0.2。推荐读者采用完全一致的版本,以避免不必要的兼容问题。于 Win 10 系统开发。用 Mac 或 Linux 也 OK。 96 | 97 | > 特别要注意的是,教程后期关于对象存储的章节,相关的库目前为止(2021.07.29)仅支持到 Python 3.8 。使用 Python 3.9 可能会有潜在的 bug。 98 | 99 | ## 代码使用说明 100 | 101 | 确认你的电脑已经正确安装 Python。 102 | 103 | 下载项目后,在命令行中进入项目目录,并创建**虚拟环境**: 104 | 105 | ```bash 106 | python -m venv env 107 | ``` 108 | 109 | 运行**虚拟环境**(Windows环境): 110 | 111 | ```bash 112 | env\Scripts\activate.bat 113 | ``` 114 | 115 | 或(Linux环境): 116 | 117 | ```bash 118 | source env/bin/activate 119 | ``` 120 | 121 | 自动安装所有依赖项: 122 | 123 | ```bash 124 | pip install -r requirements.txt 125 | ``` 126 | 127 | 然后进行数据迁移: 128 | 129 | ```bash 130 | python manage.py migrate 131 | ``` 132 | 133 | 最后运行测试服务器: 134 | 135 | ```bash 136 | python manage.py runserver 137 | ``` 138 | 139 | 项目就运行起来了。 140 | 141 | 管理员账号:`dusai` 密码:`admin123456` 142 | 143 | 如果你想清除所有数据及媒体文件,将它们直接删除,并运行: 144 | 145 | ```bash 146 | python manage.py createsuperuser 147 | ``` 148 | 149 | 即可重新创建管理员账号。 150 | 151 | ## 开始你的表演 152 | 153 | 说了这么多,相信你已经迫不及待了。让我们赶紧开始旅程吧! 154 | 155 | ## 社区 156 | 157 | **一个人的学习是孤单的。欢迎扫码 Django 交流QQ群(107143175)、博主公众号、TG群组,和大家一起进步吧!** 158 | 159 | ![](https://blog.dusaiphoto.com/QR-0608.jpg) 160 | 161 | ## 许可协议 162 | 163 | 本教程(包括且不限于文章、代码、图片等内容)遵守 **署名-非商业性使用 4.0 国际 (CC BY-NC 4.0) 协议**。协议内容如下。 164 | 165 | **您可以自由地:** 166 | 167 | - **共享** — 在任何媒介以任何形式复制、发行本作品。 168 | - **演绎** — 修改、转换或以本作品为基础进行创作。 169 | 170 | 只要你遵守许可协议条款,许可人就无法收回你的这些权利。 171 | 172 | **惟须遵守下列条件:** 173 | 174 | - **署名** — 您必须给出**适当的署名**,提供指向本许可协议的链接,同时标明是否(对原始作品)作了修改。您可以用任何合理的方式来署名,但是不得以任何方式暗示许可人为您或您的使用背书。 175 | - **非商业性使用** — 您不得将本作品用于**商业目的**。 176 | 177 | - **没有附加限制** — 您不得适用法律术语或者技术措施从而限制其他人做许可协议允许的事情。 178 | 179 | > 适当的署名:您必须提供创作者和署名者的姓名或名称、版权标识、许可协议标识、免责标识和作品链接。 180 | > 181 | > 商业目的:主要目的为获得商业优势或金钱回报。 -------------------------------------------------------------------------------- /album/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/album/__init__.py -------------------------------------------------------------------------------- /album/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for album project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'album.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /album/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for album project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-totn20y*cv_h4qmoippn&dlu!^_6b)(&h_1$h0tav38^w=%2tc' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ['*'] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 41 | 'photo', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'album.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [BASE_DIR / 'templates'], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = 'album.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': 'django.db.backends.sqlite3', 81 | 'NAME': BASE_DIR / 'db.sqlite3', 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 92 | }, 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 107 | 108 | LANGUAGE_CODE = 'en-us' 109 | 110 | TIME_ZONE = 'UTC' 111 | 112 | USE_I18N = True 113 | 114 | USE_L10N = True 115 | 116 | USE_TZ = True 117 | 118 | 119 | # Static files (CSS, JavaScript, Images) 120 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 121 | 122 | STATIC_URL = '/static/' 123 | STATICFILES_DIRS = ( 124 | BASE_DIR / 'static', 125 | ) 126 | # 静态文件收集目录 127 | STATIC_ROOT = BASE_DIR / 'collected_static' 128 | 129 | # Default primary key field type 130 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 131 | 132 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 133 | 134 | 135 | MEDIA_URL = '/media/' 136 | MEDIA_ROOT = BASE_DIR / 'media' -------------------------------------------------------------------------------- /album/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | from photo.views import home 4 | 5 | 6 | from django.conf import settings 7 | from django.conf.urls.static import static 8 | 9 | urlpatterns = [ 10 | path('admin/', admin.site.urls), 11 | path('photo/', include('photo.urls', namespace='photo')), 12 | path('', home, name='home'), 13 | ] 14 | 15 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 16 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) -------------------------------------------------------------------------------- /album/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for album project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'album.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/db.sqlite3 -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'album.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /md/10-前言.md: -------------------------------------------------------------------------------- 1 | **这是面向新手的用 Django 开发网络相册的教程**。 2 | 3 | 教程基于 **Django 3**。 4 | 5 | ## 什么是 Django 6 | 7 | **Django** 是一个由 **Python** 写成、非常流行且重量级的开源 Web 应用框架。你可以用它以更高的效率、更少的代码,轻松搭建一个高性能的网站。 8 | 9 | 如果你以前从未接触过 web 开发,并且想快速上线自己的个性化网站,Django 是你的绝佳选择。 10 | 11 | 在本教程中,你会见识到 Django 如何用非常少量、简单易懂的代码,完成功能强大的网站。 12 | 13 | ## 教程特点 14 | 15 | - 零基础、免费、中文 16 | - 基于 Python 3.8.10、Django 3.2.5 和 Bootstrap 5.0.2 17 | - 完全开源的文章和代码 18 | 19 | ## 适合人群 20 | 21 | - 拥有一台能开机的电脑 22 | - 具有基础的python编程知识 23 | - 每天能抽出一个小时学习 24 | 25 | **后端知识**具有 `Python` 语法基础就足够了。 26 | 27 | 如果本教程你看着非常吃力,那么可以先看看我的 Django 博客入门教程: 28 | 29 | - [博客传送门](https://www.dusaiphoto.com/article/2/) 30 | - [GitHub传送门](https://github.com/stacklens/django_blog_tutorial/tree/master/md) 31 | 32 | > 微信公众号也同步更新,搜“杜赛说编程”即可。 33 | 34 | **前端知识**具有 `html/css/javascript` 语法基础即可,教程中不会涉及高深的前端知识。 35 | 36 | 不要犹豫,现在立刻开始Django的学习吧! 37 | 38 | ## 教程快照 39 | 40 | ![](https://blog.dusaiphoto.com/dj-album-10-1.jpg) 41 | 42 | ![](https://blog.dusaiphoto.com/dj-album-10-2.jpg) 43 | 44 | ![](https://blog.dusaiphoto.com/dj-album-10-3.jpg) 45 | 46 | ## 知识点 47 | 48 | 你将在本教程中学到的知识: 49 | 50 | - 搭建开发环境 51 | - Django 代码结构 52 | - 数据存储 53 | - MTV 模式 54 | - 模态与动画 55 | - 登入与登出 56 | - 批量上传图片 57 | - 分页 58 | - 云存储 59 | - 部署 60 | 61 | 篇幅不长,十个章节都是浓缩的知识点,勤奋的你只需要奋斗十个晚上就足够看完了。 62 | 63 | ## 资源列表 64 | 65 | 本教程的代码托管在 GitHub:[Django-album-tutorial](https://github.com/stacklens/django-album-tutorial) 66 | 67 | Django 的官方网站:[Django](https://www.djangoproject.com/) 68 | 69 | 项目开发完毕后使用 Git/GitHub 分布式管理:[Windows环境下使用Git和GitHub](https://www.dusaiphoto.com/article/article-detail/13/) 70 | 71 | ## 遇到困难时怎么办 72 | 73 | - 认真检查代码拼写、缩进是否正确。一个标点符号的错误可能会导致难以发现的问题 74 | - 较简单的问题直接询问百度;若无法得到满意的答案请尝试 Google 以英文关键字搜索。要坚信全世界这么多学习 Django 的人,你遇到的问题别人早就遇到过了 75 | - [Django官方网站](https://www.djangoproject.com/)是最权威的学习文档,英语不佳的同学,要有耐心仔细阅读 76 | - 在本教程下留言,博主会尽量帮忙解决;也可以私信我:dusaiphoto@foxmail.com 77 | - 实在无法处理的问题,可以暂时跳过。待到技术水平上升台阶,再回头来解决问题 78 | - 若以上办法均不能解决你的问题,请在[StackOverflow](https://stackoverflow.com/)等技术网站上求助,那里有海量的热心程序员在等着你的问题 79 | 80 | ## 关于版本 81 | 82 | 本教程基于 Python 3.8.10、Django 3.2.5 和 Bootstrap 5.0.2。推荐读者采用完全一致的版本,以避免不必要的兼容问题。 83 | 84 | 教程于 Win 10 系统开发。用 Mac 或 Linux 也 OK。 85 | 86 | > 特别要注意的是,教程后期关于云存储的章节,相关的库目前为止(2021.07.29)仅支持到 Python 3.8 。使用 Python 3.9 可能会有潜在的 bug。 87 | 88 | ## 开始你的表演 89 | 90 | 说了这么多,相信你已经迫不及待了。让我们赶紧开始旅程吧! 91 | 92 | ## 社区 93 | 94 | **一个人的学习是孤单的。欢迎扫码 Django 交流QQ群(107143175)、博主公众号、TG群组,和大家一起进步吧!** 95 | 96 | ![](https://blog.dusaiphoto.com/QR-0608.jpg) 97 | 98 | ## 许可协议 99 | 100 | 本教程(包括且不限于文章、代码、图片等内容)遵守 **署名-非商业性使用 4.0 国际 (CC BY-NC 4.0) 协议**。协议内容如下。 101 | 102 | **您可以自由地:** 103 | 104 | - **共享** — 在任何媒介以任何形式复制、发行本作品。 105 | - **演绎** — 修改、转换或以本作品为基础进行创作。 106 | 107 | 只要你遵守许可协议条款,许可人就无法收回你的这些权利。 108 | 109 | **惟须遵守下列条件:** 110 | 111 | - **署名** — 您必须给出**适当的署名**,提供指向本许可协议的链接,同时标明是否(对原始作品)作了修改。您可以用任何合理的方式来署名,但是不得以任何方式暗示许可人为您或您的使用背书。 112 | - **非商业性使用** — 您不得将本作品用于**商业目的**。 113 | 114 | - **没有附加限制** — 您不得适用法律术语或者技术措施从而限制其他人做许可协议允许的事情。 115 | 116 | > 适当的署名:您必须提供创作者和署名者的姓名或名称、版权标识、许可协议标识、免责标识和作品链接。 117 | > 118 | > 商业目的:主要目的为获得商业优势或金钱回报。 119 | -------------------------------------------------------------------------------- /md/100-云存储.md: -------------------------------------------------------------------------------- 1 | 经过几章节的开发,项目的核心功能都具备了,但最头疼的带宽问题依然没解决。作为一个草根网站,服务器的带宽是很宝贵的,那小水龙头经不起海量高清图片的折腾。 2 | 3 | 解决方案不只一种,比如采用CDN加速、负载均衡、对象存储等。 4 | 5 | 本章将采用**对象存储**的手段解决服务器带宽不足的问题。 6 | 7 | ## 什么是对象存储 8 | 9 | **对象存储OSS**(Object Storage Service)是一种海量、安全、低成本、高持久的云存储服务,你可以将其作为移动应用、大型网站、图片分享或热点音视频的主要存储方式。通俗点讲就类似云端硬盘,它的带宽资源充足、数据丢失的可能性几乎为零。并且对于小流量的网站来说,OSS 所需的费用开销非常少。 10 | 11 | > 笔者自己博客用的 OSS ,每月支出大概两三毛钱。 12 | 13 | 基本上所有的云服务商都提供 OSS 服务,比如阿里云、腾讯云、百度云,也有专门做 OSS 起家的七牛云、又拍云等等。 14 | 15 | 利益相关:笔者自己用的阿里云全家桶(包括后续的部署),所以本章会以**阿里云OSS**作为案例讲解。新用户通过此[阿里云OSS推广链接](https://www.aliyun.com/product/oss?userCode=m3bbolgr)注册有折扣和现金券,比较划算。 16 | 17 | > 你也可以根据喜好选择其他服务商,原理都是差不多的。 18 | 19 | 下面从 OSS 的设置开始讲起。 20 | 21 | ## 设置OSS 22 | 23 | 首先打开[OSS开通页面](https://www.aliyun.com/product/oss?userCode=m3bbolgr)。注册好阿里云账号后,点击下图中的“立即开通”: 24 | 25 | ![](https://blog.dusaiphoto.com/dj-album-100-1.png) 26 | 27 | > 阿里云 OSS 是先使用后付费的。如果你只是试用,不考虑续租,那就等同于零费用。 28 | 29 | 开通完成后就进入了 OSS 管理界面。 30 | 31 | 阿里云 OSS 用 Bucket 来存放具体的文件。Bucket 可以理解为一个空间,或者一块专门的区域。 32 | 33 | 点击“创建 Bucket”: 34 | 35 | ![](https://blog.dusaiphoto.com/dj-album-100-2.png) 36 | 37 | 来到创建页面。 38 | 39 | 记录下页面中的 `Bucket名称` 和 `Endpoint` 的值,后续会用到: 40 | 41 | ![](https://blog.dusaiphoto.com/dj-album-100-3.png) 42 | 43 | **读写权限**设置为**公共读**,因为相册允许匿名用户访问: 44 | 45 | ![](https://blog.dusaiphoto.com/dj-album-100-4.png) 46 | 47 | 其他的选项就按照图片里来,或者按照喜好选择了。 48 | 49 | 点击“确定”后,Bucket 就创建好了。 50 | 51 | 现在就可以在 Bucket 里上传文件了: 52 | 53 | ![](https://blog.dusaiphoto.com/dj-album-100-5.png) 54 | 55 | 我们随便上传些测试图片。 56 | 57 | 传完之后就显示在文件管理中了: 58 | 59 | ![](https://blog.dusaiphoto.com/dj-album-100-6.png) 60 | 61 | 最后一步。 62 | 63 | 虽然 Bucket 的权限设置为公共读了,但是操作 Bucket 依然需要对用户身份进行验证。 64 | 65 | 因此点击导航栏右上角的头像,再点击“AccessKey管理”新建管理员 ID 和 Secret。 66 | 67 | ![](https://blog.dusaiphoto.com/dj-album-100-7.png) 68 | 69 | 进入后,页面可能会提醒你为了安全考虑,尽量用**子账户**创建 ID 和 Secret。按照它的提示操作即可。 70 | 71 | 顺利创建好 `AccessKey ID` 和 `AccessKey Secret` 后,别忘了给子账户打开操作 OSS 的权限。 72 | 73 | 搞定后就可以继续正式写代码了。 74 | 75 | ## 后端代码 76 | 77 | 阿里云给 Python 程序员提供 OSS 的软件开发工具包(SDK),封装好了所有常规操作,非常方便。 78 | 79 | 在虚拟环境中安装此 SDK: 80 | 81 | ```python 82 | (env)> pip install oss2==2.15.0 -i https://pypi.tuna.tsinghua.edu.cn/simple 83 | ``` 84 | 85 | > 笔者写作此文时(2021.08.13)此 SDK 仅对 Python 3.8 以下版本提供支持。如果你的 Python 高于 3.8,那么记得查看官方的兼容性更新。 86 | 87 | 安装成功后,下一步就是在 `views.py` 中链接到 Bucket。 88 | 89 | 在视图文件头部写入: 90 | 91 | ```python 92 | # /photo/views.py 93 | 94 | ... 95 | 96 | import oss2 97 | 98 | # 填入阿里云账号的 99 | auth = oss2.Auth('LTA...fhK', 'zVE...6NC') 100 | # 填入 OSS 的 <域名> 和 101 | bucket = oss2.Bucket(auth, 'http://oss-cn-beijing.aliyuncs.com', 'dusai-test') 102 | ``` 103 | 104 | 这里需要填入四个东西:AccessKey ID 、AccessKey Secret、 Endpoint 和 Bucket 名。 105 | 106 | 接下来的步骤稍微不太好理解,看仔细了。 107 | 108 | 虽然 SDK 中提供了操作 Bucket 的对象 `ObjectIteratorV2` ,但是此对象提供的功能太少,不能直接用到我们的相册项目中。 109 | 110 | 所以将其作为父类,新建一个 `ObjIterator` 类: 111 | 112 | ```python 113 | # /photo/views.py 114 | 115 | ... 116 | 117 | class ObjIterator(oss2.ObjectIteratorV2): 118 | # 初始化时立即抓取图片数据 119 | def __init__(self, bucket): 120 | super().__init__(bucket) 121 | self.fetch_with_retry() 122 | 123 | # 分页要求实现__len__ 124 | def __len__(self): 125 | return len(self.entries) 126 | 127 | # 分页要求实现__getitem__ 128 | def __getitem__(self, key): 129 | return self.entries[key] 130 | 131 | # 此方法从云端抓取文件数据 132 | # 然后将数据赋值给 self.entries 133 | def _fetch(self): 134 | result = self.bucket.list_objects_v2(prefix=self.prefix, 135 | delimiter=self.delimiter, 136 | continuation_token=self.next_marker, 137 | start_after=self.start_after, 138 | fetch_owner=self.fetch_owner, 139 | encoding_type=self.encoding_type, 140 | max_keys=self.max_keys, 141 | headers=self.headers) 142 | self.entries = result.object_list + [oss2.models.SimplifiedObjectInfo(prefix, None, None, None, None, None) 143 | for prefix in result.prefix_list] 144 | # 让图片以上传时间倒序 145 | self.entries.sort(key=lambda obj: -obj.last_modified) 146 | 147 | return result.is_truncated, result.next_continuation_token 148 | ``` 149 | 150 | 让我们拆解上面的代码: 151 | 152 | - 通过阅读源码可以发现,`ObjectIteratorV2` 中的文件数据存储在 `self.entries` 属性中。由于原 `ObjectIteratorV2` 仅在启动迭代时才会从云端获取并填充数据到 `self.entries` ,这不符合本项目的使用需求。因此覆写了 `__init__()`,让其在实例化阶段就立即获取数据。 153 | - 为了尽量减少对旧代码的改动,我们想让这个 OSS 类也能够使用 Django 的分页器。由于分页器要求对象必须实现计数和取值,因此增加了 `__len__()` 和 `__getitem__()` 方法,让计数和取值功能与 `self.entries` 关联起来。 154 | - 原本父类中的 `_fetch()` 方法,用于对文件数据进行预处理的。里面的一大段全是从父类源码抄过来的,唯一改动的只有 `self.entries.sort(...)` 这一段。因为父类中是以文件名进行排序的,为了更符合相册的直觉,修改为以上传时间的倒序排序。 155 | 156 | 大功告成了,接下来的步骤就非常轻松愉快了。 157 | 158 | > 2021/09/16更新:上面的代码用继承加协议的方式,实现了一个有限长度的容器。但这种实现方式在本项目中没太有必要(并且经博主测试还有小bug),因为列表推导式 `[i for i in oss2.ObjectIteratorV2(bucket)]` 就实现了相同的效果,并且代码更简单。 159 | 160 | 咱们继续往下。 161 | 162 | 新建视图函数 `oss_home()` ,将旧的 `home()` 函数中的代码抄过来,并做如下改动: 163 | 164 | ```python 165 | # /photo/views.py 166 | 167 | ... 168 | 169 | def oss_home(request): 170 | photos = ObjIterator(bucket) 171 | paginator = Paginator(photos, 6) 172 | page_number = request.GET.get('page') 173 | paged_photos = paginator.get_page(page_number) 174 | context = {'photos': paged_photos} 175 | 176 | # 省略登入登出的POST请求代码 177 | # ... 178 | 179 | return render(request, 'photo/oss_list.html', context) 180 | ``` 181 | 182 | 其实就改动了两行: 183 | 184 | - 第一行,数据集合不再来源于模型类了,而是刚写的 OSS 类 `ObjIterator` 。 185 | - 最后一行,模板文件变为 `oss_list.html` 了。(此文件暂时还没写) 186 | 187 | 你看,经过前面的努力,对 OSS 的操作变得跟 Django 内置的模型一样的简单,辛苦没有白费啊。 188 | 189 | 最后记得给这个新视图配置 url 路由: 190 | 191 | ```python 192 | # /photo/urls.py 193 | 194 | ... 195 | 196 | from photo.views import home, upload, oss_home 197 | 198 | urlpatterns = [ 199 | ... 200 | path('oss-home/', oss_home, name='oss_home'), 201 | ] 202 | ``` 203 | 204 | 接下来写 `oss_list.html` 模板。 205 | 206 | ## 前端代码 207 | 208 | 线上环境和开发环境有个很大的不同,就是线上环境具有严重的延迟。因此有些 Bug 只有部署到线上才能够被发觉。 209 | 210 | 究竟是什么 Bug 卖个关子,先在 `base.html` 中引入一个新的插件 `jquery.js` 备用: 211 | 212 | ```html 213 | 214 | 215 | 216 | 217 | 218 | ... 219 | 220 | 221 | ... 222 | 223 | 224 | 225 | 226 | 227 | ... 228 | 229 | 230 | ``` 231 | 232 | 然后新建 `/templates/photo/oss_list.html` 模板。 233 | 234 | 将老的 `list.html` 代码抄过来,并修改如下的部分代码: 235 | 236 | ```html 237 | 238 | 239 | ... 240 | 241 |
242 | {% for photo in photos %} 243 | 244 |
245 | 254 |
255 | {% endfor %} 256 |
257 | 258 | ... 259 | 260 | {% for photo in photos %} 261 | 270 | {% endfor %} 271 | ``` 272 | 273 | 改动如下: 274 | 275 | - 修改了所有图片展示的 `` 的标签的 `src` (也就是路径),变成了阿里云 OSS 中的文件的路径。**记得将 `src` 修改为你自己的 OSS 路径。** 276 | - 在卡片元素里增加了 `id="cards"` 和 `class="... grid-item"` 属性,为解决 Bug 备用。 277 | 278 | 接下来就正式讲讲这个 Bug 了。由于相册的排版采用了 `masonry.js` 瀑布流插件,此插件是以页面**加载时**图片的尺寸为依据进行排版的。但问题是高清图片的尺寸都很大,插件在估算排版时图片都还没加载完成,导致它不能够正确得知图片的尺寸,最终造成图片全堆叠在一起的显示错误。 279 | 280 | > 开发时此问题未出现是因为图片加载无延迟。 281 | 282 | 解决的方案是利用 `jquery.js` 脚本,确保在所有图片都加载完毕后,再一次触发 `masonry.js` 插件的排版估算,像这样: 283 | 284 | ```html 285 | 286 | 287 | ... 288 | 289 | {% block scripts %} 290 | 291 | ... 292 | 293 | 301 | 302 | {% endblock scripts %} 303 | ``` 304 | 305 | 将这段脚本同样添加到旧的 `list.html` 模板中,因为它也有同样的问题。 306 | 307 | 完成后刷新页面,访问 `/photo/oss-home/` 这个地址: 308 | 309 | ![](https://blog.dusaiphoto.com/dj-album-100-8.jpg) 310 | 311 | 或许你现在还感受不到 OSS 存储和本地存储的区别。 312 | 313 | 别急,等到下一章部署到线上后,你测试下就会深有体会了。 314 | 315 | ## 总结 316 | 317 | 对象存储 OSS 可不止本文中写的这么点玩意儿,它有非常多的操作手段。比如说你可以把图片的增删改查等所有操作全都集成到自己的站点中,而不是像现在这样,只实现了对 OSS 文件的列举功能。 318 | 319 | 另一方面,OSS 的用途和数据库不一样,对它内部文件的查询、列举有自己的一套优化规则。如果你的文件数量很巨大,那么本文中实现的这个 `ObjIterator` 类可能效率不佳。此外 OSS 还有大量高阶功能,比如自动生成缩略图、url签名、权限管理等。 320 | 321 | 鉴于教程篇幅有限,对 OSS 的探讨就不深究下去了,读者有兴趣请自行研究[OSS文档](https://help.aliyun.com/product/31815.html?source=5176.11533457&userCode=m3bbolgr)。 322 | 323 | 下一章将探讨 Web 开发的终极内容:部署。 324 | 325 | > 点赞 or 吐槽?评论区见! 326 | -------------------------------------------------------------------------------- /md/110-部署网站.md: -------------------------------------------------------------------------------- 1 | 我们的相册虽然还有不完善的地方,但是没关系,越早把它部署到互联网上,才能越早发现线上特有的问题,让产品在迭代中成长。 2 | 3 | **注意**:以下流程经过笔者验证,能够保证顺利部署项目。如果你不知道每一步都是干嘛的,那么请严格按照文章的流程顺序操作。 4 | 5 | ## 配置服务器 6 | 7 | 要架设网站,首先你要有一台连接到互联网的服务器。国内比较出名的云服务器有**阿里云**、**腾讯云**、**百度云**,三家各有优劣,大家自行了解比较,并选择自己适合的购买。 8 | 9 | 和前章一样,笔者用的是阿里云全家桶,所以教程会以**阿里云ECS**作为例子讲解。新用户通过此[阿里云ECS推广链接](https://www.aliyun.com/product/ecs?userCode=m3bbolgr)注册有折扣和现金券;学生有优惠服务器每月9.5元,很划算。 10 | 11 | > 阿里云服务器购买页面变动频繁,如果图中展示的步骤和你购买时的不一样也没关系,核心步骤都是差不多,稍微找一下就OK了。 12 | 13 | 首先进入**阿里云ECS的购买页面**: 14 | 15 | ![](http://blog.dusaiphoto.com/%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%28249%29.png) 16 | 17 | 图片字很小,看不清楚的同学将就一下放大看吧。 18 | 19 | 挑重点说一下: 20 | 21 | - **实例**从入门级里选一款便宜的(比如2核2G的),以后流量高了再升级也不迟(土豪请无视这条)。 22 | - **镜像**选择 Ubuntu 。其他 Linux 版本也是可以的,根据你的使用习惯确定。 23 | - **系统盘**先选个 20G,够你用一阵了。数据盘暂时用不上,不用勾选。 24 | 25 | 点击下一步,来到**网络和安全组**页面: 26 | 27 | ![](http://blog.dusaiphoto.com/%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%28250%29.png) 28 | 29 | 这页默认就行了,公网带宽选最低的 1M ,初期够用了。 30 | 31 | > 如果有询问是否购买公网 IP 的选项,记得勾上。没公网 IP 就没办法连接到互联网了。 32 | 33 | 点击下一步,到**系统配置**页面: 34 | 35 | ![](http://blog.dusaiphoto.com/%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%28241%29.png) 36 | 37 | 为了后面远程连接服务器更简单,这里勾选**自定义密码**,也就是输入用户/密码的认证方式了。实际上**秘钥对**的认证方式更安全些,以后摸熟了再改回来吧。 38 | 39 | 点击下一步,到**分组设置**页面。这个页面全部默认设置就好了。点击下一步,**确认订单**无误后,就可以付款啦。 40 | 41 | 付款成功后,通过控制台就可以看到已购买的云服务器了: 42 | 43 | ![](http://blog.dusaiphoto.com/%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%28243%29.png) 44 | 45 | 这里有时候会有黄字提醒你服务器的网络端口没开,点击黄字链接进入**安全组规则**选项卡开通一下: 46 | 47 | ![](http://blog.dusaiphoto.com/%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%28244%29.png) 48 | 49 | 把 22(远程连接端口)、443(HTTPS端口)、80(HTTP端口)都打开,3389端口顺便也开了。 50 | 51 | > 这一步很重要,如果不打开后续会始终连接不上站点。如果没有黄字提醒,那就一定要到安全组中确认端口已打开。 52 | 53 | 至此服务器的购买、配置就完成啦。稍等几分钟后等待初始化完成,就可以得到服务器的**公网 IP 地址**,笔者的是 `47.104.227.185` ,后面会用到。 54 | 55 | ## 准备工作 56 | 57 | 在正式部署前,还有些准备工作需要做。 58 | 59 | ### 修改后端配置 60 | 61 | 首先 Django 的**配置**要更改为线上状态: 62 | 63 | ```python 64 | # /album/settings.py 65 | 66 | ... 67 | 68 | # 修改项。关闭调试模式 69 | # 关闭后 django 不再处理静态资源 70 | # 也不再提供错误提示页面 71 | DEBUG = False 72 | 73 | # 修改项。允许所有的IP访问网络服务 74 | ALLOWED_HOSTS = ['*'] 75 | 76 | # 新增项。静态文件收集目录 77 | STATIC_ROOT = os.path.join(BASE_DIR, 'collected_static') 78 | ``` 79 | 80 | 然后在虚拟环境中执行: 81 | 82 | ```python 83 | (env)> pip freeze > requirements.txt 84 | ``` 85 | 86 | 将后端所有的依赖库记录到 `requirements.txt` 中。 87 | 88 | ### 代码上传Github 89 | 90 | 将项目代码拷贝到云服务器的方式有几种。比较方便的是上传到 Github ,再由 Github 将项目代码下载到服务器。因此你需要把项目上传到 Github。 91 | 92 | > Github 经常会速度很慢或无法登录。这时候你也可以尝试用国内的 [Gitee](https://gitee.com/),或者直接点对点本地上传服务器。 93 | 94 | 如何上传这里就不细讲了,请自行学习 Git 相关知识,注册 Github 账号等。 95 | 96 | 需要提醒的是,所有依赖的**库都不需要上传**,比如 `env` 目录,它们可以在服务器中很方便地安装。 97 | 98 | > 这就是前面生成的 `requirements.txt` 的作用。 99 | 100 | 接下来就是正式部署了。 101 | 102 | ## 远程连接 103 | 104 | 部署的第一步就是想办法连接到云服务器上去,否则一切都免谈。鉴于项目是在 Windows 环境开发的,推荐用 **XShell** 来作为远程连接的工具。XShell 有[学校及家庭版本](https://www.netsarang.com/zh/free-for-home-school/),填一下姓名和邮箱就可以免费使用了。 105 | 106 | XShell 怎么使用就不赘述了,以读者的聪明才智,稍微查阅一下就明白了。 107 | 108 | > 使用相当简单,基本就是把主机 IP、端口号(22)以及登录验证填好就能连接了。 109 | 110 | 连接成功后,就能在 XShell 窗口中看到阿里云的欢迎字样了: 111 | 112 | ```bash 113 | Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-77-generic x86_64) 114 | 115 | * Documentation: https://help.ubuntu.com 116 | * Management: https://landscape.canonical.com 117 | * Support: https://ubuntu.com/advantage 118 | 119 | Welcome to Alibaba Cloud Elastic Compute Service ! 120 | 121 | root@dusai:~$ 122 | ``` 123 | 124 | `root@dusai:~$ `是命令提示符(root 是用户名,dusai 是主机名),输入命令时不需要你输入这个。 125 | 126 | > 本文后面把 `root@dusai:`字符省略掉,方便大家阅读。 127 | 128 | ## 安装项目及依赖 129 | 130 | **接下来的部署指令均在服务器中执行**,也就是在 XShell 中操作,别搞混了。 131 | 132 | 首先更新系统库: 133 | 134 | ```bash 135 | ~$ apt-get update 136 | ~$ apt-get upgrade 137 | ``` 138 | 139 | 部署到正式环境时,**后端服务器**就不能用 Django 自带的开发服务器了(性能低下),而是改用 Nginx + Gunicorn + Django 配合提供网络服务: 140 | 141 | - 客户端发来 http 请求,Nginx 作为直接对外的接口,对 http 请求进行分析; 142 | - 如果是静态资源请求,则由Nginx自己处理(效率极高); 143 | - 如果是动态资源请求,则把它转发给 Gunicorn 进行预处理后,再转发给 Django,最终完成资源的返回。 144 | 145 | 除此之外,还要确保 Python3 、Git 和 virtualenv 也都正确安装。 146 | 147 | 顺序执行以下指令: 148 | 149 | ```bash 150 | ~$ apt-get install nginx 151 | ~$ apt-get install python3.8 152 | ~$ apt-get install python3-pip 153 | ~$ apt-get install git 154 | ~$ pip3 install virtualenv 155 | ``` 156 | 157 | 均成功后,创建并跳转到项目目录: 158 | 159 | ```bash 160 | ~$ mkdir -p /home/sites/album 161 | ~$ cd /home/sites/album 162 | 163 | # 进入的路径如下所示 164 | /home/sites/album$ 165 | ``` 166 | 167 | 接下来就可以从 Github 下载项目了: 168 | 169 | ```bash 170 | # 以教程仓库为例 django-vue-tutorial 171 | ../album$ git clone https://github.com/stacklens/django-album-tutorial.git 172 | ``` 173 | 174 | 这里就以教程的仓库为例,读者用自己项目时一定要注意路径名称正确。 175 | 176 | > 如果你是从非公开项目下载,用户名密码的认证方式 Github 已经准备废弃了。如遇报错请以密钥认证的形式下载。 177 | 178 | 下载好项目后,在同级路径创建并进入虚拟环境: 179 | 180 | ```bash 181 | ../album$ virtualenv --python=3.8 venv 182 | ../album$ source venv/bin/activate 183 | 184 | # 看到 (venv) 开头就对了 185 | (venv) ../album$ 186 | ``` 187 | 188 | 进入项目目录,安装依赖、收集静态资源并迁移数据库: 189 | 190 | ```python 191 | # 这里的 django-album-tutorial 路径是从 Github 拉取下来的项目路径 192 | # 记得改成你自己的 193 | (venv) ../album$ cd django-album-tutorial 194 | 195 | (venv) ../django-album-tutorial$ pip3 install -r requirements.txt 196 | (venv) ../django-album-tutorial$ python3 manage.py collectstatic 197 | (venv) ../django-album-tutorial$ python3 manage.py migrate 198 | ``` 199 | 200 | 最后启动 nginx: 201 | 202 | ```bash 203 | # 为了阅读方便,后续命令行均省略 $ 前面的路径部分 204 | (venv) ~$ service nginx start 205 | ``` 206 | 207 | 在浏览器中访问你的云服务器的公网 IP ,看看效果: 208 | 209 | ![](https://blog.dusaiphoto.com/dj-album-110-8.png) 210 | 211 | 看到 Nginx 的欢迎页面则成功一半了。继续。 212 | 213 | ## 配置nginx 214 | 215 | Nginx 欢迎界面这个默认配置显然是不能用的,所以需要重新写 Nginx 的配置文件。 `/etc/nginx/sites-available` 目录是定义 **Nginx 可用配置**的地方。输入指令创建配置文件 `myblog` 并打开 **vim 编辑器**: 216 | 217 | ```python 218 | (venv) ~$ vim /etc/nginx/sites-available/album 219 | ``` 220 | 221 | 关于 `vim` 编辑器如何使用也不多说了,这里只说两个最基本的操作: 222 | 223 | - 按 `i` 键切换到**编辑模式**,这时候才可以进行输入、删除、修改等操作 224 | - 按 ` Ctrl + c` 退回到**命令模式**,然后输入 `:wq + Enter` 保存文件修改并退回到服务器命令行 225 | 226 | 回到正题,用 `vim` 在 `album` 文件中写入: 227 | 228 | ```python 229 | server { 230 | charset utf-8; 231 | listen 80; 232 | server_name 47.104.227.185; # 改成你的 IP 233 | 234 | location /static { 235 | # 这里的 django-album-tutorial 路径是从 Github 拉取下来的项目路径 236 | # 记得改成你自己的 237 | alias /home/sites/album/django-album-tutorial/collected_static; 238 | } 239 | 240 | location /media { 241 | # 这里的 django-album-tutorial 路径是从 Github 拉取下来的项目路径 242 | # 记得改成你自己的 243 | alias /home/sites/album/django-album-tutorial/media; 244 | } 245 | 246 | location / { 247 | proxy_set_header Host $host; 248 | proxy_pass http://unix:/tmp/47.104.227.185.socket; # 改成你的 IP 249 | } 250 | } 251 | 252 | ``` 253 | 254 | 此配置会监听 80 端口(通常 http 请求的端口),监听的 IP 地址写你自己的**服务器公网 IP**。 255 | 256 | 配置中有两个核心规则: 257 | 258 | - 如果请求静态资源,则直接转发到对应目录中寻找 259 | - 其他请求则转发给 Gunicorn(再转交给 Django) 260 | 261 | > 如果你已经申请好域名了,就把配置中有 IP 的地方都修改为域名,比如:server_name www.dusaiphoto.com。 262 | 263 | 写好后就退出 `vim` 编辑器,回到命令行。因为我们写的只是 Nginx 的**可用配置**,所以还需要把这个配置文件链接到**在用配置**上去: 264 | 265 | ```bash 266 | (venv) ~$ ln -s /etc/nginx/sites-available/album /etc/nginx/sites-enabled 267 | ``` 268 | 269 | 测试下 `nginx` 配置是否正常: 270 | 271 | ```bash 272 | (venv) ~$ nginx -t 273 | nginx: the configuration file /etc/nginx/nginx.conf syntax is ok 274 | nginx: configuration file /etc/nginx/nginx.conf test is successful 275 | ``` 276 | 277 | 至此 Nginx 就配置好了,接下来搞定 `Gunicorn`。 278 | 279 | > 有的读者无论怎么配置都只能看到 Nginx 欢迎页面,有可能是 sites-enabled 目录中的 default 文件覆盖了你写的配置。将 default 文件删掉就可以正常代理自己的配置文件了。 280 | 281 | ## Gunicorn及测试 282 | 283 | Nginx 搞定后就只剩 Gunicorn 了。 284 | 285 | 下面的三条命令分别是安装 Gunicorn 、 重启 Nginx 和 启动 Gunicorn: 286 | 287 | ```bash 288 | (venv) ~$ pip3 install gunicorn 289 | (venv) ~$ service nginx restart 290 | # 将 IP 改为你的公网 IP 291 | # .wsgi 前面为 Django 配置文件所在的目录名 292 | (venv) ~$ gunicorn --bind unix:/tmp/47.104.227.185.socket album.wsgi:application 293 | 294 | # Gunicorn 成功启动后命令行提示如下 295 | [2021-07-29 15:09:22 +0800] [11945] [INFO] Starting gunicorn 20.1.0 296 | [2021-07-29 15:09:22 +0800] [11945] [INFO] Listening at: unix:/tmp/47.104.227.185.socket (11945) 297 | [2021-07-29 15:09:22 +0800] [11945] [INFO] Using worker: sync 298 | [2021-07-29 15:09:22 +0800] [11947] [INFO] Booting worker with pid: 11947 299 | ``` 300 | 301 | Gunicorn 就启动成功了。(注意启动时命令行所在的路径) 302 | 303 | 接下来用浏览器访问试试: 304 | 305 | ![](https://blog.dusaiphoto.com/dj-album-110-9.jpg) 306 | 307 | 大功告成,撒花庆祝! 308 | 309 | > 此时你就可以对比本地存储和 OSS 存储的巨大鸿沟了。 310 | 311 | ## 收尾工作 312 | 313 | ### 后期运维 314 | 315 | 你的网站是需要不断更新优化代码的。每次修改代码后,更新到服务器上也很简单。在**虚拟环境**中并**进入项目目录**,依次(collectstatic 和 migrate 是可选的)执行以下命令: 316 | 317 | ```bash 318 | git pull 319 | 320 | python3 manage.py collectstatic 321 | python3 manage.py migrate 322 | 323 | # 重启 gunicorn 324 | pkill gunicorn 325 | gunicorn --bind unix:/tmp/47.104.227.185.socket my_blog.wsgi:application 326 | ``` 327 | 328 | 加上 `cd` 更改目录的指令,部署过程有十几条指令,手动输入也太麻烦了。简单粗暴的办法是利用 XShell 的宏,把部署指令写成顺序执行的脚本,点几个按钮就完成了,非常方便。 329 | 330 | > 更高级的做法是在服务器上编写自动化部署的脚本,这个就读者以后慢慢研究吧。 331 | 332 | 如果你更改了 Nginx 的配置文件,还需要重启 Nginx 服务: 333 | 334 | ```bash 335 | service nginx restart 336 | ``` 337 | 338 | ### 域名及优化 339 | 340 | 相对部署来说,域名配置就很容易了,各家云服务商都有此业务。 341 | 342 | 有了域名之后要改的地方: 343 | 344 | - `Nginx` 中与 IP/域名 有关的位置 345 | - `Gunicorn` 中与 IP/域名 有关的位置 346 | 347 | 域名搞定之后,接着就可以着手考虑把网站升级为 https 版本了。 348 | 349 | 最后,在开发时我们往 `settings.py` 中写入如 SECRET_KEY 、邮箱密码等各种敏感信息,部署时千万不要直接上传到公开仓库,而是把这些信息写到服务器本地,然后在 `settings.py` 中读取。 350 | 351 | ### 进程托管 352 | 353 | 部署过程中还有个新手经常碰到的问题,就是当 SSH 终端一关闭,Web 服务也一起被关闭了,导致网站无法连接。这个问题在 @frostming 的文章 [《Web 服务的进程托管》](https://frostming.com/2020/05-24/process-management) 中用了三种常见方法解决了,并且还实现了异常重启和开机自启动。有类似疑惑的同学可以前往围观。 354 | 355 | ## 总结 356 | 357 | 部署可以说是入门者最大的难关了,也是检验成果、获取成就感的关键一步。 358 | 359 | 多查资料,要相信你遇到的问题别人早就遇到过了。 360 | 361 | **路漫漫其修远兮,吾将上下而求索。** 362 | 363 | > 点赞 or 吐槽?来评论区! 364 | 365 | 366 | 367 | 368 | 369 | -------------------------------------------------------------------------------- /md/120-无限滚动.md: -------------------------------------------------------------------------------- 1 | 有读者私信提问:点击页码的分页形式在移动端体验不佳,能否修改成**无限滚动**的分页形式? 2 | 3 | 那么本文就作为**附加章节**,聊聊无限滚动分页的实现方式,给大家参考。 4 | 5 | > 如果你有其他感兴趣的内容,请在评论区告诉我。 6 | 7 | ## 无限滚动 8 | 9 | **无限滚动**是指每当页面滑动到底部时,下一页的数据将自动被获取、并填充到页面底部。这样做的好处是省去了用户手动点击页码翻页的动作,这在移动端的体验提升是比较明显的。 10 | 11 | 无限滚动的重点在于**不重载整个页面**的情况下,对网页进行**部分更新**,这种技术被称为 **AJAX**(Asynchronous JavaScript and XML)。 AJAX 技术提供强大的灵活度,也让开发方式变得非常的不同。让我们在接下来的实践中感受吧。 12 | 13 | ## 后端部分 14 | 15 | 回顾前面章节中 Django 的开发模式:视图将数据作为上下文,传递到模板中,模板经过渲染(将标签替换为数据),显示到浏览器中。但问题是模板渲染通常是一个整体,要更新所有内容一起更新。而无限滚动仅仅只需要更新一小部分页面。 16 | 17 | 因此开发的思路需要变成这样: 18 | 19 | - 后端提供两个 url 路径。 20 | - **路径1**作为浏览器访问的入口,提供页面基础的骨架。 21 | - **路径2**专门用于给**路径1**提供数据。 22 | 23 | 按照上述思路,先写好路径2的视图函数: 24 | 25 | ```python 26 | # /photo/views.py 27 | 28 | ... 29 | 30 | from django.http import JsonResponse 31 | 32 | # 获取数据的视图 33 | def fetch_photos(request): 34 | photos = Photo.objects.values() 35 | paginator = Paginator(photos, 4) 36 | page_number = int(request.GET.get('page')) 37 | data = {} 38 | 39 | # 页码正确才返回数据 40 | if page_number <= paginator.num_pages: 41 | paged_photos = paginator.get_page(page_number) 42 | data.update({'photos': list(paged_photos)}) 43 | 44 | return JsonResponse(data) 45 | ``` 46 | 47 | 视图函数 `fetch_photos()` 最显著的特点是不再按照 MTV 模式,返回了一个**带有数据的模板**,而是仅仅只返回了**数据**。这个数据通过 `JsonResponse(data)` 被转换为 JSON 格式,方便前端读取。 48 | 49 | > `if` 语句保证只有正确的页码才会有数据。否则返回空值。 50 | 51 | 接着给**路径1**和**路径2**配置路由: 52 | 53 | ```python 54 | # /photo/urls.py 55 | 56 | from django.urls import path 57 | from photo.views import ( 58 | home, 59 | upload, 60 | oss_home, 61 | fetch_photos, 62 | ) 63 | 64 | from django.views.generic import TemplateView 65 | 66 | app_name = 'photo' 67 | 68 | urlpatterns = [ 69 | ... 70 | path( 71 | 'endless-home/', 72 | TemplateView.as_view(template_name='photo/endless_list.html'), 73 | name='endless_home' 74 | ), 75 | path('fetch/', fetch_photos, name='fetch'), 76 | ] 77 | ``` 78 | 79 | 由于提供基础骨架的路径 `endless_home` 已经不需要提供自定义的 `context` 了,因此直接由 `TemplateView` 转发到模板即可。数据由专门的 `fetch` 路径提供,也就是对应前面的 `fetch_photos()` 视图。 80 | 81 | > 这已经比较接近前后端分离开发的思想了。 82 | 83 | 后端这样就搞定了! 84 | 85 | 真正的难点在前端代码,让我们继续。 86 | 87 | ## 前端部分 88 | 89 | ### 开胃菜 90 | 91 | 无限滚动核心的需求是代码要监听页面的滚动行为,一但到达底部就触发获取新数据的事件。 92 | 93 | 具备此类功能的插件很多,笔者找了一个在 Github 上小巧的插件 [Bounds.js](https://chriscavs.github.io/bounds-demo/) 。 94 | 95 | 进入插件的 [Github 仓库](https://github.com/ChrisCavs/bounds.js/blob/master/src/bounds.js),直接将 `bounds.js` 这个文件复制或者下载,放到相册项目的 `/static/` 路径中,取名叫 `bounds.js` 。 96 | 97 | > 即路径为 `/static/bounds.js` 。 98 | 99 | **注意**:下载完毕后,必须将文件尾部的 `export default bound` 这行代码**注释**或者**删除**掉,否则会报错。 100 | 101 | > 这行代码是插件从 NPM 安装时才需要的。 102 | 103 | 接下来的问题是:既然抛弃了 Django 的上下文,那获取的数据如何渲染到页面中? 104 | 105 | jQuery 能够充当这个角色,但用起来更方便的是当下几个流行的“胖前端”框架,比如 [Vue](https://v3.cn.vuejs.org/) 。很多老铁总认为 Vue 这种框架是和前后端分离绑定在一起的,实则不然,称作“关系密切”会更贴切。你可以把 Vue 当成大号的 jQuery 使用,或者和模板混用(就像本文这样),一点问题都没有。 106 | 107 | 另外,由于前端要自行向后端索取数据,因此还得有发送请求的插件,比如 `axios.js` 。 108 | 109 | 有了以上认识后,最后让我们把提到的这三个小玩意儿引用到 `base.html` 中: 110 | 111 | ```html 112 | 113 | 114 | ... 115 | 116 | 117 | 118 | 119 | 120 | 121 | ... 122 | ``` 123 | 124 | ### 主菜 125 | 126 | 接下来完成**图片列表**部分。它有点复杂是因为在脚本(Javascript)中独立承担了监听事件、获取数据、渲染数据、状态管理等逻辑。如果你看着非常头疼,那可能需要先浏览下 Vue 的入门文档了。 127 | 128 | 新建 `/templates/photo/endless_list.html` 文件。这里先把所有代码全贴出来: 129 | 130 | ```html 131 | 132 | 133 | {% extends "base.html" %} 134 | {% block title %}首页{% endblock title %} 135 | 136 | {% block content %} 137 |
138 |
139 |
140 |
141 | 146 | 152 | 153 |
154 |
155 |
156 | 157 | 158 | 171 |
172 | 173 |
174 | 175 | {% endblock content %} 176 | 177 | {% block scripts %} 178 | 259 | {% endblock scripts %} 260 | ``` 261 | 262 | 篇幅很长,让我们逐个探讨。 263 | 264 | ## 代码拆解 265 | 266 | 从 html 部分开始拆解。 267 | 268 | > 你可以将它与普通的 Django 模板逐行对比,研究其区别。 269 | 270 | ```html 271 |
272 |
273 |
274 |
275 | 280 | 286 | 287 |
288 |
289 |
290 | 291 | 292 | 305 |
306 | 307 |
308 | ``` 309 | 310 | 抛弃了 Django 模板语法,我们又用上了 Vue 的模板语法,比如: 311 | 312 | - 根元素要有 `id="app"` 以方便 Vue 的挂载。 313 | - `v-for` 遍历图片数据。 314 | - `:data-bs-target` 和 `:src` 分别绑定了不同的 Vue 单行语句,用于动态获取模态窗 `id` 和图片的路径。 315 | 316 | 额外**需要注意**的是,代码中取消了 Bootstrap 的瀑布流样式,原因是它与 `bounds.js` 互相冲突。没办法只能忍痛割爱了。 317 | 318 | > 没有了瀑布流就是排列平整的正常卡片结构了,但在实际开发中这不是什么大问题。因为既然都用 Vue 了,那么你可能都不会用 Bootstrap,而是用基于 Vue 的专门的 UI 组件。 319 | 320 | 此外,底部的 `
` 是一个标志物,`bounds.js` 根据它是否出现在浏览器视窗中,从而判断页面是否已经到了底部。 321 | 322 | ```javascript 323 | // 监听滚动到底部的事件 324 | let setBounds = () => { 325 | const box = document.querySelector('.box'); 326 | // 进入底部后将 Vue 的页码状态 +1 327 | const onEnter = () => { 328 | app._instance.data.pageNum += 1; 329 | console.log('onEnter'); 330 | }; 331 | // 离开底部事件 332 | const onLeave = () => { 333 | console.log('onLeave'); 334 | }; 335 | const boundary = bound({ 336 | margins: {bottom: 10} 337 | }) 338 | boundary.watch(box, onEnter, onLeave); 339 | } 340 | ``` 341 | 342 | `setBounds()` 函数就是 `bounds.js` 插件文档提供的标准写法。它的原理是根据 `class="box"` 元素进入或离开视窗,从而调用 `onEnter()` 或 `onLeave()` 函数。到达底部时,`onEnter()` 函数将访问 Vue 实例,将页码的状态 +1 ,也就是“翻页”。 343 | 344 | 接下来看 Vue 实例内部。 345 | 346 | ```javascript 347 | data() { 348 | return { 349 | // 图片列表数据 350 | photos: [], 351 | // 当前页码 352 | pageNum: 1, 353 | } 354 | }, 355 | ``` 356 | 357 | Vue 管理了两个状态: 358 | 359 | - `photos` 是当前所有的图片集的数据。 360 | - `pageNum` 是当前的页码。 361 | 362 | ```javascript 363 | // Vue实例创建完毕后,立即获取第一页的数据 364 | created() { 365 | axios.get('/photo/fetch', { 366 | params: { 367 | page: this.pageNum 368 | } 369 | }) 370 | .then((response) => { 371 | this.photos = response.data.photos; 372 | this.pageNum = 1; 373 | }) 374 | }, 375 | ``` 376 | 377 | 生命周期钩子 `created()` 在 Vue 实例初始化完成后立即调用,获取第一页的数据。 378 | 379 | ```javascript 380 | watch: { 381 | // 监听页码变化的事件 382 | // 请求下一页的数据 383 | pageNum(newValue, oldValue) { 384 | if (newValue > 1) { 385 | axios.get('/photo/fetch', { 386 | params: { 387 | page: this.pageNum 388 | } 389 | }) 390 | .then((response) => { 391 | sleep(500).then(() => { 392 | if (Object.keys(response.data).length !== 0) { 393 | this.photos = [...this.photos, ...response.data.photos]; 394 | } 395 | }) 396 | }) 397 | } 398 | } 399 | }, 400 | ``` 401 | 402 | - 当页面到达底部时, `onEnter()` 事件将更新 Vue 实例的 `pageNum` 的值。 403 | - 而 `pageNum` 被 Vue 监听,一但更新就会触发请求下一页数据的代码。 404 | - `sleep(500)` 是为了方便开发时观察,线上时可去掉或改得非常小。 405 | - 取得数据后,将其解包拼接到 `photos` 列表中。 406 | 407 | 最后还有些零零碎碎的内容了,比如要记得 `app.mount('#app');` 挂载 Vue 实例,还要 `$(window).on('load', ...)`开启滚动事件。 408 | 409 | 欧了,接下来试试效果。 410 | 411 | ## 测试效果 412 | 413 | 启动开发服务器,浏览器进入 `http://127.0.0.1:8000/photo/endless-home/` 路径。 414 | 415 | 效果如下: 416 | 417 | ![](https://blog.dusaiphoto.com/dj-album-120-1.jpg) 418 | 419 | 首页数据显示正常。 420 | 421 | 把页面滚动到底部: 422 | 423 | ![](https://blog.dusaiphoto.com/dj-album-120-2.jpg) 424 | 425 | 第二页的数据自动追加到页面底部。效果还是不错的。 426 | 427 | ## 总结 428 | 429 | 前后端分离的开发模式在当下非常流行。它的好处就是把前后端的工作彻底分割开了,后端只负责提供数据,前端负责数据的渲染。因此相比传统的 Django MTV 模式而言,前端逻辑的复杂度增加了,相信你也感受到了。 430 | 431 | 虽然本章的内容虽然并不是纯正的前后端分离,但是也非常接近了。如果对这方面感兴趣的读者,建议先阅读 [Vue文档](https://v3.cn.vuejs.org/guide/introduction.html),再根据情况阅读我的[Django-Vue搭建博客教程](https://www.dusaiphoto.com/article/103/)。 432 | 433 | 另外,能够实现无限滚动的方式、插件、框架很多,本文仅提供了其中一种思路。在实际开发中,最好根据项目情况选择合适的方法、插件和框架,不要拘泥于形式。 434 | 435 | > 本章代码是在本地开发测试的。与前面章节一样,部署到线上也要对图片加载延迟进行对应的处理。 436 | > 437 | > 点赞 or 吐槽?评论区见! 438 | -------------------------------------------------------------------------------- /md/130-结语.md: -------------------------------------------------------------------------------- 1 | 至此本教程算告一段落了。如果还有读者特别想看、但是教程又没涉及的内容,请在评论区告诉我,我会考虑更新。 2 | 3 | 看到这里,你学会了以下内容: 4 | 5 | - 搭建开发环境 6 | - Django 代码结构 7 | - 数据存储 8 | - MTV 模式 9 | - 模态与动画 10 | - 登入与登出 11 | - 批量上传图片 12 | - 分页 13 | - 云存储 14 | - 部署 15 | - 无限滚动 16 | - 其他零星功能 17 | 18 | ## 接下来学什么 19 | 20 | ### 继续深入 21 | 22 | 本教程篇幅有限,很多功能蜻蜓点水带过了。 23 | 24 | 如果你比较适应我的写作风格,那么可以继续阅读我的[Django搭建个人博客](https://www.dusaiphoto.com/article/2/),进行更详细的探讨。 25 | 26 | 熟悉了传统的 MTV 模式后,可阅读我的[Django-Vue搭建博客](https://www.dusaiphoto.com/article/103/)尝试前后端分离的开发。 27 | 28 | > 欢迎来[我的博客](https://www.dusaiphoto.com/topic/)和公众号“杜赛说编程”转转,专注原创 Python 内容。 29 | 30 | ### 响应式布局 31 | 32 | 响应式布局,简单来说就是页面布局随着终端设备的变化而自动适应。 33 | 34 | 教程为了起步平缓,没有展开这方面的内容。如果在手机上浏览,界面可能会比较糟糕。 35 | 36 | 好在 Bootstrap 就是一个强大的响应式布局框架。在它的官网上有非常详细的介绍、复制就能用的代码,请耐心查阅:[Bootstrap官方文档](https://getbootstrap.com/docs/4.1/getting-started/introduction/) 37 | 38 | ### 其他技能 39 | 40 | 只会写 Django 是没法支撑一个漂亮的网站的。 41 | 42 | - 你要学 `JavaScript`,让界面更美观 43 | - 要学 `Linux`,以便网站运维 44 | - 要学数据库知识,让你在某些特殊情况下摆脱 ORM,高效的操作远程数据库 45 | - 以及云服务器各种组件、微信支付接口、缓存数据库、... 46 | 47 | 不用精通,但是至少得会一点点。 48 | 49 | 此外,待你熟悉好了 MTV 模式开发后,就可以开始尝试当下流行的前后端分离开发了,此时你需要学习后端的 DjangoRestFramework ,前端如 Vue/React 等现代化前端框架。 50 | 51 | ## 写在最后 52 | 53 | 你是否学到新东西了呢? 54 | 55 | 欢迎点击教程尾部的[打赏]按钮,请我喝杯咖啡~ 56 | 57 | 或者在我的[GitHub教程代码](https://github.com/stacklens/django_blog_tutorial)给一个小星星,感谢各位的支持。 58 | 59 | > 教程代码、文章已全部上传至 Github,有需要的读者可前往下载对照。 60 | 61 | 我的教程是写完了,但是你的学习才刚开始。胜利的背后总有无数个难熬的夜晚。 62 | 63 | 下一篇文章见。 64 | 65 | --- 66 | 67 | 一个人的学习是孤单的。欢迎扫码 Django 交流QQ群、公众号、TG群组等圈子,大家共同进步吧。 68 | 69 | ![](https://blog.dusaiphoto.com/QR-0608.jpg) 70 | -------------------------------------------------------------------------------- /md/20-搭建开发环境.md: -------------------------------------------------------------------------------- 1 | ## 教程的开发环境 2 | 3 | 本教程的开发环境为: 4 | 5 | - **Win 10(64位)** 6 | - **Python 3.8.10** 7 | - **Django 3.2.5** 8 | 9 | 为了避免开发环境不同而导致的错误,建议读者使用相同的版本。 10 | 11 | > 特别要注意的是,教程后期云存储的章节,相关的库目前为止(2021.07.29)仅支持到 Python 3.8 。使用 Python 3.9 可能会有潜在的 bug。 12 | 13 | ## 安装Python 14 | 15 | python的安装很简单,首先找到[Python官方网站](https://www.python.org/downloads/windows/),选择python3.8的windows版本,下载并安装。 16 | 17 | **安装时注意勾选添加python到环境变量中。**如果没有或者漏掉这一步,请安装完毕后自行添加。 18 | 19 | 若实在不知道怎么弄的,看这篇文章: 20 | 21 | [windows上安装python3教程以及环境变量配置](https://blog.csdn.net/random_w/article/details/78897365) 22 | 23 | 安装完成后打开[命令行](https://jingyan.baidu.com/article/046a7b3e83a505f9c27fa9a2.html),输入`python -V`,系统打印出python的版本号,说明安装成功了: 24 | 25 | ``` 26 | C:\> python -V 27 | Python 3.8.10 28 | ``` 29 | 30 | ## 配置虚拟环境 31 | 32 | **虚拟环境**(virtualenv,或venv )是 Python 多版本管理的工具,可以使每个项目环境与其他项目独立开来,保持环境的干净,解决包冲突问题。你可以将虚拟环境理解为一个隔绝的小系统。 33 | 34 | 首先在合适的位置新建一个文件夹,比如 `django_album_tutorial` 。 35 | 36 | 在命令行中进入此文件夹: 37 | 38 | ```python 39 | # 输入指令 40 | D:\> cd Developer\Py\django_album_tutorial 41 | # 进到目录 42 | D:\Developer\Py\django_album_tutorial> 43 | ``` 44 | 45 | 输入如下指令,创建名为 `env` 的虚拟环境: 46 | 47 | ```python 48 | > python -m venv env 49 | ``` 50 | 51 | > 为阅读方便,后续将省略盘符前的路径,以 `>` 符替代。(不影响阅读下) 52 | 53 | 创建完成后,输入`env\Scripts\activate.bat`,即可进入虚拟环境: 54 | 55 | ```python 56 | > env\Scripts\activate.bat 57 | 58 | # Linux 或 Mac 用户改为 source env/bin/activate 59 | # 看到盘符前有 (env) 标识说明进入虚拟环境成功了 60 | (env)> 61 | ``` 62 | 63 | 可以用 `pip list` 观察下虚拟环境内的包: 64 | 65 | ```python 66 | (env)> pip list 67 | Package Version 68 | ---------- ------- 69 | pip 21.1.3 70 | setuptools 56.0.0 71 | ``` 72 | 73 | 非常的干净,与全局的包是完全隔离的。 74 | 75 | ## 安装Django 76 | 77 | **在虚拟环境下**,输入如下命令: 78 | 79 | ```python 80 | (env)> pip install django==3.2.5 -i https://pypi.tuna.tsinghua.edu.cn/simple 81 | 82 | # 显示下面的文字则表明安装django成功 83 | Looking in indexes: https://pypi.tuna.tsinghua.edu.cn/simple 84 | Collecting django==3.2.5 85 | Downloading ... 86 | Successfully installed asgiref-3.4.1 django-3.2.5 pytz-2021.1 sqlparse-0.4.1 87 | ``` 88 | 89 | > 由于国内复杂的网络环境,命令中的 `-i ...` 指定了安装所采用的国内镜像源。(这里用的清华源) 90 | 91 | 安装大概会花费几分钟时间。完成后再观察下环境中的包: 92 | 93 | ```python 94 | (env)> pip list 95 | 96 | # 已经有django了 97 | Package Version 98 | ---------- ------- 99 | asgiref 3.4.1 100 | Django 3.2.5 101 | pip 21.1.3 102 | pytz 2021.1 103 | setuptools 56.0.0 104 | sqlparse 0.4.1 105 | ``` 106 | 107 | ## 创建Django项目 108 | 109 | 还是在**虚拟环境**下,在命令行中中创建 Django 项目: 110 | 111 | ```python 112 | (env)> django-admin startproject album 113 | ``` 114 | 115 | 运行此命令后将新创建名叫 `album` 的路径。进入此路径。 116 | 117 | ```python 118 | # 进入新创建的 album 目录 119 | (env)> cd album 120 | ``` 121 | 122 | 目录结构像下面这样: 123 | 124 | ```python 125 | album/ 126 | manage.py 127 | album/ 128 | __init__.py 129 | settings.py 130 | urls.py 131 | asgi.py 132 | wsgi.py 133 | ``` 134 | 135 | 这就是我们刚创建出来的 `album` 相册项目了。 136 | 137 | ## 运行Django服务器 138 | 139 | Django 自带一个轻量的Web开发服务器,也被叫做 `runserver` 。 140 | 141 | 开发服务器可以避开配置生产环境的繁琐环节,快速开发 Web 程序。它会自动的检测代码的改变并加载它,因此在修改代码后不需要手动去重启服务器,非常的方便。 142 | 143 | 要运行此服务器,首先进入`album`文件夹,即含有 `manage.py` 文件的路径。(上面的指令已经进入了) 144 | 145 | 然后输入如下命令: 146 | 147 | ```python 148 | (env)> python manage.py runserver 149 | 150 | # 下面的文字表示启动服务器成功 151 | Watching for file changes with StatReloader 152 | Performing system checks... 153 | ... 154 | Starting development server at http://127.0.0.1:8000/ 155 | Quit the server with CTRL-BREAK. 156 | ``` 157 | 158 | 打开chrome浏览器,输入http://127.0.0.1:8000/ ,即倒数第二排文字的地址。 159 | 160 | 看到下面的界面: 161 | 162 | ![](https://blog.dusaiphoto.com/django-album-10-1.png) 163 | 164 | 恭喜你,小火箭起飞,django运行起来了。 165 | 166 | ## 代码编辑器 167 | 168 | django运行起来后,我们还需要一款**代码编辑器**或者**集成开发环境**(IDE)来编辑 Python 文件,以达到开发需求。 169 | 170 | 市面上有很多代码编辑器或者集成开发环境可以选择。教程使用了代码编辑器**Visiual Studio Code**,也就是大名鼎鼎的 VS Code 了。它是免费的,所以你不需要掏腰包。 171 | 172 | 进入[VS Code官网](https://code.visualstudio.com/),直接下载、安装即可使用了。 173 | 174 | > 进入VS Code 编辑器后,可能你还需要安装一些 Python/Django 相关的扩展包,以及修改自动保存等配置。 175 | > 176 | > 都很简单,就留给读者自己折腾了。 177 | 178 | 当然你也可以根据喜好选择其他的编辑器或者开发环境: 179 | 180 | - [10大Python集成开发环境和代码编辑器(指南)](https://blog.csdn.net/cH3RUF0tErmB3yH/article/details/80156176) 181 | - [写python程序什么编辑器最好用?](https://www.zhihu.com/question/20476960) 182 | 183 | ## 浏览器 184 | 185 | 推荐 [Chrome](https://www.google.com/chrome/) 。 186 | 187 | ## 总结 188 | 189 | 经过以上一番折腾,总算是把趁手的工具都准备齐了。 190 | 191 | 准备好迎接正式的挑战吧。 192 | 193 | > 看完文章,想点赞或吐槽?欢迎到评论区和我交流! 194 | -------------------------------------------------------------------------------- /md/30-你好世界.md: -------------------------------------------------------------------------------- 1 | **Django** 官方的宣传语是:“给急于交付的完美主义者用的 Web 框架”。 2 | 3 | 这意思是又快又好呗,好大的口气。 4 | 5 | 让我们实践下,看看是不是像官方说的那样厉害。 6 | 7 | ## 创建 App 8 | 9 | 上一章在虚拟环境中创建好了 Django 项目: 10 | 11 | ```python 12 | (env)> django-admin startproject album 13 | ``` 14 | 15 | 接下来,进入 `album` 路径(有 `manage.py` 文件的路径),运行下面的指令创建 App: 16 | 17 | ```python 18 | (env)> python manage.py startapp photo 19 | ``` 20 | 21 | Django 用 App 来组织**功能独立的模块**。比如个人博客中,文章功能可以是一个 App,评论功能是另一个 App。 22 | 23 | 运行完成后,看看目前的目录结构: 24 | 25 | ```python 26 | album/ 27 | manage.py 28 | album/ 29 | __init__.py 30 | settings.py 31 | urls.py 32 | asgi.py 33 | wsgi.py 34 | db.sqlite3 # runserver 时自动创建的 35 | photo/ # 刚创建的 photo app 36 | __init__.py 37 | admin.py 38 | apps.py 39 | models.py 40 | tests.py 41 | views.py 42 | migrations/ 43 | __init__.py 44 | ``` 45 | 46 | 多出来 `photo` 路径,里面的文件都从属于这个 `photo App` 了。 47 | 48 | > 此外在根目录还多了个 `db.sqlite3` ,这是数据库文件,暂时先不管它。 49 | 50 | ## 视图函数 51 | 52 | App 中最重要的文件可能就是 `views.py` 了,它负责获取数据、处理数据,并将数据传递给用户观看的页面上。 53 | 54 | 让我们先试试打印个 `Hello World` 到浏览器中。 55 | 56 | 将 `views.py` 改成如下: 57 | 58 | ```python 59 | # /photo/views.py 60 | 61 | from django.http import HttpResponse 62 | 63 | def home(request): 64 | title = '

Hello World

' 65 | return HttpResponse(title) 66 | ``` 67 | 68 | `home()` 就是这个非常重要的获取、展现数据的函数了,称为**视图函数**。 69 | 70 | 视图函数接收的第一个参数 `request` ,被称为**请求体**,它包含了从用户端(如浏览器)传递过来的数据等信息。 71 | 72 | 视图函数**必须**要做的只有一件事:要么返回一个**响应体**(给用户端),要么抛出一个**异常**(raise)。至于其他还要做什么,都随便你。 73 | 74 | 所以你可以看到,`home()` 函数甚至都没有用到 `request` 请求体,直接返回了 `HttpResponse` 响应体。 75 | 76 | > 你可以简单理解为直接返回给前端了。(虽然中间还有其他的处理) 77 | 78 | ## url路径 79 | 80 | 视图函数是有了,但是程序并不知道这个函数对应哪个 `url` 路径。 81 | 82 | > 是 `www.a.com/home` ?还是 `www.a.com/` ? 83 | 84 | 为了告诉程序视图函数和 url 路径的对应关系,因此创建 `photo/urls.py` ,写入: 85 | 86 | ```python 87 | # /photo/urls.py 88 | 89 | from django.urls import path 90 | from photo.views import home 91 | 92 | # App名称 93 | # 用于Django幕后的url查询 94 | app_name = 'photo' 95 | 96 | # url列表 97 | urlpatterns = [ 98 | path('', home, name='home'), 99 | ] 100 | ``` 101 | 102 | `urlpatterns` 收集当前 App 下的所有 url 路径,其中的 `path()` 接收三个参数,分别是: 103 | 104 | - 路径。空字符串表示没有下一级的路径了。 105 | - 视图函数。 106 | - 路径名。 107 | 108 | 光有 `/photo/urls.py` 还不行,因为这个路径文件实际上是可以在任意位置或有任意名称的,程序并不知道。因此 Django 有一个 url 路径的集中入口,也就是 `/album/urls.py` 这个文件了。 109 | 110 | 将 `/album/urls.py` 修改成这样: 111 | 112 | ```python 113 | # /album/urls.py 114 | 115 | from django.contrib import admin 116 | from django.urls import path, include 117 | from photo.views import home 118 | 119 | urlpatterns = [ 120 | path('admin/', admin.site.urls), 121 | # 下面在配置路径 122 | path('photo/', include('photo.urls', namespace='photo')), 123 | path('', home, name='home'), 124 | ] 125 | ``` 126 | 127 | 在这个文件里新配置了两个 `path()`。 128 | 129 | `path('photo/', ...)` 指定了 photo App 的根路径,里面的 `include()` 函数指定了子路径文件的位置,以及子路径的**命名空间**。举个栗子,photo App 的路径就变成 `/photo/a/` 或者 `/photo/b/` 了;在某些函数中,你也可以用 `photo:home` 很方便的指代 `/photo/home/` 这个路径。 130 | 131 | 由于 `/photo/home/` 就是网站的首页,因此额外配置了一条 `path('', home, ...)` ,就可以通过根路径直接访问了。 132 | 133 | > Django 允许多路径指向同一视图函数。 134 | 135 | 也就是说,`www.ds.com/` 和 `www.ds.com/photo/home/` 是相同的。 136 | 137 | ## 测试 138 | 139 | 差不多了。 140 | 141 | 在虚拟环境中启动服务器试试: 142 | 143 | ![](https://blog.dusaiphoto.com/dj-album-30-1.png) 144 | 145 | 成功打印出了 Hello World 标题。 146 | 147 | 整个请求、处理、响应回路,仅写了10来行代码,还蛮轻松愉快的吧? 148 | 149 | ## 总结 150 | 151 | 光打印个 Hello World 肯定不能满足现代人对网络的需求。不过这是个必然阶段,简单了解了 Django 中从请求到响应的构造。 152 | 153 | 下一章将继续完善这个通路,你会看到 Django 是如何非常简洁地处理数据库的。 154 | 155 | > 看完文章,想点赞或吐槽?欢迎到评论区和我交流! 156 | -------------------------------------------------------------------------------- /md/40-数据存储.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 上一章的 Django 请求响应流程中,我们简单地返回了一个字符串供前端显示。但是在实际情况下,总会有大量的动态数据需要储存和使用,比如文章的标题和正文、用户的名称和密码等。 4 | 5 | 存储这些数据需要专门的地方,被称为**数据库**。本章就来了解下如何在 Django 中使用数据库。 6 | 7 | ## 数据库与模型 8 | 9 | **数据库**是存储信息的场所。数据库由多个**数据表**构成。 10 | 11 | 啥意思?举个栗子,三年级二班中同学名册就是**数据表**。有的名册记录每位同学的考试成绩、有的记录身高体重、还有的记录兴趣爱好...所有的这些名册都放在老师的柜子里,这个柜子就是**“数据库”**了。 12 | 13 | > 默认情况下,数据库就是db.sqlite3这个文件了。 14 | > 15 | > 在网站上线后你可能想换别的数据库,不过目前还不需要讨论这个内容。 16 | 17 | 操作数据库使用的是古老的 SQL 语句,它是完全不同于 Python 的另一种语言,这对新手来说无疑是困难的。 18 | 19 | 幸运的是,在 Django 里写小型 Web 应用并不需要你直接去操作数据库。你只需要用 Python 语言定义好**模型**,而模型会自动生成操作数据库所必要的一切。 20 | 21 | > 这叫**对象关系映射**(**Object Relational Mapping**,简称**ORM**),用于实现编程语言里不同类型系统的数据之间的转换。 22 | 23 | 光讲理论有点枯燥,接下来通过实践理解。 24 | 25 | ## 编写模型 26 | 27 | Photo App 中的 `models.py` 就是编写模型的地方。 28 | 29 | 将其修改如下: 30 | 31 | ```python 32 | # /photo/models.py 33 | 34 | from django.db import models 35 | from django.utils.timezone import now 36 | 37 | class Photo(models.Model): 38 | image = models.ImageField(upload_to='photo/%Y%m%d/') 39 | created = models.DateTimeField(default=now) 40 | 41 | def __str__(self): 42 | return self.image.name 43 | ``` 44 | 45 | 继承自 `models.Model` 的对象被称为模型类,它对应了数据库中的数据表。模型类中可以定义很多**字段**,字段对应数据表中的不同信息。 46 | 47 | > 比如名册,里面每个同学都有姓名、年龄,在数据库中就是姓名字段、年龄字段等。 48 | 49 | `Photo` 模型中仅有两个字段。 50 | 51 | `ImageField` 字段用于存储图片信息。通常来说,字段中会存储对应数据,比如 `CharField` 会将字符数据保存在数据库中。但 `ImageField` 有点特殊,因为图片作为一种文件,直接保存在硬盘中就足够了,并不需要真正放进数据库中。因此 `ImageField` 实际上只在数据库中保存了图片的名称、存储路径、索引等元数据,真正的图片文件被保存在 `upload_to` 参数所指定的路径中。 52 | 53 | > `'photo/%Y%m%d/'` 是动态格式化当前日期的特殊书写方式。比如今天是2021年8月5日,那么图片将被保存在项目路径下的 `/photo/20210805/` 文件夹中。 54 | 55 | 另外一个 `DateTimeField` 用于记录图片创建的时间,默认值为当前时间。 56 | 57 | 方法 `__str__` 用于美化模型在后台、命令行中的输出信息。 58 | 59 | 接下来要修改 `/album/settings.py` 文件,这是 Django 的全局配置文件: 60 | 61 | ```python 62 | # /album/settings.py 63 | 64 | ... 65 | 66 | INSTALLED_APPS = [ 67 | 'django.contrib.admin', 68 | '... 69 | 70 | # 注册App 71 | 'photo', 72 | ] 73 | 74 | ... 75 | 76 | # 指定媒体文件路径 77 | MEDIA_URL = '/media/' 78 | MEDIA_ROOT = BASE_DIR / 'media' 79 | ``` 80 | 81 | 将 `photo` 添加到 `INSTALLED_APPS` 列表中,让 Django 程序加载这个自定义的 App。 82 | 83 | > 后续进行模型迁移时,程序会在此列表中进行搜索。 84 | 85 | 由于图片是媒体文件,它并不直接保存在数据库中,因此要增加 `MEDIA_URL` 和 `MEDIA_ROOT` 配置,指定这些图片上传的路径位置。 86 | 87 | 此外,媒体文件表现在前端中同样也是单独的 url 路径。因此要在根路由文件 `/album/urls.py` 添加对其的路径支持: 88 | 89 | ```python 90 | # /album/urls.py 91 | 92 | ... 93 | 94 | from django.conf import settings 95 | from django.conf.urls.static import static 96 | 97 | urlpatterns = [ 98 | ... 99 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 100 | ``` 101 | 102 | 至此模型基本就写好了。 103 | 104 | > 只有在开发阶段时,即 `settings.py` 中的 `DEBUG = True` 时,服务器才会处理图片或 `.js` 这类**静态文件**。当 `DEBUG = True` 时,Django 服务器将拒绝处理静态文件(由于效率低下),此时需要将其交给 nginx 进行处理。后续部署的章节将会继续讨论。 105 | 106 | 接下来进行数据迁移。 107 | 108 | ## 数据迁移 109 | 110 | **数据迁移**这个词听起来很玄乎,其实它有点类似于将刚才写的模型同步到数据库里。要记得数据库和 Python 是完全不同的东西,你在 Python 里写的模型长啥样,数据库并不知道。因此需要数据迁移这个步骤,Django 框架将在幕后自动为你处理好 Python 对象和数据库数据之间的对应关系。 111 | 112 | > 张三(Python)有一本名册(模型类),拿给了李四(数据库)。李四将名册的结构(需要记录哪些信息)原封不动抄到自己的名册中(数据迁移)。 113 | 114 | 由于 `ImageField` 的数据迁移依赖 `Pillow` 库,因此首先安装它:(一定要记得在虚拟环境中!) 115 | 116 | ```python 117 | (env)> pip install Pillow==8.3.1 -i https://pypi.tuna.tsinghua.edu.cn/simple 118 | ... 119 | Successfully installed Pillow-8.3.1 120 | ``` 121 | 122 | 接着输入指令创建迁移文件: 123 | 124 | ```python 125 | (env)> python manage.py makemigrations 126 | # 以下为输出 127 | Migrations for 'photo': 128 | photo\migrations\0001_initial.py 129 | - Create model Photo 130 | ``` 131 | 132 | 成功后执行迁移: 133 | 134 | ```python 135 | (env)> python manage.py migrate 136 | # 以下为输出 137 | Operations to perform: 138 | Apply all migrations: admin, auth, contenttypes, photo, sessions 139 | Running migrations: 140 | Applying contenttypes.0001_initial... OK 141 | Applying auth.0001_initial... OK 142 | Applying admin.0001_initial... OK 143 | ... 144 | Applying photo.0001_initial... OK 145 | Applying sessions.0001_initial... OK 146 | ``` 147 | 148 | 没报错就表示迁移成功了。 149 | 150 | 接下来我们存储些数据看看效果。 151 | 152 | ## 后台操作 153 | 154 | Django 开发之所以高效,原因之一就是自带很多通用功能的默认实现,比如**后台管理**功能。 155 | 156 | 首先在命令行里创建管理员账号: 157 | 158 | ```python 159 | (env)> python manage.py createsuperuser 160 | 161 | Username (leave blank to use 'dusai'): dusai 162 | Email address: 163 | Password: 164 | Password (again): 165 | Superuser created successfully. 166 | ``` 167 | 168 | 注意在命令行里填写密码是不会显示任何字符的。 169 | 170 | 接着还需要将 `Photo` 模型注册到后台中。修改 `/photo/admin.py` 文件如下: 171 | 172 | ```python 173 | # /photo/admin.py 174 | 175 | from django.contrib import admin 176 | from photo.models import Photo 177 | 178 | admin.site.register(Photo) 179 | ``` 180 | 181 | 这就Ok了。 182 | 183 | 重新启动服务器,输入 `127.0.0.1:8000/admin` 路径: 184 | 185 | ![](https://blog.dusaiphoto.com/dj-album-40-1.png) 186 | 187 | 出现了管理员登录页面。 188 | 189 | 输入刚才的账号密码登录后台: 190 | 191 | ![](https://blog.dusaiphoto.com/dj-album-40-2.png) 192 | 193 | 可以看到后台中已经有了 `Photo` 的管理入口。 194 | 195 | 点击 `Add` 添加新的图片数据: 196 | 197 | ![](https://blog.dusaiphoto.com/dj-album-40-3.png) 198 | 199 | 随便选择一张本地图片,并点击 `Save`: 200 | 201 | ![](https://blog.dusaiphoto.com/dj-album-40-4.png) 202 | 203 | 保存好之后,就可以在后台中看到已经上传的图片信息了。 204 | 205 | 接着到 `/media` 路径查看,图片文件确实也岁月静好的躺在那里: 206 | 207 | ![](https://blog.dusaiphoto.com/dj-album-40-5.png) 208 | 209 | ## 总结 210 | 211 | Django 的 ORM 模型系统给刚入门的同学非常多的便利。有可能你听都没听过操作数据库的 SQL 语言,却感受不到任何痛苦,因为模型的存在让你以 Python 的方式进行建库建表、增删改查等常规操作。但 ORM 的缺点就是处理复杂的查询语句会比较费劲,到那时就是你恶补数据库原生命令的时候了。 212 | 213 | Django 开发的三剑客:模型、视图、模板,前面两个已经见识过了。下一章拜会最后一位:模板,也聊聊 MTV 模式。 214 | 215 | > 点赞或吐槽?到评论区和我交流! 216 | 217 | -------------------------------------------------------------------------------- /md/50-MTV模式.md: -------------------------------------------------------------------------------- 1 | Django 框架主要关注的是**模型**(Model)、**模板**(Template)和**视图**(Views),称为MTV模式。 2 | 3 | 它们各自的职责如下: 4 | 5 | | 层次 | 职责 | 6 | | ------------------------ | ------------------------------------------------------------ | 7 | | 模型(Model),数据层 | 处理与数据相关的所有事务: 如何存取、如何验证有效性、数据之间的关系等。 | 8 | | 模板(Template),表现层 | 处理与表现相关的事务,即数据如何在页面中进行显示。 | 9 | | 视图(View),逻辑层 | 存取模型及调取恰当模板的相关逻辑。模型与模板的桥梁。 | 10 | 11 | 简单来说就是 Model 存取数据,View 决定需要调取哪些数据,而 Template 则负责将调取的数据以合理的方式展现出来。 12 | 13 | 本章主要聊聊模板,以及它们三者的交互。 14 | 15 | ## 小试牛刀 16 | 17 | 首先给 `Photo` 模型增加一个 `Meta` 内部类: 18 | 19 | ```python 20 | # /photo/models.py 21 | 22 | ... 23 | 24 | class Photo(models.Model): 25 | ... 26 | class Meta: 27 | ordering = ('-created',) 28 | ``` 29 | 30 | `ordering` 属性定义了图片数据的排序,比如这里是按创建时间倒序排列。 31 | 32 | 接着修改 `home()` 视图函数: 33 | 34 | ```python 35 | # /photo/views.py 36 | 37 | from django.shortcuts import render 38 | from photo.models import Photo 39 | 40 | def home(request): 41 | photos = Photo.objects.all() 42 | context = {'photos': photos} 43 | return render(request, 'photo/list.html', context) 44 | ``` 45 | 46 | - 前面章节说过,Django 的 **ORM** 尽可能让你脱离晦涩的数据库操作语句。比如这里,模型类直接用 `Photo.objects.all()` 取出所有的图片模型对象,就和操作普通的 Python 对象差不多,相当亲切。 47 | - 视图取出的数据最终要传递给模板,这些数据的集合被称为**上下文**(context),以字典的形式组织在一起。 48 | - 将数据载入模板、填充上下文、返回响应体对象,这是常用的流程,因此 Django 提供了 `render()` 这个快捷函数。它的三个参数分别是当前请求体、模板所在路径和上下文字典。 49 | 50 | 接下来在项目根目录下新建 `/templates/photo/list.html` 模板文件: 51 | 52 | ```python 53 | # /templates/photo/list.html 54 | 55 | {% for photo in photos %} 56 | 57 | {% endfor %} 58 | ``` 59 | 60 | - 模板里的逻辑语法以 `{% .. %}` 形式组织。Django 在渲染 html 文件时,碰到 `{% .. %}` 就知道这不是展现给用户的文字或符号,而是模板的控制流语句。 61 | - 模板标签中的 `photos` 就是前面视图上下文中传递的 `photos` 数据。 62 | - 模板里的数据用 `{{ ... }}` 组织,它是实实在在的需要动态替换成展现给用户的内容,注意它和控制流标签的区别。注意看,标签里的 `photo` 就是 `Photo` 模型的实例,`.image` 是模型里定义的图片字段,`.url` 是字段中包含的图片路径。 63 | - 最后以 `{% endfor %}` 标志整个控制流的结束。 64 | 65 | > `` 这个就是普通的 `html` 标签了,教程虽然只会用到最基础的 html/css/javascript 知识,但篇幅有限不会展开讲解。如果阅读非常吃力建议先看一些相关基础。 66 | 67 | 模板虽然写好了,但是还没告诉 Django 模板文件的路径。默认的模板路径在每个 App 自己的路径中,但笔者习惯把所有模板文件集中在一起,因此需要改一下全局配置: 68 | 69 | ```python 70 | # /album/settings.py 71 | 72 | ... 73 | # 注意是修改此配置 74 | # 不是新增 75 | TEMPLATES = [ 76 | { 77 | 'DIRS': [BASE_DIR / 'templates'], 78 | ... 79 | }, 80 | ] 81 | ... 82 | ``` 83 | 84 | 这就完工了。 85 | 86 | 运行服务器看看:(请确保已经在后台保存了一些图片数据) 87 | 88 | ![](https://blog.dusaiphoto.com/dj-album-50-1.jpg) 89 | 90 | 虽然没有任何样式,导致图片太大直接撑爆了屏幕,但整个 MTV 流程顺利打通了。 91 | 92 | > 如果图片未正常显示,先打开浏览器的控制台看看有无 404 报错。有报错可能是前面章节有关媒体文件路径配置不正确(MEDIA_ROOT 等),无报错则可能是其他原因。 93 | 94 | ## 模板复用 95 | 96 | 模板文件实质上是 `html` 文件。 Django 会按照模板标签的规则,将里面的标签文本替换成对应的数据。 97 | 98 | 既然是普通的 html 文件,那么写的时候还是得按照其书写规范,不要乱来。 99 | 100 | 一个标准的 html 文档至少要包含下面的内容: 101 | 102 | ```html 103 | 104 | 105 | 106 | 107 | My test page 108 | 109 | 110 |

This is my page

111 | 112 | 113 | ``` 114 | 115 | 因此前面测试写的那个 `list.html` 得修改。 116 | 117 | 另一方面,html 文件与 Python 代码一样,很多地方是可以复用的。比如需要载入的资源啊、全局的基础样式啊、页眉页脚啊这些。因此模板也提供了办法来实现其复用,让我们实践看看。 118 | 119 | ### 基础模板 120 | 121 | 首先来写所有模板的“父类”,即全站的基础模板 `/templates/base.html` : 122 | 123 | ```html 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | {% block title %}{% endblock title %} 132 | 133 | 138 | 139 | 140 | 141 | {% include 'header.html' %} 142 | 143 | {% block content %}{% endblock content %} 144 | 145 | {% include 'footer.html' %} 146 | 147 | 151 | 156 | 157 | {% block scripts %}{% endblock scripts %} 158 | 159 | 164 | 165 | 166 | ``` 167 | 168 | 有点长但不复杂,来分解一下: 169 | 170 | - 它是个规范的 html 文档。 171 | - `{% block ... %}` 给“继承”它的“模板子类”预留出空间,表示这个位置的内容将由“子类”来填充。 172 | - `{% include '...' %}` 表示在这个位置,插入另外一个模板文件。也就是页眉和页脚了。 173 | - 它远程引用了 `Bootstrap` 的 css/js 文件。 [Bootstrap](https://getbootstrap.com/) 是非常流行的前端响应式框架,上手极其简单(直接在官网抄示例代码),用它可以非常高效写出屏幕自适应的现代化页面。 174 | - 引入了后续卡片效果要用的瀑布流插件 `masonry.js` 。 175 | - 将全局背景设置为深灰色。 176 | 177 | ### 页眉和页脚 178 | 179 | 理论上页眉和页脚也是全站点共用的,也可以直接写在 `base.html` 里。不过为了保持干净,还是独立出来好了。 180 | 181 | 首先是新建**页眉**模板文件: 182 | 183 | ```html 184 | 185 | 186 | 187 | 195 | ``` 196 | 197 | 非常简单,就是一个普通的 Bootstrap 导航栏标签。 198 | 199 | 注意此文件的路径,和 `base.html` 里的 `{% include 'header.html' %}` 要能够对应。 200 | 201 | 接着是**页脚**: 202 | 203 | ```html 204 | 205 | 206 | 207 |



208 |
209 |
210 |

211 | Copyright © www.dusaiphoto.com 2021 212 |

213 |
214 |
215 | ``` 216 | 217 | 也很简单,是一个固定在底部的横幅。 218 | 219 | ### 图片列表 220 | 221 | 最后才是正儿八经的图片列表模板。 222 | 223 | 修改 `list.html` 文件如下: 224 | 225 | ```html 226 | 227 | 228 | {% extends "base.html" %} 229 | {% block title %}首页{% endblock title %} 230 | 231 | {% block content %} 232 | {% for photo in photos %} 233 | 234 | {% endfor %} 235 | {% endblock content %} 236 | 237 | ``` 238 | 239 | - `{% extends "base.html" %}` 表示此模板“继承”自 `base.html` 。 240 | - 注意看 `{% block title %}` 等标签和 `base.html` 的对应关系。 241 | 242 | 重启服务器看看效果: 243 | 244 | ![](https://blog.dusaiphoto.com/dj-album-50-2.jpg) 245 | 246 | 绕了一圈,除了页眉页脚,看上去似乎没啥变化,但是模板文件的结构变清晰了。 247 | 248 | ## 模板布局 249 | 250 | 上面这种撑爆屏幕的图片效果肯定是没法用的。 251 | 252 | 我们按照 Bootstrap 官方给的[卡片示例代码](https://getbootstrap.com/docs/5.0/components/card/)稍微改一下: 253 | 254 | ```html 255 | 256 | 257 | {% extends "base.html" %} 258 | {% block title %}首页{% endblock title %} 259 | 260 | {% block content %} 261 |
262 |
263 | {% for photo in photos %} 264 |
265 |
266 | 271 |
272 |
273 | {% endfor %} 274 |
275 |
276 | {% endblock content %} 277 | ``` 278 | 279 | 在后台中再多添加一些图片数据。 280 | 281 | > 教程示例均来自[pixabay](https://pixabay.com/zh/illustrations/)免费商用图片,此致感谢。 282 | 283 | 刷新页面看看效果: 284 | 285 | ![](https://blog.dusaiphoto.com/dj-album-50-3.jpg) 286 | 287 | Bootstrap 把复杂的布局技术隐藏起来,你只需要引入它并按照它的规则修改 `class` 等属性就可以获得非常漂亮、屏幕自适应的界面。 288 | 289 | ## 总结 290 | 291 | 目前为止,我们只写了非常少的 Python 代码(不到三十行),和非常简单的 html 代码,就获得了像模像样的相册网站了,说不定可以拿去交付(忽悠)甲方了。 Django 和 Bootstrap 的配合还不错吧? 292 | 293 | 下一章将通过照片细节展示功能,探讨模态与动画技术。 294 | 295 | > 点赞和吐槽?评论区来告诉我! 296 | -------------------------------------------------------------------------------- /md/60-模态与动画.md: -------------------------------------------------------------------------------- 1 | 如何展示图片的细节有很多种方法。作为一个小巧的相册网站,不需要给每张照片单独的详情页面,而是用浮窗的形式就足够了。 2 | 3 | 本章将用模态窗和动画的搭配,实现单页面图片展示的功能。 4 | 5 | > 这更多的是模板部分的内容。 6 | 7 | ## 悬浮动画 8 | 9 | 动画是产品精致程度的体现,好的动画可以让用户感觉非常舒适。 10 | 11 | 随着前端技术的发展,仅靠 css 也可以实现相当精美的动画,并且有很多现成可参考的代码。 12 | 13 | 比如下面这段鼠标悬停后,元素悬浮的动画。 14 | 15 | 在项目根路径新建 `/static/hover.css` 文件,写入:(直接复制即可,暂时不用深究细节) 16 | 17 | ```css 18 | /* /static/hover.css */ 19 | 20 | /* 此样式来自 Hover.css */ 21 | /* https://ianlunn.github.io/Hover/ */ 22 | /* Float Shadow */ 23 | .hvr-float-shadow { 24 | display: inline-block; 25 | vertical-align: middle; 26 | -webkit-transform: perspective(1px) translateZ(0); 27 | transform: perspective(1px) translateZ(0); 28 | box-shadow: 0 0 1px rgba(0, 0, 0, 0); 29 | position: relative; 30 | -webkit-transition-duration: 0.3s; 31 | transition-duration: 0.3s; 32 | -webkit-transition-property: transform; 33 | transition-property: transform; 34 | } 35 | .hvr-float-shadow:before { 36 | pointer-events: none; 37 | position: absolute; 38 | z-index: -1; 39 | content: ''; 40 | top: 100%; 41 | left: 5%; 42 | height: 10px; 43 | width: 90%; 44 | opacity: 0; 45 | background: -webkit-radial-gradient(center, ellipse, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0) 80%); 46 | background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0) 80%); 47 | /* W3C */ 48 | -webkit-transition-duration: 0.3s; 49 | transition-duration: 0.3s; 50 | -webkit-transition-property: transform, opacity; 51 | transition-property: transform, opacity; 52 | } 53 | .hvr-float-shadow:hover, .hvr-float-shadow:focus, .hvr-float-shadow:active { 54 | -webkit-transform: translateY(-5px); 55 | transform: translateY(-5px); 56 | /* move the element up by 5px */ 57 | } 58 | .hvr-float-shadow:hover:before, .hvr-float-shadow:focus:before, .hvr-float-shadow:active:before { 59 | opacity: 1; 60 | -webkit-transform: translateY(5px); 61 | transform: translateY(5px); 62 | /* move the element down by 5px (it will stay in place because it's attached to the element that also moves up 5px) */ 63 | } 64 | ``` 65 | 66 | css 文件与媒体文件类似,都属于**静态文件**,Django 默认是不处理它们的。 67 | 68 | 因此需要修改全局配置,指定此类文件的存放路径:(就是 `hover.css` 的路径) 69 | 70 | ```python 71 | # /album/settings.py 72 | 73 | ... 74 | # 修改 75 | STATIC_URL = '/static/' 76 | # 新增 77 | STATICFILES_DIRS = ( 78 | BASE_DIR / 'static', 79 | ) 80 | ``` 81 | 82 | 接着同样的,将其注册到 url 路径中: 83 | 84 | ```python 85 | # /album/urls.py 86 | 87 | ... 88 | urlpatterns = [ 89 | ... 90 | ] 91 | 92 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 93 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 94 | ``` 95 | 96 | 然后就可以像下面这样,在 `base.html` 中引入它了: 97 | 98 | ```html 99 | 100 | 101 | ... 102 | 103 | ... 104 | {% load static %} 105 | 106 | 107 | ... 108 | ``` 109 | 110 | - `{% load static %}` 标签载入静态文件相关的全局配置。 111 | - `{% static 'hover.css' %}` 载入此 css 文件的实际地址。 112 | 113 | 使用也很简单,你想要哪个元素带有鼠标悬停悬浮效果,在元素标签里加 `class="hvr-float-shadow"` 就可以了。 114 | 115 | 像这样修改 `list.html` : 116 | 117 | ```html 118 | 119 | 120 | ... 121 | {% for photo in photos %} 122 |
123 |
124 | ... 125 |
126 |
127 | {% endfor %} 128 | ... 129 | ``` 130 | 131 | 刷新页面并将鼠标悬停在图片卡片上,它就有向上腾空的动画了。 132 | 133 | > 如果无此效果,检查浏览器控制台是否有 404 报错。有就表示静态文件载入失败。 134 | 135 | ## 模态窗 136 | 137 | 模态窗是指平铺在页面上的一个窗口,并且带有背景遮罩的效果。Bootstrap 自带的模态窗就非常够用了。 138 | 139 | 继续修改 `list.html` 代码对应位置: 140 | 141 | ```html 142 | 143 | 144 | ... 145 | {% block content %} 146 | ... 147 | {% for photo in photos %} 148 |
149 |
150 | 151 | 156 | 161 | 162 | 163 |
164 |
165 | {% endfor %} 166 | ... 167 | 168 | 169 | 170 | {% for photo in photos %} 171 | 172 | 185 | {% endfor %} 186 | 187 | 188 | {% endblock content %} 189 | ``` 190 | 191 | - 图片被超链接标签 `` 包裹,点击则触发模态窗打开的事件。 192 | - `data-bs-toggle` 属性指定了当前元素为模态窗。 193 | - `data-bs-target` 的值要**严格对应**模态窗的 `id` 值,Bootstrap 依据此来链接多个按钮和模态窗的映射关系。 194 | 195 | 保存后刷新页面,点击图片,漂亮的模态窗就滑动出来了: 196 | 197 | ![](https://blog.dusaiphoto.com/dj-album-60-1.jpg) 198 | 199 | ## 总结 200 | 201 | 本章的内容比较简单,代码量不多但是视觉效果是很好的,这就是动画的魅力。 202 | 203 | 模态窗的优点是页面不会跳转,这使得你的网站使用起来连续性非常好。跳转使用户感觉烦躁,并且说不定某次跳转时就离开你的页面了。另一方面,模态窗适合展示较少的信息,大量信息还是老老实实跳转页面吧。 204 | 205 | > 点赞 or 吐槽?来评论区! 206 | 207 | -------------------------------------------------------------------------------- /md/70-登录与登出.md: -------------------------------------------------------------------------------- 1 | 作为个人相册来说,通常只需要简单的用户管理就足够了,原因如下: 2 | 3 | - 具有操作资源权限的只有自己,不用考虑诸多网络安全的问题。 4 | - Django 后台提供了丰富的管理能力。 5 | - 评论等需要登录参与的功能可以托管给第三方。(比如 Github 的某些评论插件) 6 | 7 | 话虽如此,但前台登录退出功能还是要有的,后台总归没那么直观。 8 | 9 | 因此本章就来简单实现下管理员账户在前台的登入和登出。 10 | 11 | ## 后端代码 12 | 13 | 浏览器发起的 HTTP 请求,总共定义了八种请求类型,来表示对指定资源的不同操作方式。比如说前面章节在访问相册主页时,实际上提起的是 **GET** 请求。GET 请求被称为安全请求,因为按照规范,它不应该在服务器上产生任何结果,也就是不会修改信息。 14 | 15 | > 安全方法也被称作是**幂等的**。 16 | 17 | 其他常用的还有 **POST** 请求,它的使用又分两种情况。 18 | 19 | 一种情况是 POST 请求会修改服务器资源,比如发表新文章、修改用户数据等,另一种情况是请求会携带敏感信息。用户的登入登出就属于第二种情况。 20 | 21 | > GET 请求会将信息暴露在地址栏中,且更容易受到跨域等攻击。 22 | 23 | 因此首页的 `home()` 视图函数不仅要处理显示图片的 GET 请求,还要肩负用户登录管理的 POST 请求。 24 | 25 | 像这样修改 `views.py` 文件: 26 | 27 | ```python 28 | # /photo/views.py 29 | 30 | from django.shortcuts import render 31 | from photo.models import Photo 32 | from django.contrib.auth import authenticate, login, logout 33 | 34 | def home(request): 35 | photos = Photo.objects.all() 36 | context = {'photos': photos} 37 | 38 | # 处理登入登出的POST请求 39 | if request.method == 'POST': 40 | username = request.POST.get('username') 41 | password = request.POST.get('password') 42 | user = authenticate(request, username=username, password=password) 43 | # 登入 44 | if user is not None and user.is_superuser: 45 | login(request, user) 46 | # 登出 47 | isLogout = request.POST.get('isLogout') 48 | if isLogout == 'True': 49 | logout(request) 50 | 51 | return render(request, 'photo/list.html', context) 52 | ``` 53 | 54 | - 请求体 `request` 包含用户请求的相关信息。`request.method` 即表明了请求的类型。如果是登录相关的 POST 请求,那么进行下一步的处理。 55 | - `request.POST.get()` 可获取到 POST 请求中包含的数据。这里就是用户名和密码了。 56 | - 验证用户名、密码是否正确要用 `authenticate()` 方法。切记不能直接去比较模型中的字符串,因为密码是加密存储的。 57 | - 如果此用户存在并且是管理员,那么则 `login()` 函数登入。否则不进行处理。 58 | - 请求中还包含一个标志位 `isLogout` 用来判断用户是想登入还是登出。如果它为真,则 `logout()` 退出账号。 59 | 60 | 除此之外其他还是和之前一样了。 61 | 62 | 接下来是前端部分。 63 | 64 | ## 前端代码 65 | 66 | 登录功能一般会将入口放在页眉上,这样可以保证用户可以在任意网页位置进行登入登出操作。 67 | 68 | 因此修改 `header.html` 模板文件,变成下面这样: 69 | 70 | ```html 71 | 72 | 73 | 74 | 101 | 102 | 103 | {% if user.is_superuser %} 104 | 105 | 118 | 119 | 120 | {% else %} 121 | 122 | 144 | 145 | {% endif %} 146 | ``` 147 | 148 | 主要变化就是将导航栏里的网站标题变成了模态窗的入口。页面会根据用户的登录与否,决定显示登入还是登出的模态窗,并同时显示或隐藏“创建新相片”的按钮。 149 | 150 | 需要注意的点: 151 | 152 | - `{% if user.is_superuser %}` 判断当前是否是管理员用户,与视图中的 `user.is_superuser` 是相同的。在模板中要包裹在标签内。 153 | - 根据是否是管理员用户,模态窗的具体内容有所不同。这个内容就决定了用户是要登入还是登出。 154 | - 表单元素 `
` 中的 `action="{% url 'home' %}"` 说明此表单提交给当前页面。注意这里的 `'home'` ,就是当初我们在 `path()` 中给 url 赋予的名字。`method="post"` 说明表单提交的是 POST 请求。 155 | - 登出的模态窗有一个 `` 的隐藏数据,此数据不显示给用户,但是会悄悄提交 `isLogout="True"` 这个数据。就是靠它来确定登入登出状态的。 156 | - 注意每个表单都有 `{% csrf_token %}` 这么个东西,这是 Django 用来防止跨域攻击的小插件,默认情况下表单必须携带,否则会报 403 错误。 157 | 158 | 接下来测试。 159 | 160 | ## 测试 161 | 162 | 刷新页面,点击页眉中的标题: 163 | 164 | ![](https://blog.dusaiphoto.com/dj-album-70-1.jpg) 165 | 166 | 提示登录的模态窗就显示出来了。 167 | 168 | 输入管理员账号密码后,点击提交按钮,有如下变化: 169 | 170 | ![](https://blog.dusaiphoto.com/dj-album-70-2.jpg) 171 | 172 | 注意看页眉右边出现个加号,这就表示登录成功了。 173 | 174 | > 这个加号用于后续章节上传图片。目前暂时没有功能。 175 | 176 | 再次点击页眉标题,此时显示的就是提示退出登录的模态窗了。点击红色的提交按钮后,应该就正常退出了账户,右侧的小加号也消失了。 177 | 178 | ## 总结 179 | 180 | 本章学习了 Django 中如何处理用户的登录问题的。 181 | 182 | 你会发现后端的 Python 代码总是比前端 html 代码更少,很大一部分原因是 Django 很好的封装了通用的功能。**用户验证**就是个例子,你可以在后台中看看用户密码,它是以密文的形式存储的。但是你的代码中完全没管加密与解密的问题,直接调用内置的 `authenticate()` 函数就搞定了。 183 | 184 | > 登入登出功能也一样,Django 封装了具体的处理方式,你调用 `login()` 、 `logout()` 函数即可。 185 | 186 | 另一方面,你真的不用害怕大量的 html 代码。教程中涉及的内容大概就和 Word 文档排版的难度差不多,唯一区别就是 Word 排版是用鼠标点点点,而 html 将点点点的动作转化成了英文字母。多查阅一定看得懂。 187 | 188 | 下一章聊聊如何批量上传图片。 189 | 190 | > 点赞 or 吐槽?评论区告诉我! -------------------------------------------------------------------------------- /md/80-批量上传图片.md: -------------------------------------------------------------------------------- 1 | 虽然 Django 自带的后台已经能够上传图片了,但是默认只能单张上传。如果你照片比较多,实在是有点不方便。 2 | 3 | 现在让我们来试着实现**批量上传图片**的功能。 4 | 5 | ## 后端代码 6 | 7 | 上一章讲过 GET 和 POST 请求的区别。图片上传会修改服务器资源,因此应该用 POST 请求。 8 | 9 | 又鉴于 `home()` 视图函数已经肩负用户登录功能了,如果图片上传也在里面处理,会显得太乱。 10 | 11 | 因此在 `/photo/views.py` 里新写一个 `upload()` 视图,专门用来处理文件上传。 12 | 13 | 修改内容如下: 14 | 15 | ```python 16 | # /photo/views.py 17 | 18 | ... 19 | from django.shortcuts import render, redirect 20 | 21 | ... 22 | 23 | def upload(request): 24 | if request.method == 'POST' and request.user.is_superuser: 25 | images = request.FILES.getlist('images') 26 | for i in images: 27 | photo = Photo(image=i) 28 | photo.save() 29 | return redirect('home') 30 | ``` 31 | 32 | 和前面的用户名、密码这类普通的字符串数据不同,图片这类二进制文件数据在 `request` 中有专门的地方存放,即 `request.FILES` 了。因为图片可以是批量上传,所以是类似列表的有序集合,用 `.getlist('images')` 将此集合取出。 33 | 34 | 接下来就简单了。迭代这个图片列表,将它们全部都保存到模型中。调用 `photo.save()` 方法将其存储到数据库。 35 | 36 | 最后,`upload()` 视图并没有返回响应体,而是跳转到 `home()` 视图去了,继续执行 `home()` 视图中的代码了。参数 `'home'` 对应之前章节定义的 `path(... name='home')` 这个路径名。 37 | 38 | 接着例行惯例,需要给视图函数配置一个路径: 39 | 40 | ```python 41 | # /photo/urls.py 42 | 43 | ... 44 | from photo.views import home, upload 45 | ... 46 | urlpatterns = [ 47 | ... 48 | path('upload/', upload, name='upload'), 49 | ] 50 | ``` 51 | 52 | 接着去修改模板中的代码。 53 | 54 | ## 前端代码 55 | 56 | 上一章我们已经给上传文件预留了一个 “+” 号作为入口。 57 | 58 | 余下的工作就是将这个 “+” 号变成模态窗。 59 | 60 | 修改 `header.html` 模板: 61 | 62 | ```html 63 | 64 | 65 | 66 | 67 | 89 | 90 | 91 | 92 | {% if user.is_superuser %} 93 | 94 | ... 95 | 96 | 97 |
123 | 124 | {% else %} 125 | ... 126 | {% endif %} 127 | ``` 128 | 129 | "+" 号通过超链接,作为触发模态窗的按钮。之所以还是考虑用模态窗的形式,是因为上传表单的信息量太小,没必要做页面的跳转。 130 | 131 | 让我们仔细看看上传文件表单的构造。 132 | 133 | 首先看 `
` 标签里的东东: 134 | 135 | - `action="{% url 'photo:upload' %}"` 指定此表单提交的路径。再次注意看它是如何和 url 路径的名称对应的。 136 | - `method="post"` 说明这是个 POST 请求的表单。 137 | - `enctype="multipart/form-data"` 指定表单的编码方式(将文件以二进制上传),**必须**有它才能正常上传文件。 138 | 139 | 再看 `` 标签里: 140 | 141 | - `type="file"` 表示这是个文件上传控件。 142 | - `name="images" ` 注意它要和后端代码的 `.getlist('images')` 的参数对应。 143 | - `multiple="multiple"` 表示支持多文件上传。 144 | - `accept="image/*"` 文件类型为图片。 145 | 146 | 欧了,测试。 147 | 148 | ## 测试 149 | 150 | 刷新页面,点击加号: 151 | 152 | ![](https://blog.dusaiphoto.com/dj-album-80-1.jpg) 153 | 154 | 接下来就简单了,点击控件,随意选定多个图片,再点击提交按钮,图片就上传成功了。 155 | 156 | ## 总结 157 | 158 | 图片上传本身并不难,难的是对其进行安全的处理: 159 | 160 | - 如何裁剪图片尺寸? 161 | - 如何限制图片类型? 162 | - 如何判断图片里是否带有病毒? 163 | - 如何确保用户没有上传奇怪的小电影截图? 164 | 165 | 个人相册还好说,操作资源就自己,不会瞎折腾。但如果你要开发论坛或平台,这些都是要好好考虑的。 166 | 167 | 另一个现实的问题是,云服务器资源是昂贵的,作为草根网站,你的服务器带宽根本支撑不起庞大的图片流量。用户会在你的站点卡得吐血。教程末尾章节将会解决这个问题,在此之前,下一章先聊聊分页。 168 | 169 | > 点赞 or 吐槽?评论区和我聊! 170 | -------------------------------------------------------------------------------- /md/90-分页.md: -------------------------------------------------------------------------------- 1 | 正如你想得那样,分页作为一个通用功能,Django 也提供了内置的实现。 2 | 3 | Django 把程序员照顾得太好了,严重缩短了大家的工时。 4 | 5 | > 不996怎么升职加薪? 6 | 7 | ## 分页器 8 | 9 | 内置的分页器功能足够强大,配合模型用起来也是极其简单。 10 | 11 | 修改 `views.py` 文件: 12 | 13 | ```python 14 | # /photo/views.py 15 | 16 | ... 17 | from django.core.paginator import Paginator 18 | 19 | def home(request): 20 | # 已有代码 21 | photos = Photo.objects.all() 22 | # 新增分页代码 23 | paginator = Paginator(photos, 5) 24 | page_number = request.GET.get('page') 25 | paged_photos = paginator.get_page(page_number) 26 | # 将分页器对象传入上下文 27 | context = {'photos': paged_photos} 28 | 29 | # 后续其他已有代码 30 | ... 31 | 32 | ... 33 | ``` 34 | 35 | 你可以看到为了使用分页器,总共修改了四行代码(除去引入模块的语句)。从函数的第二行开始: 36 | 37 | - 将模型的查询集 `photos` 、以及你期望的每页的图片数量作为参数,实例化分页器 `Paginator`。 38 | - 从 GET 请求体中获取页码。 39 | - 根据页码,从分页器中取得对应页码的对象。 40 | - 将**分页后的对象**传递到模板。 41 | 42 | 欧了,改前端去。 43 | 44 | ## 前端代码 45 | 46 | 如果你觉得分页器仅仅是将列表做了个切片,那就错了。分页后的数据不仅具有原始查询集(queryset)的迭代功能,还包括了很多分页相关的元数据。 47 | 48 | 修改 `list.html` 模板,你就能看出来: 49 | 50 | ```html 51 | 52 | 53 | ... 54 | 55 | {% block content %} 56 |
57 | 58 |
59 | {% for photo in photos %} 60 | ... 61 | {% endfor %} 62 |
63 | 64 | 65 |
66 | 67 | {% if photos.has_previous %} 68 | 71 | « 72 | 73 | 76 | {{ photos.previous_page_number }} 77 | 78 | {% endif %} 79 | 80 | 81 | {{ photos.number }} 82 | 83 | 84 | {% if photos.has_next %} 85 | 88 | {{ photos.next_page_number }} 89 | 90 | 93 | » 94 | 95 | {% endif %} 96 | 97 |
98 |
99 | 100 | ... 101 | {% endblock content %} 102 | 103 | 104 | {% block scripts %} 105 | 120 | {% endblock scripts %} 121 | ``` 122 | 123 | 最主要的改动,就是在页面的底部增加了切换页码的入口。 124 | 125 | 需要注意的点: 126 | 127 | - 分页后的对象 `photos` 拥有 `previous_page_number` 、 `number` 、 `has_previous` 等一系列有关分页的属性,非常方便。(具体作用看属性名就能懂) 128 | - 超链接用 `href="?page={{ photos.next_page_number }}" ` 给 GET 请求附加了数据,它在后端中通过 `request.GET.get('page')` 被获取到。 129 | - 代码末尾用 css 稍微修改了页码的颜色、文字大小等样式。(`.current` 对应 `class="current"` 的元素) 130 | 131 | 又欧了,测试去。 132 | 133 | ## 测试 134 | 135 | 刷新页面看看: 136 | 137 | ![](https://blog.dusaiphoto.com/dj-album-90-1.jpg) 138 | 139 | 页面底部已经有页码可供选择了。 140 | 141 | 切换它们,并注意观察浏览器地址栏的变化。 142 | 143 | ## 总结 144 | 145 | 经过几章节的开发,项目的核心功能都具备了:列表、详情、数据存储、批量上传、登录、分页样样不少。 146 | 147 | 看似真的可以拿去称霸朋友圈了,但还是那个最致命的问题没解决:个人服务器的带宽太有限了,根本支撑不起高清图片的流量。或许可以通过缩略图等手段缓解首页的卡顿,但是细节展示你总要高清大图吧,治标不治本。 148 | 149 | 解决此问题除了选择更大的带宽外,还可以通过CDN加速、负载均衡、对象存储等手段。 150 | 151 | 下一章我们将探讨其中一种方案:对象存储(云存储)。 152 | 153 | > 点赞 or 吐槽?评论区见! 154 | 155 | -------------------------------------------------------------------------------- /media/photo/20210722/coffee.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210722/coffee.jpg -------------------------------------------------------------------------------- /media/photo/20210723/italy-6349105_640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210723/italy-6349105_640.jpg -------------------------------------------------------------------------------- /media/photo/20210723/ortahisar-5678553_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210723/ortahisar-5678553_1280.jpg -------------------------------------------------------------------------------- /media/photo/20210723/portrait-5601950_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210723/portrait-5601950_1280.jpg -------------------------------------------------------------------------------- /media/photo/20210723/sea-6406047_640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210723/sea-6406047_640.jpg -------------------------------------------------------------------------------- /media/photo/20210723/sup-6421284_640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210723/sup-6421284_640.jpg -------------------------------------------------------------------------------- /media/photo/20210723/versailles-6469580_640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210723/versailles-6469580_640.jpg -------------------------------------------------------------------------------- /media/photo/20210723/woman-6373424_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210723/woman-6373424_1280.jpg -------------------------------------------------------------------------------- /media/photo/20210723/woman-6466382_640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210723/woman-6466382_640.jpg -------------------------------------------------------------------------------- /media/photo/20210726/carousel-6402074_640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210726/carousel-6402074_640.jpg -------------------------------------------------------------------------------- /media/photo/20210726/children-moc-chau-2099536_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210726/children-moc-chau-2099536_1280.jpg -------------------------------------------------------------------------------- /media/photo/20210726/desert-5720527_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210726/desert-5720527_1280.jpg -------------------------------------------------------------------------------- /media/photo/20210726/engineer-4922781_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210726/engineer-4922781_1280.jpg -------------------------------------------------------------------------------- /media/photo/20210726/eye-6399571_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210726/eye-6399571_1280.jpg -------------------------------------------------------------------------------- /media/photo/20210726/fashion-6251535_640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210726/fashion-6251535_640.jpg -------------------------------------------------------------------------------- /media/photo/20210726/flower-4774929_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210726/flower-4774929_1280.jpg -------------------------------------------------------------------------------- /media/photo/20210726/mountain-6241333_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210726/mountain-6241333_1280.jpg -------------------------------------------------------------------------------- /media/photo/20210726/palmtrees-6388901_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210726/palmtrees-6388901_1280.jpg -------------------------------------------------------------------------------- /media/photo/20210726/wheat-6329586_640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/photo/20210726/wheat-6329586_640.jpg -------------------------------------------------------------------------------- /media/repo/readme-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/repo/readme-1.gif -------------------------------------------------------------------------------- /media/repo/readme-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/repo/readme-2.gif -------------------------------------------------------------------------------- /media/repo/readme-3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/media/repo/readme-3.gif -------------------------------------------------------------------------------- /photo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/photo/__init__.py -------------------------------------------------------------------------------- /photo/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from photo.models import Photo 3 | 4 | admin.site.register(Photo) 5 | -------------------------------------------------------------------------------- /photo/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PhotoConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'photo' 7 | -------------------------------------------------------------------------------- /photo/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-07-22 08:31 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Photo', 17 | fields=[ 18 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('image', models.ImageField(upload_to='photo/%Y%m%d/')), 20 | ('created', models.DateTimeField(default=django.utils.timezone.now)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /photo/migrations/0002_alter_photo_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-07-29 06:39 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('photo', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='photo', 15 | options={'ordering': ('-created',)}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /photo/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklens/django-album-tutorial/2828d8c3f6ed55469d932329adcf90abc60374ef/photo/migrations/__init__.py -------------------------------------------------------------------------------- /photo/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.timezone import now 3 | 4 | class Photo(models.Model): 5 | image = models.ImageField(upload_to='photo/%Y%m%d/') 6 | created = models.DateTimeField(default=now) 7 | 8 | def __str__(self): 9 | return self.image.name 10 | 11 | class Meta: 12 | ordering = ('-created',) -------------------------------------------------------------------------------- /photo/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /photo/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from photo.views import ( 3 | home, 4 | upload, 5 | oss_home, 6 | fetch_photos, 7 | ) 8 | 9 | from django.views.generic import TemplateView 10 | 11 | app_name = 'photo' 12 | 13 | urlpatterns = [ 14 | path('', home, name='home'), 15 | path('upload/', upload, name='upload'), 16 | path('oss-home/', oss_home, name='oss_home'), 17 | path( 18 | 'endless-home/', 19 | TemplateView.as_view(template_name='photo/endless_list.html'), 20 | name='endless_home' 21 | ), 22 | path('fetch/', fetch_photos, name='fetch'), 23 | ] -------------------------------------------------------------------------------- /photo/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | from photo.models import Photo 3 | from django.contrib.auth import authenticate, login, logout 4 | 5 | from django.core.paginator import Paginator 6 | 7 | 8 | import oss2 9 | 10 | # 填入阿里云账号的 11 | # auth = oss2.Auth('', '') 12 | # 填入 OSS 的 <域名> 和 13 | # bucket = oss2.Bucket(auth, '', '') 14 | 15 | class ObjIterator(oss2.ObjectIteratorV2): 16 | # 初始化时立即抓取图片数据 17 | def __init__(self, bucket): 18 | super().__init__(bucket) 19 | self.fetch_with_retry() 20 | 21 | # 分页要求实现__len__ 22 | def __len__(self): 23 | return len(self.entries) 24 | 25 | # 分页要求实现__getitem__ 26 | def __getitem__(self, key): 27 | return self.entries[key] 28 | 29 | # 修改图片排序方式 30 | def _fetch(self): 31 | result = self.bucket.list_objects_v2(prefix=self.prefix, 32 | delimiter=self.delimiter, 33 | continuation_token=self.next_marker, 34 | start_after=self.start_after, 35 | fetch_owner=self.fetch_owner, 36 | encoding_type=self.encoding_type, 37 | max_keys=self.max_keys, 38 | headers=self.headers) 39 | self.entries = result.object_list + [oss2.models.SimplifiedObjectInfo(prefix, None, None, None, None, None) 40 | for prefix in result.prefix_list] 41 | # 让图片以上传时间倒序 42 | self.entries.sort(key=lambda obj: -obj.last_modified) 43 | 44 | return result.is_truncated, result.next_continuation_token 45 | 46 | def oss_home(request): 47 | raise ValueError(""" 48 | 请确保 /photo/views.py 中有关阿里云的信息填写正确。 49 | (即 auth 和 bucket 属性中的信息)。 50 | 完成后将它们取消注释,并删除此行raise代码。""") 51 | 52 | photos = ObjIterator(bucket) 53 | paginator = Paginator(photos, 6) 54 | page_number = request.GET.get('page') 55 | paged_photos = paginator.get_page(page_number) 56 | context = {'photos': paged_photos} 57 | 58 | 59 | # 省略登入登出的POST请求代码 60 | # ... 61 | 62 | return render(request, 'photo/oss_list.html', context) 63 | 64 | 65 | 66 | def home(request): 67 | photos = Photo.objects.all() 68 | paginator = Paginator(photos, 5) 69 | page_number = request.GET.get('page') 70 | paged_photos = paginator.get_page(page_number) 71 | context = {'photos': paged_photos} 72 | 73 | # 处理登入登出的POST请求 74 | if request.method == 'POST': 75 | username = request.POST.get('username') 76 | password = request.POST.get('password') 77 | user = authenticate(request, username=username, password=password) 78 | # 登入 79 | if user is not None and user.is_superuser: 80 | login(request, user) 81 | # 登出 82 | isLogout = request.POST.get('isLogout') 83 | if isLogout == 'True': 84 | logout(request) 85 | return render(request, 'photo/list.html', context) 86 | 87 | 88 | def upload(request): 89 | if request.method == 'POST' and request.user.is_superuser: 90 | images = request.FILES.getlist('images') 91 | for i in images: 92 | photo = Photo(image=i) 93 | photo.save() 94 | return redirect('home') 95 | 96 | 97 | 98 | 99 | 100 | from django.http import JsonResponse 101 | 102 | # 无限滚动 103 | def fetch_photos(request): 104 | photos = Photo.objects.values() 105 | paginator = Paginator(photos, 4) 106 | page_number = int(request.GET.get('page')) 107 | data = {} 108 | 109 | # 页码正确才返回数据 110 | if page_number <= paginator.num_pages: 111 | paged_photos = paginator.get_page(page_number) 112 | data.update({'photos': list(paged_photos)}) 113 | 114 | return JsonResponse(data) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aliyun-python-sdk-core==2.13.35 2 | aliyun-python-sdk-kms==2.15.0 3 | asgiref==3.4.1 4 | certifi==2021.5.30 5 | cffi==1.14.6 6 | charset-normalizer==2.0.3 7 | crcmod==1.7 8 | cryptography==3.4.7 9 | Django==3.2.5 10 | idna==3.2 11 | jmespath==0.10.0 12 | oss2==2.15.0 13 | Pillow==8.3.1 14 | pycparser==2.20 15 | pycryptodome==3.10.1 16 | pytz==2021.1 17 | requests==2.26.0 18 | six==1.16.0 19 | sqlparse==0.4.1 20 | urllib3==1.26.6 21 | -------------------------------------------------------------------------------- /static/bounds.js: -------------------------------------------------------------------------------- 1 | const checkForObserver = () => { 2 | if (!('IntersectionObserver' in window)) { 3 | throw new Error(` 4 | bounds.js requires IntersectionObserver on the global object. 5 | IntersectionObserver is unavailable in IE and other older 6 | versions of browsers. 7 | See https://github.com/ChrisCavs/bounds.js/blob/master/README.md 8 | for more compatibility information. 9 | `) 10 | } 11 | } 12 | 13 | const getMargins = (margins = {}) => { 14 | const { 15 | top = 0, 16 | right = 0, 17 | bottom = 0, 18 | left = 0 19 | } = margins 20 | return `${top}px ${right}px ${bottom}px ${left}px` 21 | } 22 | 23 | const noOp = () => {} 24 | 25 | const bound = (options) => { 26 | return new Boundary(options) 27 | } 28 | 29 | class Boundary { 30 | constructor({root, margins, threshold, onEmit} = {}) { 31 | checkForObserver() 32 | 33 | const marginString = getMargins(margins) 34 | const options = { 35 | root: root || null, 36 | rootMargin: marginString, 37 | threshold: threshold || 0.0, 38 | } 39 | 40 | this.nodes = [] 41 | this.onEmit = onEmit || noOp 42 | this.observer = new IntersectionObserver( 43 | this._emit.bind(this), 44 | options 45 | ) 46 | } 47 | 48 | // API 49 | watch(el, onEnter=noOp, onLeave=noOp) { 50 | const data = { 51 | el, 52 | onEnter, 53 | onLeave, 54 | } 55 | 56 | this.nodes.push(data) 57 | this.observer.observe(el) 58 | 59 | return data 60 | } 61 | 62 | unWatch(el) { 63 | const index = this._findByNode(el, true) 64 | 65 | if (index !== -1) { 66 | this.nodes.splice(index, 1) 67 | this.observer.unobserve(el) 68 | } 69 | 70 | return this 71 | } 72 | 73 | check(el) { 74 | const data = this._findByNode(el) || {} 75 | return data.history 76 | } 77 | 78 | clear() { 79 | this.nodes = [] 80 | this.observer.disconnect() 81 | 82 | return this 83 | } 84 | 85 | static checkCompatibility() { 86 | checkForObserver() 87 | } 88 | 89 | // HELPERS 90 | _emit(events) { 91 | const actions = events.map(event => { 92 | const data = this._findByNode(event.target) 93 | const ratio = event.intersectionRatio 94 | 95 | data.history = event.isIntersecting 96 | 97 | event.isIntersecting 98 | ? data.onEnter(ratio) 99 | : data.onLeave(ratio) 100 | 101 | return { 102 | el: event.target, 103 | inside: event.isIntersecting, 104 | outside: !event.isIntersecting, 105 | ratio: event.intersectionRatio 106 | } 107 | }) 108 | 109 | this.onEmit(actions) 110 | } 111 | 112 | _findByNode(el, returnIndex=false) { 113 | const func = returnIndex ? 'findIndex' : 'find' 114 | 115 | return this.nodes[func](node => { 116 | return node.el.isEqualNode(el) 117 | }) 118 | } 119 | } 120 | 121 | // export default bound -------------------------------------------------------------------------------- /static/hover.css: -------------------------------------------------------------------------------- 1 | /* 此样式来自 Hover.css */ 2 | /* From: https://ianlunn.github.io/Hover/ */ 3 | /* Float Shadow */ 4 | .hvr-float-shadow { 5 | display: inline-block; 6 | vertical-align: middle; 7 | -webkit-transform: perspective(1px) translateZ(0); 8 | transform: perspective(1px) translateZ(0); 9 | box-shadow: 0 0 1px rgba(0, 0, 0, 0); 10 | position: relative; 11 | -webkit-transition-duration: 0.3s; 12 | transition-duration: 0.3s; 13 | -webkit-transition-property: transform; 14 | transition-property: transform; 15 | } 16 | .hvr-float-shadow:before { 17 | pointer-events: none; 18 | position: absolute; 19 | z-index: -1; 20 | content: ''; 21 | top: 100%; 22 | left: 5%; 23 | height: 10px; 24 | width: 90%; 25 | opacity: 0; 26 | background: -webkit-radial-gradient(center, ellipse, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0) 80%); 27 | background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0) 80%); 28 | /* W3C */ 29 | -webkit-transition-duration: 0.3s; 30 | transition-duration: 0.3s; 31 | -webkit-transition-property: transform, opacity; 32 | transition-property: transform, opacity; 33 | } 34 | .hvr-float-shadow:hover, .hvr-float-shadow:focus, .hvr-float-shadow:active { 35 | -webkit-transform: translateY(-5px); 36 | transform: translateY(-5px); 37 | /* move the element up by 5px */ 38 | } 39 | .hvr-float-shadow:hover:before, .hvr-float-shadow:focus:before, .hvr-float-shadow:active:before { 40 | opacity: 1; 41 | -webkit-transform: translateY(5px); 42 | transform: translateY(5px); 43 | /* move the element down by 5px (it will stay in place because it's attached to the element that also moves up 5px) */ 44 | } -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}{% endblock title %} 7 | 8 | 13 | 14 | {% load static %} 15 | 16 | 17 | 18 | 19 | {% include 'header.html' %} 20 | 21 | {% block content %}{% endblock content %} 22 | 23 | {% include 'footer.html' %} 24 | 25 | 26 | 30 | 35 | 36 | 37 | 38 | 39 | {% block scripts %}{% endblock scripts %} 40 | 41 | 46 | 47 | -------------------------------------------------------------------------------- /templates/footer.html: -------------------------------------------------------------------------------- 1 | 2 |



3 |
4 |
5 |

6 | Copyright © www.dusaiphoto.com 2021 7 |

8 |
9 |
-------------------------------------------------------------------------------- /templates/header.html: -------------------------------------------------------------------------------- 1 | 2 | 34 | 35 | 36 | {% if user.is_superuser %} 37 | 50 | 51 | 77 | 78 | 79 | {% else %} 80 | 102 | {% endif %} 103 | -------------------------------------------------------------------------------- /templates/photo/endless_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}首页{% endblock title %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 | 14 | 20 | 21 |
22 |
23 |
24 | 25 | 26 | 39 |
40 | 41 |
42 | 43 | {% endblock content %} 44 | 45 | {% block scripts %} 46 | 127 | {% endblock scripts %} -------------------------------------------------------------------------------- /templates/photo/list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}首页{% endblock title %} 3 | 4 | {% block content %} 5 |
6 |
7 | {% for photo in photos %} 8 |
9 |
10 | 15 | 20 | 21 |
22 |
23 | {% endfor %} 24 |
25 | 26 |
27 | 28 | {% if photos.has_previous %} 29 | 32 | « 33 | 34 | 37 | {{ photos.previous_page_number }} 38 | 39 | {% endif %} 40 | 41 | 42 | {{ photos.number }} 43 | 44 | 45 | {% if photos.has_next %} 46 | 49 | {{ photos.next_page_number }} 50 | 51 | 54 | » 55 | 56 | {% endif %} 57 | 58 |
59 |
60 | 61 | {% for photo in photos %} 62 | 63 | 76 | {% endfor %} 77 | {% endblock content %} 78 | 79 | {% block scripts %} 80 | 97 | 98 | 106 | {% endblock scripts %} 107 | -------------------------------------------------------------------------------- /templates/photo/oss_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}首页{% endblock title %} 3 | 4 | {% block content %} 5 |
6 |
7 | {% for photo in photos %} 8 |
9 |
10 | 15 | 20 | 21 |
22 |
23 | {% endfor %} 24 |
25 | 26 |
27 | 28 | {% if photos.has_previous %} 29 | 32 | « 33 | 34 | 37 | {{ photos.previous_page_number }} 38 | 39 | {% endif %} 40 | 41 | 42 | {{ photos.number }} 43 | 44 | 45 | {% if photos.has_next %} 46 | 49 | {{ photos.next_page_number }} 50 | 51 | 54 | » 55 | 56 | {% endif %} 57 | 58 |
59 |
60 | 61 | {% for photo in photos %} 62 | 63 | 76 | {% endfor %} 77 | {% endblock content %} 78 | 79 | {% block scripts %} 80 | 95 | 96 | 104 | 105 | {% endblock scripts %} 106 | --------------------------------------------------------------------------------