Django + React 全栈开发 demo

视频 https://www.youtube.com/watch?v=c-QsfbznSXI 笔记

在windows 系统上开发此项目,Linux 命令有所不同。先写 Django,后写 React。
此项目实现的功能是,用户可以注册并登录网站,创建或删除 note,note 包含 title和 content。

文章目录

Django 后端

1. 创建虚拟环境

以安装必要的 python 包: python -m venv env, 此命令将生成一个文件夹 env.

2. 激活虚拟环境: ./env/Scripts/activateactivate

命令运行成功之后,终端行之前将出现 (env) 前缀:

在这里插入图片描述

3. 安装项目依赖:

  1. 首先在项目文件夹中新建文件 requirements.txt,含有项目所需的所有包:
asgiref
Django
django-cors-headers
djangorestframework
djangorestframework-simplejwt
PyJWT
pytz
sqlparse
psycopg2-binary
python-dotenv

其中:
django-cors-headers: 用于解决 cross origin request 问题
psycopg2-binary: postgreSQL(postgres, pg) 相关
python-dotenv: 用于加载环境变量

  1. 安装以上的包: pip install -r requirements.txt

4. 新建 Django 工程

4.1 新建工程 backend

运行命令:django-admin startproject backend ,此命令将生成一个新目录 backend

然后在此新的backend目录中新建名称为api 的 app:python manage.py startapp api

(env) PS D:\yt\django\django-react-tutorial> django-admin startproject backend
(env) PS D:\yt\django\django-react-tutorial> cd .\backend\
(env) PS D:\yt\django\django-react-tutorial\backend> python manage.py startapp api

Django 中的 app:一个 Django 由若干 app 组成,例如实现 authentication 的 app,一个组件也可以是一个 app, 这些 app 用于组织客制化的 code,对项目的代码实现逻辑上的划分。

工程结构:

在这里插入图片描述

4.2 设置 settings.py

这里用#1标记增加的或修改过的代码:

