|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
x
引言
前后端分离是现代Web应用开发的主流架构模式,它将用户界面(UI)与业务逻辑、数据处理分离开来,使得前后端可以独立开发、测试和部署。Vue3作为当前流行的前端框架,以其响应式数据绑定、组件化开发和轻量级特性受到开发者青睐;而Django作为Python生态中成熟的Web框架,以其” batteries-included “的理念和强大的后台管理能力著称。本文将详细介绍如何从零开始使用Vue3和Django构建一个完整的前后端分离应用,涵盖API设计、数据通信、状态管理及安全认证等关键环节。
环境搭建
前端环境准备
首先,确保你的系统已安装Node.js(推荐版本14.x或更高)。然后,使用npm安装Vue CLI:
检查Vue CLI版本:
后端环境准备
确保你的系统已安装Python(推荐版本3.8或更高)。然后,使用pip安装Django和Django REST framework:
- pip install django djangorestframework djangorestframework-simplejwt django-cors-headers
复制代码
Django后端开发
项目创建与配置
创建Django项目:
- django-admin startproject backend
- cd backend
复制代码
创建一个应用:
- python manage.py startapp api
复制代码
在backend/settings.py中,添加新创建的应用和必要的库到INSTALLED_APPS:
- INSTALLED_APPS = [
- 'django.contrib.admin',
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
- 'django.contrib.sessions',
- 'django.contrib.messages',
- 'django.contrib.staticfiles',
- 'rest_framework',
- 'rest_framework_simplejwt',
- 'corsheaders',
- 'api',
- ]
复制代码
添加中间件以处理CORS:
- MIDDLEWARE = [
- 'corsheaders.middleware.CorsMiddleware',
- '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',
- ]
复制代码
配置CORS设置:
- CORS_ALLOWED_ORIGINS = [
- "http://localhost:8080",
- "http://127.0.0.1:8080",
- ]
- CORS_ALLOW_CREDENTIALS = True
复制代码
配置REST framework和JWT认证:
- REST_FRAMEWORK = {
- 'DEFAULT_AUTHENTICATION_CLASSES': (
- 'rest_framework_simplejwt.authentication.JWTAuthentication',
- ),
- 'DEFAULT_PERMISSION_CLASSES': [
- 'rest_framework.permissions.IsAuthenticated',
- ],
- }
- from datetime import timedelta
- SIMPLE_JWT = {
- 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
- 'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
- 'ROTATE_REFRESH_TOKENS': False,
- 'BLACKLIST_AFTER_ROTATION': True,
- 'ALGORITHM': 'HS256',
- 'SIGNING_KEY': 'your-signing-key',
- 'VERIFYING_KEY': None,
- 'AUTH_HEADER_TYPES': ('Bearer',),
- 'USER_ID_FIELD': 'id',
- 'USER_ID_CLAIM': 'user_id',
- 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
- 'TOKEN_TYPE_CLAIM': 'token_type',
- }
复制代码
模型设计
在api/models.py中定义数据模型。假设我们正在构建一个简单的任务管理系统:
- from django.db import models
- from django.contrib.auth.models import User
- class Task(models.Model):
- STATUS_CHOICES = (
- ('pending', 'Pending'),
- ('in_progress', 'In Progress'),
- ('completed', 'Completed'),
- )
-
- PRIORITY_CHOICES = (
- ('low', 'Low'),
- ('medium', 'Medium'),
- ('high', 'High'),
- )
-
- title = models.CharField(max_length=200)
- description = models.TextField(blank=True)
- status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
- priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='medium')
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
- due_date = models.DateField(null=True, blank=True)
- owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tasks')
-
- def __str__(self):
- return self.title
复制代码
应用数据库迁移:
- python manage.py makemigrations
- python manage.py migrate
复制代码
序列化器
在api/serializers.py中创建序列化器:
- from rest_framework import serializers
- from django.contrib.auth.models import User
- from .models import Task
- class UserSerializer(serializers.ModelSerializer):
- class Meta:
- model = User
- fields = ('id', 'username', 'email')
- class TaskSerializer(serializers.ModelSerializer):
- owner = UserSerializer(read_only=True)
-
- class Meta:
- model = Task
- fields = '__all__'
复制代码
视图和路由
在api/views.py中创建视图:
- from rest_framework import viewsets, permissions, status
- from rest_framework.decorators import action
- from rest_framework.response import Response
- from rest_framework_simplejwt.tokens import RefreshToken
- from django.contrib.auth import authenticate
- from .models import Task
- from .serializers import TaskSerializer, UserSerializer
- class TaskViewSet(viewsets.ModelViewSet):
- queryset = Task.objects.all()
- serializer_class = TaskSerializer
- permission_classes = [permissions.IsAuthenticated]
-
- def get_queryset(self):
- return Task.objects.filter(owner=self.request.user)
-
- def perform_create(self, serializer):
- serializer.save(owner=self.request.user)
- class AuthViewSet(viewsets.ViewSet):
- permission_classes = [permissions.AllowAny]
-
- @action(detail=False, methods=['post'])
- def register(self, request):
- username = request.data.get('username')
- password = request.data.get('password')
- email = request.data.get('email', '')
-
- if not username or not password:
- return Response({'error': 'Username and password are required'},
- status=status.HTTP_400_BAD_REQUEST)
-
- if User.objects.filter(username=username).exists():
- return Response({'error': 'Username already exists'},
- status=status.HTTP_400_BAD_REQUEST)
-
- user = User.objects.create_user(username=username, password=password, email=email)
- refresh = RefreshToken.for_user(user)
-
- return Response({
- 'user': UserSerializer(user).data,
- 'tokens': {
- 'refresh': str(refresh),
- 'access': str(refresh.access_token),
- }
- }, status=status.HTTP_201_CREATED)
-
- @action(detail=False, methods=['post'])
- def login(self, request):
- username = request.data.get('username')
- password = request.data.get('password')
-
- user = authenticate(username=username, password=password)
-
- if user:
- refresh = RefreshToken.for_user(user)
- return Response({
- 'user': UserSerializer(user).data,
- 'tokens': {
- 'refresh': str(refresh),
- 'access': str(refresh.access_token),
- }
- })
-
- return Response({'error': 'Invalid credentials'},
- status=status.HTTP_401_UNAUTHORIZED)
-
- @action(detail=False, methods=['post'])
- def refresh(self, request):
- refresh_token = request.data.get('refresh')
-
- if not refresh_token:
- return Response({'error': 'Refresh token is required'},
- status=status.HTTP_400_BAD_REQUEST)
-
- try:
- refresh = RefreshToken(refresh_token)
- access_token = str(refresh.access_token)
- return Response({
- 'access': access_token,
- })
- except Exception as e:
- return Response({'error': 'Invalid refresh token'},
- status=status.HTTP_401_UNAUTHORIZED)
复制代码
在backend/urls.py中配置路由:
- from django.contrib import admin
- from django.urls import path, include
- from rest_framework.routers import DefaultRouter
- from api.views import TaskViewSet, AuthViewSet
- router = DefaultRouter()
- router.register(r'tasks', TaskViewSet, basename='task')
- router.register(r'auth', AuthViewSet, basename='auth', basename_prefix='auth')
- urlpatterns = [
- path('admin/', admin.site.urls),
- path('api/', include(router.urls)),
- ]
复制代码
运行Django开发服务器:
- python manage.py runserver
复制代码
Vue3前端开发
项目创建与配置
创建Vue3项目:
在创建过程中,选择Vue 3预设。进入项目目录:
安装必要的依赖:
- npm install axios pinia vue-router
复制代码
项目结构
创建以下目录结构:
- frontend/
- ├── public/
- ├── src/
- │ ├── assets/
- │ ├── components/
- │ ├── views/
- │ ├── stores/
- │ ├── router/
- │ ├── api/
- │ ├── App.vue
- │ └── main.js
复制代码
API请求封装
在src/api/index.js中封装API请求:
- import axios from 'axios';
- const apiClient = axios.create({
- baseURL: 'http://localhost:8000/api',
- headers: {
- 'Content-Type': 'application/json',
- },
- withCredentials: true,
- });
- // 请求拦截器
- apiClient.interceptors.request.use(
- (config) => {
- const token = localStorage.getItem('access_token');
- if (token) {
- config.headers.Authorization = `Bearer ${token}`;
- }
- return config;
- },
- (error) => {
- return Promise.reject(error);
- }
- );
- // 响应拦截器
- apiClient.interceptors.response.use(
- (response) => response,
- async (error) => {
- const originalRequest = error.config;
-
- // 如果是401错误且不是刷新令牌的请求
- if (error.response.status === 401 && !originalRequest._retry) {
- originalRequest._retry = true;
-
- try {
- const refreshToken = localStorage.getItem('refresh_token');
- const response = await apiClient.post('/auth/refresh/', {
- refresh: refreshToken,
- });
-
- const { access } = response.data;
- localStorage.setItem('access_token', access);
-
- // 更新原始请求的Authorization头
- originalRequest.headers.Authorization = `Bearer ${access}`;
-
- // 重新发送原始请求
- return apiClient(originalRequest);
- } catch (refreshError) {
- // 刷新令牌失败,清除本地存储并重定向到登录页
- localStorage.removeItem('access_token');
- localStorage.removeItem('refresh_token');
- window.location.href = '/login';
- return Promise.reject(refreshError);
- }
- }
-
- return Promise.reject(error);
- }
- );
- export default apiClient;
复制代码
在src/api/auth.js中创建认证相关的API函数:
- import apiClient from './index';
- export const authAPI = {
- register: (userData) => apiClient.post('/auth/register/', userData),
- login: (credentials) => apiClient.post('/auth/login/', credentials),
- refreshToken: () => apiClient.post('/auth/refresh/', {
- refresh: localStorage.getItem('refresh_token'),
- }),
- };
- export const taskAPI = {
- getAll: () => apiClient.get('/tasks/'),
- get: (id) => apiClient.get(`/tasks/${id}/`),
- create: (taskData) => apiClient.post('/tasks/', taskData),
- update: (id, taskData) => apiClient.put(`/tasks/${id}/`, taskData),
- delete: (id) => apiClient.delete(`/tasks/${id}/`),
- };
复制代码
状态管理(Pinia)
在src/stores/auth.js中创建认证状态管理:
- import { defineStore } from 'pinia';
- import { authAPI } from '@/api/auth';
- export const useAuthStore = defineStore('auth', {
- state: () => ({
- user: null,
- accessToken: localStorage.getItem('access_token') || null,
- refreshToken: localStorage.getItem('refresh_token') || null,
- isAuthenticated: !!localStorage.getItem('access_token'),
- }),
-
- getters: {
- getUser: (state) => state.user,
- isLoggedIn: (state) => state.isAuthenticated,
- },
-
- actions: {
- setAuth(user, tokens) {
- this.user = user;
- this.accessToken = tokens.access;
- this.refreshToken = tokens.refresh;
- this.isAuthenticated = true;
-
- localStorage.setItem('access_token', tokens.access);
- localStorage.setItem('refresh_token', tokens.refresh);
- },
-
- clearAuth() {
- this.user = null;
- this.accessToken = null;
- this.refreshToken = null;
- this.isAuthenticated = false;
-
- localStorage.removeItem('access_token');
- localStorage.removeItem('refresh_token');
- },
-
- async register(userData) {
- try {
- const response = await authAPI.register(userData);
- this.setAuth(response.data.user, response.data.tokens);
- return response;
- } catch (error) {
- throw error;
- }
- },
-
- async login(credentials) {
- try {
- const response = await authAPI.login(credentials);
- this.setAuth(response.data.user, response.data.tokens);
- return response;
- } catch (error) {
- throw error;
- }
- },
-
- async logout() {
- this.clearAuth();
- },
- },
- });
复制代码
在src/stores/task.js中创建任务状态管理:
- import { defineStore } from 'pinia';
- import { taskAPI } from '@/api/task';
- export const useTaskStore = defineStore('task', {
- state: () => ({
- tasks: [],
- currentTask: null,
- loading: false,
- error: null,
- }),
-
- getters: {
- getAllTasks: (state) => state.tasks,
- getTaskById: (state) => (id) => state.tasks.find(task => task.id === id),
- isLoading: (state) => state.loading,
- getError: (state) => state.error,
- },
-
- actions: {
- setTasks(tasks) {
- this.tasks = tasks;
- },
-
- addTask(task) {
- this.tasks.push(task);
- },
-
- updateTask(updatedTask) {
- const index = this.tasks.findIndex(task => task.id === updatedTask.id);
- if (index !== -1) {
- this.tasks[index] = updatedTask;
- }
- },
-
- removeTask(taskId) {
- this.tasks = this.tasks.filter(task => task.id !== taskId);
- },
-
- setLoading(loading) {
- this.loading = loading;
- },
-
- setError(error) {
- this.error = error;
- },
-
- async fetchTasks() {
- this.setLoading(true);
- this.setError(null);
-
- try {
- const response = await taskAPI.getAll();
- this.setTasks(response.data.results);
- return response;
- } catch (error) {
- this.setError(error.message || 'Failed to fetch tasks');
- throw error;
- } finally {
- this.setLoading(false);
- }
- },
-
- async fetchTask(id) {
- this.setLoading(true);
- this.setError(null);
-
- try {
- const response = await taskAPI.get(id);
- this.currentTask = response.data;
- return response;
- } catch (error) {
- this.setError(error.message || 'Failed to fetch task');
- throw error;
- } finally {
- this.setLoading(false);
- }
- },
-
- async createTask(taskData) {
- this.setLoading(true);
- this.setError(null);
-
- try {
- const response = await taskAPI.create(taskData);
- this.addTask(response.data);
- return response;
- } catch (error) {
- this.setError(error.message || 'Failed to create task');
- throw error;
- } finally {
- this.setLoading(false);
- }
- },
-
- async updateTask(id, taskData) {
- this.setLoading(true);
- this.setError(null);
-
- try {
- const response = await taskAPI.update(id, taskData);
- this.updateTask(response.data);
- return response;
- } catch (error) {
- this.setError(error.message || 'Failed to update task');
- throw error;
- } finally {
- this.setLoading(false);
- }
- },
-
- async deleteTask(id) {
- this.setLoading(true);
- this.setError(null);
-
- try {
- const response = await taskAPI.delete(id);
- this.removeTask(id);
- return response;
- } catch (error) {
- this.setError(error.message || 'Failed to delete task');
- throw error;
- } finally {
- this.setLoading(false);
- }
- },
- },
- });
复制代码
路由管理
在src/router/index.js中配置路由:
- import { createRouter, createWebHistory } from 'vue-router';
- import { useAuthStore } from '@/stores/auth';
- const routes = [
- {
- path: '/',
- name: 'Home',
- component: () => import('@/views/Home.vue'),
- meta: { requiresAuth: true },
- },
- {
- path: '/login',
- name: 'Login',
- component: () => import('@/views/Login.vue'),
- meta: { guest: true },
- },
- {
- path: '/register',
- name: 'Register',
- component: () => import('@/views/Register.vue'),
- meta: { guest: true },
- },
- {
- path: '/tasks',
- name: 'Tasks',
- component: () => import('@/views/Tasks.vue'),
- meta: { requiresAuth: true },
- },
- {
- path: '/tasks/:id',
- name: 'TaskDetail',
- component: () => import('@/views/TaskDetail.vue'),
- meta: { requiresAuth: true },
- },
- {
- path: '/tasks/create',
- name: 'CreateTask',
- component: () => import('@/views/CreateTask.vue'),
- meta: { requiresAuth: true },
- },
- {
- path: '/tasks/:id/edit',
- name: 'EditTask',
- component: () => import('@/views/EditTask.vue'),
- meta: { requiresAuth: true },
- },
- {
- path: '/:pathMatch(.*)*',
- name: 'NotFound',
- component: () => import('@/views/NotFound.vue'),
- },
- ];
- const router = createRouter({
- history: createWebHistory(),
- routes,
- });
- // 导航守卫
- router.beforeEach((to, from, next) => {
- const authStore = useAuthStore();
- const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
- const isGuest = to.matched.some(record => record.meta.guest);
-
- if (requiresAuth && !authStore.isAuthenticated) {
- next('/login');
- } else if (isGuest && authStore.isAuthenticated) {
- next('/');
- } else {
- next();
- }
- });
- export default router;
复制代码
视图组件
在src/views/Login.vue中创建登录页面:
- <template>
- <div class="login-container">
- <div class="login-form">
- <h1>Login</h1>
- <form @submit.prevent="handleLogin">
- <div class="form-group">
- <label for="username">Username</label>
- <input
- type="text"
- id="username"
- v-model="credentials.username"
- required
- />
- </div>
- <div class="form-group">
- <label for="password">Password</label>
- <input
- type="password"
- id="password"
- v-model="credentials.password"
- required
- />
- </div>
- <div v-if="error" class="error-message">{{ error }}</div>
- <button type="submit" :disabled="loading">
- {{ loading ? 'Logging in...' : 'Login' }}
- </button>
- </form>
- <p class="register-link">
- Don't have an account? <router-link to="/register">Register</router-link>
- </p>
- </div>
- </div>
- </template>
- <script>
- import { ref } from 'vue';
- import { useRouter } from 'vue-router';
- import { useAuthStore } from '@/stores/auth';
- export default {
- name: 'Login',
- setup() {
- const router = useRouter();
- const authStore = useAuthStore();
-
- const credentials = ref({
- username: '',
- password: '',
- });
-
- const loading = ref(false);
- const error = ref(null);
-
- const handleLogin = async () => {
- loading.value = true;
- error.value = null;
-
- try {
- await authStore.login(credentials.value);
- router.push('/');
- } catch (err) {
- error.value = err.response?.data?.error || 'Login failed';
- } finally {
- loading.value = false;
- }
- };
-
- return {
- credentials,
- loading,
- error,
- handleLogin,
- };
- },
- };
- </script>
- <style scoped>
- .login-container {
- display: flex;
- justify-content: center;
- align-items: center;
- min-height: 100vh;
- background-color: #f5f5f5;
- }
- .login-form {
- width: 100%;
- max-width: 400px;
- padding: 2rem;
- background-color: white;
- border-radius: 8px;
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
- }
- h1 {
- text-align: center;
- margin-bottom: 1.5rem;
- }
- .form-group {
- margin-bottom: 1rem;
- }
- label {
- display: block;
- margin-bottom: 0.5rem;
- font-weight: bold;
- }
- input {
- width: 100%;
- padding: 0.75rem;
- border: 1px solid #ddd;
- border-radius: 4px;
- font-size: 1rem;
- }
- button {
- width: 100%;
- padding: 0.75rem;
- background-color: #4caf50;
- color: white;
- border: none;
- border-radius: 4px;
- font-size: 1rem;
- cursor: pointer;
- transition: background-color 0.3s;
- }
- button:hover {
- background-color: #45a049;
- }
- button:disabled {
- background-color: #cccccc;
- cursor: not-allowed;
- }
- .error-message {
- color: #f44336;
- margin-bottom: 1rem;
- text-align: center;
- }
- .register-link {
- text-align: center;
- margin-top: 1rem;
- }
- a {
- color: #2196f3;
- text-decoration: none;
- }
- a:hover {
- text-decoration: underline;
- }
- </style>
复制代码
在src/views/Tasks.vue中创建任务列表页面:
- <template>
- <div class="tasks-container">
- <div class="tasks-header">
- <h1>Tasks</h1>
- <router-link to="/tasks/create" class="btn-create">Create Task</router-link>
- </div>
-
- <div v-if="loading" class="loading">Loading tasks...</div>
-
- <div v-else-if="error" class="error">{{ error }}</div>
-
- <div v-else-if="tasks.length === 0" class="empty-state">
- <p>No tasks found. Create your first task!</p>
- <router-link to="/tasks/create" class="btn-create">Create Task</router-link>
- </div>
-
- <div v-else class="tasks-list">
- <div v-for="task in tasks" :key="task.id" class="task-card">
- <div class="task-header">
- <h3>{{ task.title }}</h3>
- <span :class="['priority', task.priority]">{{ task.priority }}</span>
- </div>
- <p class="task-description">{{ task.description || 'No description' }}</p>
- <div class="task-meta">
- <span :class="['status', task.status]">{{ task.status }}</span>
- <span class="due-date" v-if="task.due_date">
- Due: {{ formatDate(task.due_date) }}
- </span>
- </div>
- <div class="task-actions">
- <router-link :to="`/tasks/${task.id}`" class="btn-view">View</router-link>
- <router-link :to="`/tasks/${task.id}/edit`" class="btn-edit">Edit</router-link>
- <button @click="confirmDelete(task)" class="btn-delete">Delete</button>
- </div>
- </div>
- </div>
-
- <div v-if="showDeleteModal" class="modal">
- <div class="modal-content">
- <h3>Confirm Delete</h3>
- <p>Are you sure you want to delete "{{ taskToDelete?.title }}"?</p>
- <div class="modal-actions">
- <button @click="cancelDelete" class="btn-cancel">Cancel</button>
- <button @click="deleteTask" class="btn-confirm" :disabled="deleting">
- {{ deleting ? 'Deleting...' : 'Delete' }}
- </button>
- </div>
- </div>
- </div>
- </div>
- </template>
- <script>
- import { ref, onMounted } from 'vue';
- import { useRouter } from 'vue-router';
- import { useTaskStore } from '@/stores/task';
- export default {
- name: 'Tasks',
- setup() {
- const router = useRouter();
- const taskStore = useTaskStore();
-
- const tasks = ref([]);
- const loading = ref(false);
- const error = ref(null);
-
- const showDeleteModal = ref(false);
- const taskToDelete = ref(null);
- const deleting = ref(false);
-
- const loadTasks = async () => {
- loading.value = true;
- error.value = null;
-
- try {
- await taskStore.fetchTasks();
- tasks.value = taskStore.getAllTasks;
- } catch (err) {
- error.value = err.message || 'Failed to load tasks';
- } finally {
- loading.value = false;
- }
- };
-
- const formatDate = (dateString) => {
- const options = { year: 'numeric', month: 'short', day: 'numeric' };
- return new Date(dateString).toLocaleDateString(undefined, options);
- };
-
- const confirmDelete = (task) => {
- taskToDelete.value = task;
- showDeleteModal.value = true;
- };
-
- const cancelDelete = () => {
- showDeleteModal.value = false;
- taskToDelete.value = null;
- };
-
- const deleteTask = async () => {
- if (!taskToDelete.value) return;
-
- deleting.value = true;
-
- try {
- await taskStore.deleteTask(taskToDelete.value.id);
- tasks.value = taskStore.getAllTasks;
- showDeleteModal.value = false;
- taskToDelete.value = null;
- } catch (err) {
- error.value = err.message || 'Failed to delete task';
- } finally {
- deleting.value = false;
- }
- };
-
- onMounted(() => {
- loadTasks();
- });
-
- return {
- tasks,
- loading,
- error,
- showDeleteModal,
- taskToDelete,
- deleting,
- formatDate,
- confirmDelete,
- cancelDelete,
- deleteTask,
- };
- },
- };
- </script>
- <style scoped>
- .tasks-container {
- max-width: 1200px;
- margin: 0 auto;
- padding: 2rem;
- }
- .tasks-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 2rem;
- }
- .btn-create {
- display: inline-block;
- padding: 0.75rem 1.5rem;
- background-color: #4caf50;
- color: white;
- text-decoration: none;
- border-radius: 4px;
- font-weight: bold;
- transition: background-color 0.3s;
- }
- .btn-create:hover {
- background-color: #45a049;
- }
- .loading, .error, .empty-state {
- text-align: center;
- padding: 2rem;
- font-size: 1.2rem;
- }
- .error {
- color: #f44336;
- }
- .tasks-list {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
- gap: 1.5rem;
- }
- .task-card {
- background-color: white;
- border-radius: 8px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- }
- .task-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 1rem;
- }
- .task-header h3 {
- margin: 0;
- font-size: 1.25rem;
- }
- .priority {
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- font-size: 0.75rem;
- font-weight: bold;
- text-transform: uppercase;
- }
- .priority.low {
- background-color: #e8f5e9;
- color: #2e7d32;
- }
- .priority.medium {
- background-color: #fff8e1;
- color: #ff8f00;
- }
- .priority.high {
- background-color: #ffebee;
- color: #c62828;
- }
- .task-description {
- color: #666;
- margin-bottom: 1rem;
- flex-grow: 1;
- }
- .task-meta {
- display: flex;
- justify-content: space-between;
- margin-bottom: 1rem;
- font-size: 0.875rem;
- }
- .status {
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- font-weight: bold;
- text-transform: capitalize;
- }
- .status.pending {
- background-color: #e3f2fd;
- color: #1565c0;
- }
- .status.in_progress {
- background-color: #fff8e1;
- color: #ff8f00;
- }
- .status.completed {
- background-color: #e8f5e9;
- color: #2e7d32;
- }
- .due-date {
- color: #666;
- }
- .task-actions {
- display: flex;
- gap: 0.5rem;
- }
- .btn-view, .btn-edit {
- display: inline-block;
- padding: 0.5rem 1rem;
- text-decoration: none;
- border-radius: 4px;
- font-size: 0.875rem;
- transition: background-color 0.3s;
- }
- .btn-view {
- background-color: #e3f2fd;
- color: #1565c0;
- }
- .btn-view:hover {
- background-color: #bbdefb;
- }
- .btn-edit {
- background-color: #fff8e1;
- color: #ff8f00;
- }
- .btn-edit:hover {
- background-color: #ffecb3;
- }
- .btn-delete {
- padding: 0.5rem 1rem;
- background-color: #ffebee;
- color: #c62828;
- border: none;
- border-radius: 4px;
- font-size: 0.875rem;
- cursor: pointer;
- transition: background-color 0.3s;
- }
- .btn-delete:hover {
- background-color: #ffcdd2;
- }
- .btn-delete:disabled {
- background-color: #f5f5f5;
- color: #999;
- cursor: not-allowed;
- }
- .modal {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(0, 0, 0, 0.5);
- display: flex;
- justify-content: center;
- align-items: center;
- z-index: 1000;
- }
- .modal-content {
- background-color: white;
- padding: 2rem;
- border-radius: 8px;
- max-width: 500px;
- width: 100%;
- }
- .modal-content h3 {
- margin-top: 0;
- margin-bottom: 1rem;
- }
- .modal-actions {
- display: flex;
- justify-content: flex-end;
- gap: 1rem;
- margin-top: 1.5rem;
- }
- .btn-cancel, .btn-confirm {
- padding: 0.75rem 1.5rem;
- border: none;
- border-radius: 4px;
- font-weight: bold;
- cursor: pointer;
- transition: background-color 0.3s;
- }
- .btn-cancel {
- background-color: #f5f5f5;
- color: #333;
- }
- .btn-cancel:hover {
- background-color: #e0e0e0;
- }
- .btn-confirm {
- background-color: #f44336;
- color: white;
- }
- .btn-confirm:hover {
- background-color: #d32f2f;
- }
- .btn-confirm:disabled {
- background-color: #ffcdd2;
- color: white;
- cursor: not-allowed;
- }
- </style>
复制代码
在src/views/CreateTask.vue中创建任务创建页面:
- <template>
- <div class="create-task-container">
- <h1>Create Task</h1>
- <form @submit.prevent="handleSubmit" class="task-form">
- <div class="form-group">
- <label for="title">Title *</label>
- <input
- type="text"
- id="title"
- v-model="task.title"
- required
- />
- </div>
-
- <div class="form-group">
- <label for="description">Description</label>
- <textarea
- id="description"
- v-model="task.description"
- rows="4"
- ></textarea>
- </div>
-
- <div class="form-group">
- <label for="status">Status</label>
- <select id="status" v-model="task.status">
- <option value="pending">Pending</option>
- <option value="in_progress">In Progress</option>
- <option value="completed">Completed</option>
- </select>
- </div>
-
- <div class="form-group">
- <label for="priority">Priority</label>
- <select id="priority" v-model="task.priority">
- <option value="low">Low</option>
- <option value="medium">Medium</option>
- <option value="high">High</option>
- </select>
- </div>
-
- <div class="form-group">
- <label for="due_date">Due Date</label>
- <input
- type="date"
- id="due_date"
- v-model="task.due_date"
- />
- </div>
-
- <div v-if="error" class="error-message">{{ error }}</div>
-
- <div class="form-actions">
- <button type="button" @click="goBack" class="btn-cancel">Cancel</button>
- <button type="submit" class="btn-submit" :disabled="loading">
- {{ loading ? 'Creating...' : 'Create Task' }}
- </button>
- </div>
- </form>
- </div>
- </template>
- <script>
- import { ref, reactive } from 'vue';
- import { useRouter } from 'vue-router';
- import { useTaskStore } from '@/stores/task';
- export default {
- name: 'CreateTask',
- setup() {
- const router = useRouter();
- const taskStore = useTaskStore();
-
- const task = reactive({
- title: '',
- description: '',
- status: 'pending',
- priority: 'medium',
- due_date: '',
- });
-
- const loading = ref(false);
- const error = ref(null);
-
- const handleSubmit = async () => {
- loading.value = true;
- error.value = null;
-
- try {
- await taskStore.createTask(task);
- router.push('/tasks');
- } catch (err) {
- error.value = err.message || 'Failed to create task';
- } finally {
- loading.value = false;
- }
- };
-
- const goBack = () => {
- router.push('/tasks');
- };
-
- return {
- task,
- loading,
- error,
- handleSubmit,
- goBack,
- };
- },
- };
- </script>
- <style scoped>
- .create-task-container {
- max-width: 800px;
- margin: 0 auto;
- padding: 2rem;
- }
- h1 {
- margin-bottom: 2rem;
- }
- .task-form {
- background-color: white;
- padding: 2rem;
- border-radius: 8px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- }
- .form-group {
- margin-bottom: 1.5rem;
- }
- label {
- display: block;
- margin-bottom: 0.5rem;
- font-weight: bold;
- }
- input[type="text"],
- input[type="date"],
- textarea,
- select {
- width: 100%;
- padding: 0.75rem;
- border: 1px solid #ddd;
- border-radius: 4px;
- font-size: 1rem;
- }
- textarea {
- resize: vertical;
- }
- .error-message {
- color: #f44336;
- margin-bottom: 1rem;
- }
- .form-actions {
- display: flex;
- justify-content: flex-end;
- gap: 1rem;
- }
- .btn-cancel, .btn-submit {
- padding: 0.75rem 1.5rem;
- border: none;
- border-radius: 4px;
- font-weight: bold;
- cursor: pointer;
- transition: background-color 0.3s;
- }
- .btn-cancel {
- background-color: #f5f5f5;
- color: #333;
- }
- .btn-cancel:hover {
- background-color: #e0e0e0;
- }
- .btn-submit {
- background-color: #4caf50;
- color: white;
- }
- .btn-submit:hover {
- background-color: #45a049;
- }
- .btn-submit:disabled {
- background-color: #cccccc;
- cursor: not-allowed;
- }
- </style>
复制代码
主应用文件
在src/App.vue中:
在src/main.js中:
- import { createApp } from 'vue';
- import { createPinia } from 'pinia';
- import App from './App.vue';
- import router from './router';
- const app = createApp(App);
- app.use(createPinia());
- app.use(router);
- app.mount('#app');
复制代码
前后端交互实战
用户认证流程
我们已经实现了完整的用户认证流程,包括注册、登录和令牌刷新。以下是认证流程的工作原理:
1. 注册:用户提交注册表单后,前端将用户数据发送到Django的/api/auth/register/端点。Django创建新用户并返回访问令牌和刷新令牌。前端将这些令牌存储在localStorage中,并更新Pinia store中的认证状态。
2. 登录:用户提交登录表单后,前端将凭据发送到Django的/api/auth/login/端点。Django验证凭据并返回访问令牌和刷新令牌。前端同样将这些令牌存储在localStorage中,并更新认证状态。
3. 令牌刷新:访问令牌通常有较短的过期时间(在我们的示例中为60分钟),而刷新令牌有较长的过期时间(1天)。当访问令牌过期时,前端会自动使用刷新令牌请求新的访问令牌。如果刷新令牌也过期或无效,用户将被重定向到登录页面。
4. 请求拦截:所有API请求都会通过axios拦截器自动添加Authorization头,包含访问令牌。如果请求返回401未授权错误,拦截器会尝试使用刷新令牌获取新的访问令牌,然后重新发送原始请求。
5. 路由守卫:Vue Router的导航守卫检查用户是否已认证,并根据路由的meta信息决定是否允许访问需要认证的页面或重定向到登录页面。
注册:用户提交注册表单后,前端将用户数据发送到Django的/api/auth/register/端点。Django创建新用户并返回访问令牌和刷新令牌。前端将这些令牌存储在localStorage中,并更新Pinia store中的认证状态。
登录:用户提交登录表单后,前端将凭据发送到Django的/api/auth/login/端点。Django验证凭据并返回访问令牌和刷新令牌。前端同样将这些令牌存储在localStorage中,并更新认证状态。
令牌刷新:访问令牌通常有较短的过期时间(在我们的示例中为60分钟),而刷新令牌有较长的过期时间(1天)。当访问令牌过期时,前端会自动使用刷新令牌请求新的访问令牌。如果刷新令牌也过期或无效,用户将被重定向到登录页面。
请求拦截:所有API请求都会通过axios拦截器自动添加Authorization头,包含访问令牌。如果请求返回401未授权错误,拦截器会尝试使用刷新令牌获取新的访问令牌,然后重新发送原始请求。
路由守卫:Vue Router的导航守卫检查用户是否已认证,并根据路由的meta信息决定是否允许访问需要认证的页面或重定向到登录页面。
CRUD操作示例
我们的任务管理系统实现了完整的CRUD(创建、读取、更新、删除)操作:
1. 创建任务:用户可以通过”Create Task”页面创建新任务。表单数据被发送到Django的/api/tasks/端点,Django创建新任务并返回任务数据。前端将新任务添加到任务列表中。
2. 读取任务:任务列表页面从Django的/api/tasks/端点获取所有任务。用户可以点击任务查看详细信息,这会从/api/tasks/{id}/端点获取特定任务的数据。
3. 更新任务:用户可以通过”Edit Task”页面编辑现有任务。更新后的数据被发送到Django的/api/tasks/{id}/端点,Django更新任务并返回更新后的数据。前端更新任务列表中的相应任务。
4. 删除任务:用户可以删除任务,这会向Django的/api/tasks/{id}/端点发送DELETE请求。Django删除任务并返回成功响应。前端从任务列表中移除已删除的任务。
创建任务:用户可以通过”Create Task”页面创建新任务。表单数据被发送到Django的/api/tasks/端点,Django创建新任务并返回任务数据。前端将新任务添加到任务列表中。
读取任务:任务列表页面从Django的/api/tasks/端点获取所有任务。用户可以点击任务查看详细信息,这会从/api/tasks/{id}/端点获取特定任务的数据。
更新任务:用户可以通过”Edit Task”页面编辑现有任务。更新后的数据被发送到Django的/api/tasks/{id}/端点,Django更新任务并返回更新后的数据。前端更新任务列表中的相应任务。
删除任务:用户可以删除任务,这会向Django的/api/tasks/{id}/端点发送DELETE请求。Django删除任务并返回成功响应。前端从任务列表中移除已删除的任务。
数据状态同步
我们的应用使用Pinia进行状态管理,确保前后端数据同步:
1. 任务状态:useTaskStore管理所有任务的状态,包括任务列表、当前任务、加载状态和错误信息。当执行CRUD操作时,store会自动更新,确保UI与后端数据保持同步。
2. 认证状态:useAuthStore管理用户认证状态,包括用户信息、访问令牌、刷新令牌和认证状态。当用户登录或注销时,store会更新,并相应地更新localStorage和UI。
3. 响应式更新:由于Pinia的响应式特性,当store中的状态发生变化时,所有依赖这些状态的组件都会自动更新,确保UI始终反映最新的数据状态。
4. 错误处理:store中的错误处理机制确保当API请求失败时,错误信息能够正确显示给用户,同时不会破坏应用的状态。
任务状态:useTaskStore管理所有任务的状态,包括任务列表、当前任务、加载状态和错误信息。当执行CRUD操作时,store会自动更新,确保UI与后端数据保持同步。
认证状态:useAuthStore管理用户认证状态,包括用户信息、访问令牌、刷新令牌和认证状态。当用户登录或注销时,store会更新,并相应地更新localStorage和UI。
响应式更新:由于Pinia的响应式特性,当store中的状态发生变化时,所有依赖这些状态的组件都会自动更新,确保UI始终反映最新的数据状态。
错误处理:store中的错误处理机制确保当API请求失败时,错误信息能够正确显示给用户,同时不会破坏应用的状态。
安全最佳实践
在构建前后端分离应用时,安全性是至关重要的。以下是我们实现的一些安全最佳实践:
后端安全措施
1. JWT认证:使用JSON Web Tokens (JWT)进行用户认证,而不是传统的会话认证。这使得我们的API可以无状态地工作,更适合前后端分离的架构。
2. CORS配置:正确配置跨域资源共享(CORS),只允许来自我们前端域名的请求,防止跨站请求伪造(CSRF)攻击。
3. 令牌过期:设置合理的令牌过期时间,访问令牌设置为60分钟,刷新令牌设置为1天,以减少令牌被滥用的风险。
4. HTTPS:在生产环境中,确保使用HTTPS协议加密所有通信,防止中间人攻击。
5. 数据验证:在Django中使用序列化器进行数据验证,确保所有输入数据都符合预期格式,防止注入攻击。
6. 权限控制:使用Django REST framework的权限系统,确保用户只能访问和修改自己的任务数据。
JWT认证:使用JSON Web Tokens (JWT)进行用户认证,而不是传统的会话认证。这使得我们的API可以无状态地工作,更适合前后端分离的架构。
CORS配置:正确配置跨域资源共享(CORS),只允许来自我们前端域名的请求,防止跨站请求伪造(CSRF)攻击。
令牌过期:设置合理的令牌过期时间,访问令牌设置为60分钟,刷新令牌设置为1天,以减少令牌被滥用的风险。
HTTPS:在生产环境中,确保使用HTTPS协议加密所有通信,防止中间人攻击。
数据验证:在Django中使用序列化器进行数据验证,确保所有输入数据都符合预期格式,防止注入攻击。
权限控制:使用Django REST framework的权限系统,确保用户只能访问和修改自己的任务数据。
前端安全措施
1. 令牌存储:将JWT令牌存储在localStorage中,而不是cookie中,以防止跨站脚本攻击(XSS)窃取令牌。注意:这种方法也有其自身的安全考虑,在生产环境中可能需要更安全的解决方案,如使用HttpOnly cookie。
2. 路由守卫:使用Vue Router的导航守卫保护需要认证的路由,防止未认证用户访问受限页面。
3. 请求拦截:使用axios拦截器自动添加认证头,确保所有API请求都包含有效的访问令牌。
4. 输入验证:在前端表单中进行基本的输入验证,提供即时反馈,但记住前端验证不能替代后端验证。
5. 错误处理:妥善处理API错误,避免在UI中暴露敏感信息。
令牌存储:将JWT令牌存储在localStorage中,而不是cookie中,以防止跨站脚本攻击(XSS)窃取令牌。注意:这种方法也有其自身的安全考虑,在生产环境中可能需要更安全的解决方案,如使用HttpOnly cookie。
路由守卫:使用Vue Router的导航守卫保护需要认证的路由,防止未认证用户访问受限页面。
请求拦截:使用axios拦截器自动添加认证头,确保所有API请求都包含有效的访问令牌。
输入验证:在前端表单中进行基本的输入验证,提供即时反馈,但记住前端验证不能替代后端验证。
错误处理:妥善处理API错误,避免在UI中暴露敏感信息。
部署考虑
虽然本文主要关注开发过程,但了解部署考虑也很重要:
后端部署
1. 生产环境设置:在Django的settings.py中设置DEBUG = False,并配置ALLOWED_HOSTS。
2. 数据库:使用生产级数据库如PostgreSQL或MySQL,而不是开发用的SQLite。
3. 静态文件:配置静态文件服务,使用WhiteNoise或类似服务,或使用CDN。
4. WSGI服务器:使用Gunicorn或uWSGI作为应用服务器,而不是Django的开发服务器。
5. 反向代理:使用Nginx或Apache作为反向代理,处理静态文件、SSL终止和请求转发。
生产环境设置:在Django的settings.py中设置DEBUG = False,并配置ALLOWED_HOSTS。
数据库:使用生产级数据库如PostgreSQL或MySQL,而不是开发用的SQLite。
静态文件:配置静态文件服务,使用WhiteNoise或类似服务,或使用CDN。
WSGI服务器:使用Gunicorn或uWSGI作为应用服务器,而不是Django的开发服务器。
反向代理:使用Nginx或Apache作为反向代理,处理静态文件、SSL终止和请求转发。
前端部署
1. 构建优化:使用Vue CLI的构建命令生成优化后的生产版本代码:
1. 静态文件服务:将构建后的静态文件部署到Web服务器或CDN。
2. 环境变量:使用环境变量配置API基础URL等设置,避免硬编码。
3. 缓存策略:配置适当的缓存策略,平衡性能和更新需求。
静态文件服务:将构建后的静态文件部署到Web服务器或CDN。
环境变量:使用环境变量配置API基础URL等设置,避免硬编码。
缓存策略:配置适当的缓存策略,平衡性能和更新需求。
CI/CD
考虑设置持续集成/持续部署(CI/CD)流程,自动化测试和部署过程,确保代码质量和部署可靠性。
总结与展望
本文详细介绍了如何使用Vue3和Django构建一个完整的前后端分离应用,涵盖了从环境搭建、API设计、数据通信、状态管理到安全认证的完整流程。通过这个实战项目,我们学习了:
1. Django REST framework的使用,包括序列化器、视图集和路由器的配置。
2. JWT认证的实现,包括令牌生成、验证和刷新。
3. Vue3组件化开发,使用Composition API构建响应式UI。
4. Pinia状态管理,实现前后端数据同步。
5. Vue Router路由管理,包括导航守卫和路由参数。
6. Axios封装,实现请求拦截和错误处理。
7. 前后端交互的最佳实践,包括CORS配置和安全考虑。
这个项目可以作为更复杂应用的基础,你可以根据需要扩展功能,例如:
1. 添加实时通知功能,使用WebSocket或Server-Sent Events。
2. 实现更复杂的权限系统,如基于角色的访问控制。
3. 添加文件上传功能,允许用户为任务添加附件。
4. 实现任务搜索和过滤功能。
5. 添加数据可视化,使用图表库展示任务统计信息。
6. 实现国际化支持,使应用支持多语言。
随着Web技术的不断发展,前后端分离架构将继续演进,新的工具和框架也会不断涌现。但无论技术如何变化,理解基本原理和最佳实践将帮助你构建更安全、更可靠、更易维护的Web应用。
希望本文能为你在Vue3和Django前后端分离开发之旅中提供有价值的指导和参考。祝你编码愉快!
版权声明
1、转载或引用本网站内容(Vue3与Django交互实战从零开始构建前后端分离应用掌握API设计数据通信状态管理及安全认证的完整流程)须注明原网址及作者(威震华夏关云长),并标明本网站网址(https://www.pixtech.cc/)。
2、对于不当转载或引用本网站内容而引起的民事纷争、行政处理或其他损失,本网站不承担责任。
3、对不遵守本声明或其他违法、恶意使用本网站内容者,本网站保留追究其法律责任的权利。
本文地址: https://www.pixtech.cc/thread-36785-1-1.html
|
|