"""
Django settings for backend project.

Generated by 'django-admin startproject' using Django 5.0.6.

For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""

from pathlib import Path
from datetime import timedelta  # 1
from dotenv import load_dotenv  # 1
import os  # 1

load_dotenv()  # 1


# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-r$)xv6rc71731q(5d)y3!!b*m=78d*fp*m9l0$-_nua(26m5q("

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = ["*"]  # 1 allow any host to host our django application

# 1 JWT tokens related
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}
# 1 JWT tokens related
SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
}

# Application definition

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "api",  # 1 新增的 app
    "rest_framework",  # 1
    "corsheaders",  # 1
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "corsheaders.middleware.CorsMiddleware",  # 1 middleware for cors
]

ROOT_URLCONF = "backend.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

WSGI_APPLICATION = "backend.wsgi.application"


# Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}


# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
    },
]


# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/

LANGUAGE_CODE = "en-us"

TIME_ZONE = "UTC"

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/

STATIC_URL = "static/"

# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

CORS_ALLOW_ALL_ORIGINS = True  # 1
CORS_ALLOWS_CREDENTIALS = True  # 1

4.3 JWT 认证

实现认证的步骤:

  1. 用户使用 username + password 登录前端
  2. 前端发送请求给后端,此请求包含第1步的 username + password
  3. 后端生成两个 token:即 jwt access token 和 jwt fresh token,发给前端
  4. 前端在 local storage 存储这两个token
  5. 前端之后每次发送请求,首先读取 local storage,
    • 如果 access_token 为空,要求前端重新登录
    • 如果 access_token 已过期,自动发送 refresh token 给后端某个 api,获得新的 access token 并存储到 local storage,将 access token 附加到请求头。如果由于 refresh token 过期等原因,未能从后端获得 access token,要求前端重新登录
    • 如果 access_token 非空且未过期,将 access token 附加到请求头,不需执行其他操作。
4.3.1 新建文件 ./backend/api/serializers.py:
from django.contrib.auth.models import User
from rest_framework import serializers

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "username", "password"]
        # don't want to return the password when returning the user
        extra_kwargs = {"password": {"write_only": True}}

    def create(self, validated_data):
        print(validated_data)
        user = User.objects.create_user(**validated_data)
        return user
4.3.2 修改./backend/api/views.py:
from django.shortcuts import render
from django.contrib.auth.models import User
from rest_framework import generics
from .serializers import UserSerializer
from rest_framework.permissions import IsAuthenticated, AllowAny


class CreateUserView(generics.CreateAPIView):
    queryset = User.objects.all()
    serializer_class = UserSerializer
    permission_classes = [AllowAny]
4.3.3 修改 ./backend/backend/urls.py:
from django.contrib import admin
from django.urls import path, include
from api.views import CreateUserView
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/user/register/", CreateUserView.as_view(), name="register"),
    path("api/token/", TokenObtainPairView.as_view(), name="get_token"),
    path("api/token/refresh/", TokenRefreshView.as_view(), name="refresh"),
    path("api-auth/", include("rest_framework.urls")),
]
4.3.4 数据库迁移

分两步:
Step 1: make migrations
终端执行命令:python manage.py makemigrations

(env) PS D:\yt\django\django-react-tutorial\backend> python manage.py makemigrations
No changes detected
(env) PS D:\yt\django\django-react-tutorial\backend> 

makemigrations 的作用是生成迁移文件,这些文件指定了需要执行的数据库迁移操作。

Step 2: apply migrations
终端执行命令:python manage.py migrate:

PS D:\yt\django\django-react-tutorial\backend> python manage.py migrate       
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  ............................
  Applying sessions.0001_initial... OK
PS D:\yt\django\django-react-tutorial\backend>

这两步用于配置数据库,确保正确设置所需的表格等等。

因此,每当连接到新数据库时,都需要再次执行上述相同的步骤来配置新数据库。

4.4 运行程序

运行命令:python manage.py runserver

PS D:\yt\django\django-react-tutorial\backend> python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
May 10, 2024 - 11:34:36
Django version 5.0.6, using settings 'backend.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CTRL-BREAK.

浏览器访问 http://127.0.0.1:8000/api/user/register

在这里插入图片描述
下一步要做的就是传一个 username 和 password 给 server,server 生成新的 user

4.5 实现用户注册

实现 sign in ,并从 server 获取 access token:

  1. http://127.0.0.1:8000/api/user/register 界面创建用户,填写 Username 和 Password 并 post,
    在这里插入图片描述
  2. http://127.0.0.1:8000/api/token/ 路径输入上述 Username 和 Password,就会生成 access token 和 refresh token:
    在这里插入图片描述
    前端将会存储这两个 token,以后前端每次向后端发送一条请求,请求里都必须附带 access token。(不受保护的路径除外)

复制上面的 refresh token,打开url:http://127.0.0.1:8000/api/token/refresh/ ,粘贴,提交, 可以得到新的 access token,如下图所示:

在这里插入图片描述

至此,用户注册、登录功能已经实现。Ctrl + C 停止服务器,接下来,实现创建 note 以及 删除 note 功能。

4.6 创建或删除 note 实现

4.6.1 修改./backend/api/models.py:
from django.db import models
from django.contrib.auth.models import User


class Note(models.Model):
    title = models.CharField(max_length=100)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notes")

    def __str__(self):
        return self.title

这里,一个 author 可以有多个 note,是 one-many 的关系。
ForeignKey 是说,一个 note 链接到 一个 user,
on_delete=models.CASCADE 含义:如果删除某个 user,那么,将同时删除此用户的全部 note。
related_name="notes" 含义: notes 字段引用所有的 note, 通过 .notes 可以获得一个用户创建的全部 note 对象。

4.6.2 修改文件./backend/api/serializers.py:
from django.contrib.auth.models import User
from rest_framework import serializers
from .models import Note 


class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "username", "password"]
        # don't want to return the password when returning the user
        # 能写不能读
        extra_kwargs = {"password": {"write_only": True}}

    def create(self, validated_data):
        print(validated_data)
        user = User.objects.create_user(**validated_data)
        return user


class NoteSerializer(serializers.ModelSerializer):
    class Meta:
        model = Note
        fields = ["id", "title", "content", "created_at", "author"]
        # 能读不能写
        extra_kwargs = {"author": {"read_only": True}}
4.6.3 修改文件 .\backend\api\views.py
from django.shortcuts import render
from django.contrib.auth.models import User
from rest_framework import generics
from .serializers import UserSerializer, NoteSerializer
from rest_framework.permissions import IsAuthenticated, AllowAny
from .models import Note

# 创建 note
# ListCreateAPIView, do two things:
# listing all notes created by a user or create a new note
class NoteListCreate(generics.ListCreateAPIView):
    serializer_class = NoteSerializer
    # Cannot call this route, unless authenticated and pass a valid jwt token
    permission_classes = [IsAuthenticated]

    # overriding get_queryset(django docs)
    def get_queryset(self):
        user = self.request.user
        # get all notes written by this "user", that's what filter means
        return Note.objects.filter(author=user)

    # overriding perform_create(django docs)
    def perform_create(self, serializer):
        if serializer.is_valid():
            serializer.save(author=self.request.user)
        else:
            print(serializer.errors)

# 删除 note
class NoteDelete(generics.DestroyAPIView):
    serializer_class = NoteSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        user = self.request.user
        return Note.objects.filter(author=user)


class CreateUserView(generics.CreateAPIView):
    queryset = User.objects.all()
    serializer_class = UserSerializer
    permission_classes = [AllowAny]

4.6.4 新建文件 ./backend/api/urls.py:
from django.urls import path
from . import views

urlpatterns = [
    path("notes/", views.NoteListCreate.as_view(), name="note-list"),
    path("notes/delete/<int:pk>/", views.NoteDelete.as_view(), name="delete-note"),
]
4.6.5 链接 url,修改: ./backend/backend/urls.py:
from django.contrib import admin
from django.urls import path, include
from api.views import CreateUserView
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/user/register/", CreateUserView.as_view(), name="register"),
    path("api/token/", TokenObtainPairView.as_view(), name="get_token"),
    path("api/token/refresh/", TokenRefreshView.as_view(), name="refresh"),
    path("api-auth/", include("rest_framework.urls")),
    # 新增行,如果非以上任何路径,将转发到文件:api.urls
    path("api/", include("api.urls")),
]
4.6.6 测试

再次运行数据库迁移的两条命令:ython manage.py makemigrations 以及 python manage.py migrate:

PS D:\yt\django\django-react-tutorial> cd backend
PS D:\yt\django\django-react-tutorial\backend> python manage.py makemigrations
Migrations for 'api':
  api\migrations\0001_initial.py
    - Create model Note
PS D:\yt\django\django-react-tutorial\backend> python manage.py migrate  
Operations to perform:
  Apply all migrations: admin, api, auth, contenttypes, sessions
Running migrations:
  Applying api.0001_initial... OK
PS D:\yt\django\django-react-tutorial\backend> 

运行程序:python manage.py runserver

在这里插入图片描述
因为未传 token, 所以出现上述错误提示。后端到这里先结束,接下来写前端。

React 前端

1 新建 React 工程

主目录运行命令:npm create vite@latest frontend -- --template react,将生成新的前端目录 frontend, 如下图:

在这里插入图片描述

2 安装必要的 React npm 包

进入 frontend 目录,运行命令: npm i axios react-router-dom jwt-decode,以下是运行结果,

PS D:\yt\django\django-react-tutorial> cd frontend 
PS D:\yt\django\django-react-tutorial\frontend> npm i axios react-router-dom jwt-decode
npm WARN EBADENGINE Unsupported engine {
npm WARN EBADENGINE   package: 'vite@5.2.11',
npm WARN EBADENGINE   required: { node: '^18.0.0 || >=20.0.0' },
npm WARN EBADENGINE   current: { node: 'v16.20.2', npm: '8.5.5' }
npm WARN EBADENGINE }
npm WARN EBADENGINE Unsupported engine {
npm WARN EBADENGINE   package: 'jwt-decode@4.0.0',
npm WARN EBADENGINE   required: { node: '>=18' },
npm WARN EBADENGINE   current: { node: 'v16.20.2', npm: '8.5.5' }
npm WARN EBADENGINE }
npm WARN EBADENGINE Unsupported engine {
npm WARN EBADENGINE   package: 'rollup@4.17.2',
npm WARN EBADENGINE   required: { node: '>=18.0.0', npm: '>=8.0.0' },
npm WARN EBADENGINE   current: { node: 'v16.20.2', npm: '8.5.5' }
npm WARN EBADENGINE }

added 291 packages, and audited 292 packages in 27s

104 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
PS D:\yt\django\django-react-tutorial\frontend> 

因为出现了若干版本相关的警告,所以接下来卸载 node, 重新下载最新版本 node-v20.13.1-x64.msi 并安装,然后运行如下两条命令卸载以上包,重新安装:

npm un axios react-router-dom jwt-decode
npm i axios react-router-dom jwt-decode

PS D:\yt\django\django-react-tutorial\frontend> npm un axios react-router-dom jwt-decode

removed 13 packages, and audited 279 packages in 1s

103 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
PS D:\yt\django\django-react-tutorial\frontend> npm i axios react-router-dom jwt-decode 

added 13 packages, and audited 292 packages in 3s

104 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
PS D:\yt\django\django-react-tutorial\frontend> 

3 组织 React 工程

3.1 删除 frontend/src/ 路径下的文件 index.css 以及 App.css

3.2 删除 App.jsx 中不需要的代码:

import React from "react";

function App() {
  return <></>;
}

export default App;

3.3 删除 main.jsx 中的import './index.css':

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

3.4 新建文件夹以及文件

src目录下新建3个文件夹 pages, styles, components,新建 2 个文件:constants.js, api.js,
frontend目录下新建文件 .env

在这里插入图片描述
constants.js:

export const ACCESS_TOKEN = "access"
export const REFRESH_TOKEN = "refresh"

acess token 和 refresh token 都将存储在 local storage 中,constants.js 文件用于访问存储的 token。

api.js 中写拦截器代码,拦截器用于拦截将要发送的任何请求,它会自动添加正确的请求头(request header),就不必每个请求中手动重复写相关代码。这里设置 axios 拦截器:

// api.js
import axios from "axios";
import { ACCESS_TOKEN } from "./constants";

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
});

api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem(ACCESS_TOKEN);
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

export default api;

.env 文件设置:

VITE_API_URL="http://localhost:8000"

3.5 设置受保护的路由

components 文件夹下新建文件 ProtectedRoute.jsx,访问此文件中的路由需要 token。

import { Navigate } from "react-router-dom";
import { jwtDecode } from "jwt-decode";
import api from "../api";
import { REFRESH_TOKEN, ACCESS_TOKEN } from "../constants";
import { useState, useEffect } from "react";

function ProtectedRoute({ children }) {
  const [isAuthorized, setIsAuthorized] = useState(null);

  useEffect(() => {
    auth().catch(() => setIsAuthorized(false));
  }, []);

  // refresh the access token for us automatically
  const refreshToken = async () => {
    const refreshToken = localStorage.getItem(REFRESH_TOKEN);
    try {
      const res = await api.post("/api/token/refresh/", {
        refresh: refreshToken,
      });
      if (res.status === 200) {
        localStorage.setItem(ACCESS_TOKEN, res.data.access);
        setIsAuthorized(true);
      } else {
        setIsAuthorized(false);
      }
    } catch (error) {
      console.log(error);
      setIsAuthorized(false);
    }
  };

  // check if we need to refresh the token or we are good to go
  const auth = async () => {
    const token = localStorage.getItem(ACCESS_TOKEN);
    if (!token) {
      setIsAuthorized(false);
      return;
    }
    const decoded = jwtDecode(token);
    const tokenExpiration = decoded.exp;
    const now = Date.now() / 1000;  // in seconds

    if (tokenExpiration < now) {
      await refreshToken();
    } else {
      setIsAuthorized(true);
    }
  };

  if (isAuthorized === null) {
    return <div>Loading...</div>;
  }

  return isAuthorized ? children : <Navigate to="/login" />;
}

export default ProtectedRoute;

3.6 设置 pages 目录

新建4 个文件:Home.jsx, Login.jsx, NotFound.jsx, Register.jsx:

在这里插入图片描述
这4个文件输入 rafce, 生成初始代码 (vscode 需要安装 extension: VS Code ES7+ React/Redux/React-Native/JS snippets):
register.jsx 为例:

import React from 'react'

const Register = () => {
  return (
    <div>Register</div>
  )
}

export default Register

3.7 实现 Navigation

用 3.6 的 4 个page页测试导航。

src\App.js:

import react from "react"
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"
import Login from "./pages/Login"
import Register from "./pages/Register"
import Home from "./pages/Home"
import NotFound from "./pages/NotFound"
import ProtectedRoute from "./components/ProtectedRoute"

function Logout() {
  localStorage.clear()
  return <Navigate to="/login" />
}

// Before registering, clear local storage to prevent from the possibilities 
// of reading old tokens
function RegisterAndLogout() {
  localStorage.clear()
  return <Register />
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route
          path="/"
          element={
            <ProtectedRoute>
              <Home />
            </ProtectedRoute>
          }
        />
        <Route path="/login" element={<Login />} />
        <Route path="/logout" element={<Logout />} />
        <Route path="/register" element={<RegisterAndLogout />} />
        <Route path="*" element={<NotFound />}></Route>
      </Routes>
    </BrowserRouter>
  )
}

export default App

3.8 测试 Navigation

npm run dev, 测试ok
在这里插入图片描述

3.9 register/login 实现

components 目录下新建文件: Form.jsx,此组件为 register/login 共用。

import { useState } from "react";
import api from "../api";
import { useNavigate } from "react-router-dom";
import { ACCESS_TOKEN, REFRESH_TOKEN } from "../constants";
import "../styles/Form.css";
import LoadingIndicator from "./LoadingIndicator";

function Form({ route, method }) {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [loading, setLoading] = useState(false);
  const navigate = useNavigate();

  const name = method === "login" ? "Login" : "Register";

  const handleSubmit = async (e) => {
    setLoading(true);
    e.preventDefault();

    try {
      const res = await api.post(route, { username, password });
      if (method === "login") {
        localStorage.setItem(ACCESS_TOKEN, res.data.access);
        localStorage.setItem(REFRESH_TOKEN, res.data.refresh);
        navigate("/");
      } else {
        navigate("/login");
      }
    } catch (error) {
      alert(error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="form-container">
      <h1>{name}</h1>
      <input
        className="form-input"
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        placeholder="Username"
      />
      <input
        className="form-input"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      {loading && <LoadingIndicator />}
      <button className="form-button" type="submit">
        {name}
      </button>
    </form>
  );
}

export default Form;

对应的 css styles/Form.css:

.form-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin: 50px auto;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  max-width: 400px;
}

.form-input {
  width: 90%;
  padding: 10px;
  margin: 10px 0;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-sizing: border-box;
}

.form-button {
  width: 95%;
  padding: 10px;
  margin: 20px 0;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.2s ease-in-out;
}

.form-button:hover {
  background-color: #0056b3;
}

login.jsx 代码:

import Form from "../components/Form";

function Login() {
  return <Form route="/api/token/" method="login" />;
}

export default Login;

register.jsx 代码:

import Form from "../components/Form";

function Register() {
  return <Form route="/api/user/register/" method="register" />;
}

export default Register;

新建两个文件 components/LoadingIndicator.jsxstyles/LoadingIndicator.css
components/LoadingIndicator.jsx :

import "../styles/LoadingIndicator.css"

const LoadingIndicator = () => {
    return <div className="loading-container">
        <div className="loader"></div>
    </div>
}

export default LoadingIndicator

styles/LoadingIndicator.css

.loader-container {
  display: flex;
  justify-content: center;
  align-items: center;
}

.loader {
  border: 5px solid #f3f3f3; /* Light grey */
  border-top: 5px solid #3498db; /* Blue */
  border-radius: 50%;
  width: 50px;
  height: 50px;
  animation: spin 2s linear infinite;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

3.10 测试 register/login

frontend 目录 运行 npm run dev,同时 backend 目录运行 python manage.py runserver
在 register 路径输入用户名和密码,将自动重定向到 login 路径,再次输入用户名和密码登录,local storage 将会出现 access token 和 refresh token ,测试 ok。
在这里插入图片描述

3.11 Homepage 实现

目标: http://localhost:5173/ 界面实际展示的内容应该如下所示,列出所有的 notes,允许删除,并且能创建新的 note:

在这里插入图片描述
src/pages/Home.jsx:

import { useState, useEffect } from "react";
import api from "../api";
import Note from "../components/Note";
import "../styles/Home.css";

function Home() {
  const [notes, setNotes] = useState([]);
  const [content, setContent] = useState("");
  const [title, setTitle] = useState("");

  useEffect(() => {
    getNotes();
  }, []);

  const getNotes = () => {
    api
      .get("/api/notes/")
      .then((res) => res.data)
      .then((data) => {
        setNotes(data);
        console.log(data);
      })
      .catch((err) => alert(err));
  };

  const deleteNote = (id) => {
    api
      .delete(`/api/notes/delete/${id}/`)
      .then((res) => {
        if (res.status === 204) alert("Note deleted!");
        else alert("Failed to delete note.");
        getNotes();
      })
      .catch((error) => alert(error));
  };

  const createNote = (e) => {
    e.preventDefault();
    api
      .post("/api/notes/", { content, title })
      .then((res) => {
        if (res.status === 201) alert("Note created!");
        else alert("Failed to make note.");
        getNotes();
      })
      .catch((err) => alert(err));
  };

  return (
    <div>
      <div>
        <h2>Notes</h2>
        {notes.map((note) => (
          <Note note={note} onDelete={deleteNote} key={note.id} />
        ))}
      </div>
      <h2>Create a Note</h2>
      <form onSubmit={createNote}>
        <label htmlFor="title">Title:</label>
        <br />
        <input
          type="text"
          id="title"
          name="title"
          required
          onChange={(e) => setTitle(e.target.value)}
          value={title}
        />
        <label htmlFor="content">Content:</label>
        <br />
        <textarea
          id="content"
          name="content"
          required
          value={content}
          onChange={(e) => setContent(e.target.value)}
        ></textarea>
        <br />
        <input type="submit" value="Submit"></input>
      </form>
    </div>
  );
}

export default Home;

Note.jsx :

import React from "react";
import "../styles/Note.css"

function Note({ note, onDelete }) {
    const formattedDate = new Date(note.created_at).toLocaleDateString("en-US")

    return (
        <div className="note-container">
            <p className="note-title">{note.title}</p>
            <p className="note-content">{note.content}</p>
            <p className="note-date">{formattedDate}</p>
            <button className="delete-button" onClick={() => onDelete(note.id)}>
                Delete
            </button>
        </div>
    );
}

export default Note

Note.css :

.note-container {
  padding: 10px;
  margin: 20px 0;
  border: 1px solid #ccc;
  border-radius: 5px;
}

.note-title {
  color: #333;
}

.note-content {
  color: #666;
}

.note-date {
  color: #999;
  font-size: 0.8rem;
}

.delete-button {
  background-color: #f44336; /* Red */
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 5px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.delete-button:hover {
  background-color: #d32f2f; /* Darker red */
}

省略一些文件例如 LoadingIndicator.jsx 等。

部署数据库

1 创建远程数据库服务

在网站 choreo上部署。左侧 tab 选择 database,然后创建 PostgreSQL 服务。

在这里插入图片描述

创建一个名称为 db 的数据库,虽然注明 0.03美元每小时,但不会真正收费,因为是免费的开发版,数据库每小时后会自动关闭服务,需手动开启。

在这里插入图片描述

copy 以上参数,paste 到 django 后端,实现数据库连接。

backend 目录新建文件: \backend\.env:

DB_HOST=
DB_PORT=
DB_USER=
DB_NAME=
DB_PWD=

然后复制粘贴choreo上的各项参数,所有的值全部放在双引号中。

2 连接远程数据库

修改backend\backend\settings.py中的数据库设置:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

改为:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.getenv("DB_NAME"),
        "USER": os.getenv("DB_USER"),
        "PASSWORD": os.getenv("DB_PWD"),
        "HOST": os.getenv("DB_HOST"),
        "PORT": os.getenv("DB_PORT"),
    }
}

依次运行命令:

python manage.py migrate,此命令连接远程数据库,因此需要较长时间,这一命令完成后,可以像之前那样启动后端,但此时数据库是远程的 PostgreSQL.

python manage.py runserver,前端正常 work.

部署后端

1. 新建 github 仓库

需要将后端代码 push 到 github,之后,每次 commit 到 github, choreo 将自动更新部署。

1.1 新建 .gitignore 文件:

在这里插入图片描述

1.2 同时在 frontend\.gitignore 文件中也增加 env

backend\.gitignore

.env
db.sqlite3

1.3 新建 Choreo 配置文件:backend\.choreo\endpoints.yaml:

version: 0.1

endpoints:
  - name: "REST API"
    port: 8000
    type: REST
    networkVisibility: Public
    context: /

1.4 新建 Procfile, 用以设置执行此应用程序的命令, `

0.0.0.0` 表示允许在任意 origin 或公共地址上允许访问 app.

web: python manage.py runserver 0.0.0.0:8000

1.5 本地新建 git 仓库

  1. 将前端和后端代码都加到 git 中:
PS D:\yt\django\django-react-tutorial> git init
Initialized empty Git repository in D:/yt/django/django-react-tutorial/.git/
PS D:\yt\django\django-react-tutorial> git add .
warning: in the working copy of 'frontend/.eslintrc.cjs', LF will be replaced by CRLF the next time Git touches it
.....................
warning: in the working copy of 'frontend/vite.config.js', LF will be replaced by CRLF the next time Git touches it  
PS D:\yt\django\django-react-tutorial> git commit -m "first commit"
[master (root-commit) aa8caac] first commit
 59 files changed, 8381 insertions(+)
 create mode 100644 .gitignore
 .....
PS D:\yt\django\django-react-tutorial>
  1. 然后将主分支名称改为 main , github 要求使用此分支名:
PS D:\yt\django\django-react-tutorial> git branch
* master
PS D:\yt\django\django-react-tutorial> git branch -M main
PS D:\yt\django\django-react-tutorial> git branch
* main
PS D:\yt\django\django-react-tutorial> 
  1. github 新建一个名称为 "Django-React-Full-Stack` 的仓库,必须设为 “public", 因为 Choreo 免费开发版只支持访问 public 仓库。

  2. 执行 github 上列出的如下命令:

在这里插入图片描述

PS D:\yt\django\django-react-tutorial> git remote add origin https://github.com/alice201601/Django-React-Full-Stack.git
error: remote origin already exists.
PS D:\yt\django\django-react-tutorial> git push -u origin main                                                       
Enumerating objects: 74, done.

1.6 部署
Choreo 新建工程:Django-React-Tutorial,
创建两个组件,组件类型 backend 选 service,frontend 选 web application,
frontend:
在这里插入图片描述
Choreo 构建相应的组件,并部署。

部署前端

代码修改后,使用的 git 命令:

git push origin main 上传到 github

若干步骤都和 Choreo 相关,省略。

部署完成

Django + React + PostgreSQL,运行ok:

在这里插入图片描述

  • 25
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Django和Vue.js是两个非常流行的开发框架,它们结合使用可以实现全栈开发。下面给出一个关于Django Vue3全栈开发学习文档的简要说明。 首先,学习文档应该从基础开始,介绍Django和Vue.js的基本概念和用法。对于初学者来说,可以先学习Django的核心概念,比如模型、视图和模板。然后,学习如何使用Django建立数据模型、创建RESTful API,并且如何将数据渲染到模板中。 接下来,学习文档应该逐步介绍Vue.js的基础知识,包括Vue的实例、组件和指令等。学习者可以通过编写简单的Vue组件来加深对Vue.js的理解,并学习如何使用Vue.js来处理前端的交互逻辑。 然后,学习文档可以介绍如何将Django和Vue.js结合起来进行全栈开发。这包括如何在Django中配置前后端分离的开发环境,如何使用Django提供的API来处理数据的增删改查操作,以及如何使用Vue.js来渲染和处理前端界面。 除了基础知识外,学习文档还应该提供一些实践项目来帮助学习者巩固所学知识。这些项目可以是基于Django和Vue.js的实际应用,比如一个简单的博客系统或一个任务管理应用。通过实际项目的实践,学习者可以更好地理解和运用所学知识。 最后,学习文档应该补充一些额外的资源和参考资料,比如官方文档、书籍和在线教程等,以帮助学习者进一步扩展自己的知识。 总而言之,一本Django Vue3全栈开发的学习文档应该从基础概念开始,逐步引导学习者掌握Django和Vue.js的用法,并通过实践项目和额外资源提供进一步的学习支持。希望以上的回答对您有所帮助。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值