Django2實戰示例 第五章 內容分享功能


目錄

Django2實戰示例 第一章 創建博客應用
Django2實戰示例 第二章 增強博客功能
Django2實戰示例 第三章 擴展博客功能
Django2實戰示例 第四章 創建社交網站
Django2實戰示例 第五章 內容分享功能
Django2實戰示例 第六章 追蹤用戶行為
Django2實戰示例 第七章 創建電商網站
Django2實戰示例 第八章 管理支付與訂單
Django2實戰示例 第九章 擴展商店功能
Django2實戰示例 第十章 創建在線教育平台
Django2實戰示例 第十一章 渲染和緩存課程內容
Django2實戰示例 第十二章 創建API
Django2實戰示例 第十三章 上線

第五章 內容分享功能

在上一章我們使用內置驗證框架迅速的建立了整個網站的用戶相關功能,還學習了如何通過一對一字段擴展用戶信息,以及為網站添加第三方認證登錄功能。

這一章會學習使用JavaScript小書簽程序,將其他網站的圖片內容分享到本站,還將學習使用jQuery在Django中使用AJAX技術。本章包含如下要點

  • 創建多對多關系
  • 自定義表單行為
  • 在Django中使用jQuery
  • 創建jQuery小書簽程序
  • 使用sorl-thumbnail創建縮略圖
  • 使用jQuery發送AJAX請求和創建AJAX視圖
  • 創建視圖的自定義裝飾器
  • AJAX動態加載頁面

1創建圖片分享功能

我們的站點將讓用戶可以收藏然后分享他們在互聯網上看到的圖片到本站來,為此將要做以下工作:

  • 用一個數據類存放圖片和相關信息
  • 建立表單和視圖用於處理圖片上傳
  • 需要建立一個系統,讓用戶將外站圖片貼到本站來。

這是一個獨立與用戶驗證系統的新功能,為此新建一個應用images

django-admin startapp images

然后在settings.py中激活該應用:

INSTALLED_APPS = [
    # ...
    'images.apps.ImagesConfig',
]

1.1創建圖片模型

編輯images應用的models.py文件,添加如下代碼:

from django.db import models
from django.conf import settings

class Image(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='images_created', on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200,blank=True)
    url = models.URLField()
    image = models.ImageField(upload_to='images/%Y/%m/%d')
    description = models.TextField(blank=True)
    created = models.DateField(auto_now_add=True,db_index=True)

    def __str__(self):
        return self.title

這是我們用於存儲圖片的模型,來看一下具體的字段:

  • user:這是一個連接到User模型的外鍵,體現了用戶與圖片的一對多關系,即一個用戶可以上傳多個圖片。
  • title:圖片的名稱
  • slug:該圖片的簡稱,用於動態建立該圖片的URL
  • image:圖片文件字段,用於存放圖片
  • description:可選的關於圖片的描述
  • created:圖片分享到本站來的時間,使用了auto_now_add自動生成創建時間,並且使用了db_index=True創建索引

數據庫索引可以有效的提高數據庫查詢效率。對於頻繁使用filter()exclude()或者order_by()等方法的字段推薦創建字段。ForeignKey和設置了unique=True的字段默認會被創建索引。還可以使用Meta.index_together創建聯合索引。

譯者注:為created字段創建索引是常用做法。

這里我們需要自定義該模型的行為,重寫Image模型的save()方法,使圖片在保存到數據庫時,自動根據title字段生成slug字段的內容。導入slugify()然后為Image模型添加一個save()方法:

from django.utils.text import slugify

class Image(models.Model):
    # ......

    def save(self, *args, kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super(Image, self).save(*args, kwargs)

譯者注:原書代碼縮進有誤,此處已經修改為正確版本。

在這段代碼里,使用了Django內置的slugify()自動生成了slug字段的內容。之后調用超類的方法保存圖片,這樣用戶無需手工輸入。

1.2創建多對多關系

我們將在Image模型中再添加一個外鍵,用於存儲哪些用戶喜歡該圖片。由於一個用戶可能喜歡多個圖片,一個圖片也可能被多個用戶喜歡,因此圖片和用戶之間多對多的關系,需要修改Image模型添加如下字段:

users_like = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='images_liked', blank=True)

當定義了ManyToManyField多對多外鍵字段時,Django會創建一張中間表,中間表分別通過外鍵關聯到當前的模型和ManyToManyField()的第一個參數對應的模型,多對多關系可以用於任意兩個有關系的模型。

ForeignKey一樣,related_name屬性定義了多對多字段反向查詢的名稱,多對多字段提供了一個多對多模型管理器用來進行查詢,類似image.users_like.all(),如果是從user對象查詢,則類似user.images_liked.all()

之后進行Image類的數據遷移。

1.3添加圖片模型至管理后台

編輯images應用的admin.py文件,將Image類添加至管理后台:

from django.contrib import admin
from .models import Image

@admin.register(Image)
class ImageAdmin(admin.ModelAdmin):
    list_display = ['title', 'slug', 'image', 'created']
    list_filter = ['created']

啟動站點,打開http://127.0.0.1:8000/admin/,可以看到Image已經被加入管理后台,如圖所示:

image

2從外站分享內容至本站

我們實現用戶將外站圖片分享到本站的方式是:用戶提供圖片的URL,一個標題和可選的秒數,我們的站點會將該圖片下載下來,建立一個對應的新Image對象,然后保存進數據庫。

已經建立完了圖片模型,這里我們需要建立一個表單供用戶提交圖片信息。在Images應用下建立forms.py文件,然后添加如下代碼:

from django import forms
from .models import Image

class ImageCreateForm(forms.ModelForm):
    class Meta:
        model = Image
        fields = ('title', 'url', 'description',)
        widgets = {
            'url': forms.HiddenInput,
        }

這里使用了ModelForm類,基於Image模型創建了表單,僅包含titleurldescription字段。用戶無需直接在表單中輸入圖片URL,我們將使用一個JavaScript小書簽程序來從外站選擇一個圖片並將其URL作為Get請求的參數,然后訪問我們的站點。所以我們使用了HiddenInput小插件替代了默認的url字段的設置。我們這么做是希望這個字段不被用戶看到。

2.1驗證表單字段

為了驗證這個URL是一個圖片,需要檢查URL中的文件名是否以.jpg.jpeg擴展名結尾。像在之前章節那樣,我們將針對url字段編寫一個自定義驗證器clean_url(),這樣表單對象調用is_valid()時,我們的驗證器就可以修改數據或者報錯。添加如下方法到ImageCreateForm

def clean_url(self):
    url = self.cleaned_data['url']
    valid_extensions = ['jpg', 'jpeg']
    extension = url.rsplit('.', 1)[1].lower()
    if extension not in valid_extensions:
        raise forms.ValidationError('The given URL does not match valid image extensions.')
    return url

在上邊的代碼中,定義了clean_URL()方法來驗證url字段,該方法解釋如下:

  1. cleaned_data中獲取url字段的值
  2. 將URL通過從右邊開始的第一個.進行切分,然后取切分結果的第二個元素,也就是擴展名進行比較。如果驗證失敗,則拋出一個ValidationError錯誤。這里我們采用的驗證方式比較簡陋,而且僅支持jpg類型圖片,你可以采用正則表達式或者其他高級方法來驗證URL是否是一個有效的圖片文件地址。

除了驗證URL之外,我們還必須在驗證成功的時候將圖片下載並保存到數據庫中。我們可以使用處理該表單的視圖來完成這個操作,但更常用的方式是重寫表單的save()來實現此功能。

2.2重寫表單的save()方法

在之前已經知道,ModelForm有一個save()方法,將當前的模型數據存儲到數據庫中並且返回該對象。這個方法還接受一個commit布爾值參數,用於確定是否實際將數據持久化到數據庫中。如果commit=False,則save()方法僅返回當前的數據對象,但不執行數據庫寫入操作。因此我們可以重寫save()方法,讓其下載圖片之后,再將數據對象寫入數據庫。

添加如下導入語句到forms.py文件:

from urllib import request
from django.core.files.base import ContentFile
from django.utils.text import slugify

之后添加下列save()方法至ImageCreateForm類中:

def save(self, force_insert=False, force_update=False, commit=True):
    image = super(ImageCreateForm, self).save(commit=False)
    image_url = self.cleaned_data['url']
    image_name = '{}.{}'.format(slugify(image.title), image_url.rsplit('.', 1)[1].lower())

    # 根據URL下載圖片
    response = request.urlopen(image_url)
    image.image.save(image_name, ContentFile(response.read()), save=False)

    if commit:
        image.save()
    return image

我們重寫了save()方法,保持與原來方法一樣的默認參數設置。重寫的方法工作邏輯如下:

  1. 先調用父類的save()方法,使用現有表單數據建立一個新的image數據對象但不保存
  2. cleaned_data中獲取URL
  3. image.slug與擴展名拼成新的文件名
  4. 使用Python的urllib模塊下載圖片,然后使用image字段的save()方法保存到MEDIA目錄中。image字段的save()方法的參數之一ContentFile是下載的圖片內容,這里使用了save=False防止直接將字段寫入數據庫。
  5. 為了和原save()方法的行為保持一致,僅當commit=True的時候寫入數據庫。

譯者注:本章到現在為止出現了模型的save()方法,表單的save()方法和image字段的save()方法,讀者不要混淆。

之后來編寫處理表單的視圖,編輯images應用的views.py文件,添加如下代碼:

from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .forms import ImageCreateForm

@login_required
def image_create(request):
    if request.method == "POST":
        # 表單被提交
        form = ImageCreateForm(request.POST)
        if form.is_valid():
            # 表單驗證通過
            cd = form.cleaned_data
            new_item = form.save(commit=False)
            # 將當前用戶附加到數據對象上
            new_item.user = request.user
            new_item.save()
            messages.success(request, 'Image added successfully')
            # 重定向到新創建的數據對象的詳情視圖
            return redirect(new_item.get_absolute_url())
    else:
        # 根據GET請求傳入的參數建立表單對象
        form = ImageCreateForm(data=request.GET)

    return render(request, 'images/image/create.html', {'section': 'images', 'form': form})

使用@login_required裝飾器令image_create視圖僅供登錄后的用戶使用,這個視圖工作邏輯如下::

  1. 我們通過一個Get請求附加的參數創建表單對象,參數會帶着urltitle字段對應的內容。這個Get請求是由之后我們創建的JavaScript小書簽程序發起的,現在,我們就假設該表單已經被初始化而且被用戶確認並提交。
  2. 表單提交后,如果驗證通過,那么建立一個新的Image對象,但是不存入數據庫。
  3. 取得當前的用戶,賦給Image對象的外鍵后進行保存,這樣就可以知道該圖片由哪個用戶上傳。
  4. 將圖片寫入數據庫。
  5. 創建一個成功保存圖片的消息,然后將用戶重定向到規范化的圖片對象的URL,現在還沒有為Image模型創建get_absolute_url()方法,稍后會進行創建。

images應用中建立urls.py文件,添加如下代碼:

from django.urls import path
from . import views

app_name = 'images'

urlpatterns = [
    path('create/', views.image_create, name='create'),
]

然后編輯bookmarks項目的根urls.py文件,為images應用增加一條二級路由匹配:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('account/', include('account.urls')),
    path('social-auth/', include('social_django.urls', namespace='social')),
    path('images/', include('images.urls', namespace='images')),
]

最后來建立對應的模板,在images應用的目錄下創建如下目錄和文件結構:

templates/
    images/
        image/
            create.html

然后編輯剛剛創建的create.html文件,添加如下代碼:

{# create.html #}
{% extends "base.html" %}
{% block title %}Bookmark an image{% endblock %}
{% block content %}
    <h1>Bookmark an image</h1>
    <img src="{{ request.GET.url }}" class="image-preview">
    <form action="." method="post">
        {{ form.as_p }}
        {% csrf_token %}
        <input type="submit" value="Bookmark it!">
    </form>
{% endblock %}

現在啟動站點,輸入類似http://127.0.0.1:8000/images/create/?title=...&url=...的鏈接,其中包含titleurl兩個參數,分別表示圖片的名稱和URL地址。可以使用下邊這個測試地址:

http://127.0.0.1:8000/images/create/?title=%20Django%20and%20Duke&url=http://upload.wikimedia.org/wikipedia/commons/8/85/Django_Reinhardt_and_Duke_Ellington_%28Gottlieb%29.jpg

應該可以看到下面的頁面:

image

在description內輸入一些內容,然后點擊BOOKMARK IT!按鈕,一個新的Image對象會被存入數據庫。由於此時get_absolute_url()方法還未編寫,所以會報錯如下:

image

此時不用擔心這個錯誤信息,通過剛才編寫的視圖可以知道,執行到這里報錯說明圖片已經成功存入數據庫,打開http://127.0.0.1:8000/admin/images/image/即可看到該圖片的信息,如下圖所示:

image

2.3使用jQuery創建小書簽程序

小書簽程序是一段JavaScript代碼,可以被瀏覽器保存為書簽,在點擊該小書簽時,其中的JavaScript代碼被執行,從而實現一些功能。

一些比較知名的站點,如Pinterest,使用小書簽程序讓用戶可以從其他網站將內容分享到其網站上。我們建立的程序和這個小書簽程序類似,讓用戶將圖片分享到我們的站點來。

我們將使用jQuery建立小書簽程序,jQuery是一個得到廣泛使用的JavaScript庫,可以快速開發基於JavaScript的程序,可以訪問其官方站點https://jquery.com/了解更多信息。

用戶將會這樣使用我們的小書簽:

  1. 用戶將我們網站上的一個鏈接拖到瀏覽器的書簽欄中,這個鏈接的href屬性中保存着JS代碼,這個鏈接被保存到瀏覽器書簽成為一個可點擊的書簽
  2. 用戶在其他網站上看到想分享的圖片,點擊這個小書簽,小書簽里邊的程序被運行,讓用戶選擇要分享的圖片然后自動以GET請求訪問我們的網站。

由於小書簽程序保存在用戶的瀏覽器上,在用戶第一次保存后,想要更新該程序就很困難,所以一般小書簽程序實際上是一個程序啟動器,實際執行的程序位於我們的網站上。這就是我們創建小書簽的方法解說,現在來實現:

images/templates/目錄下創建一個文件,叫做bookmarklet_launcher.js,添加如下JavaScript代碼:

(function () {
    if (window.myBookmarklet !== undefined) {
        myBookmarklet()
    }
    else {
        document.body.appendChild(document.createElement('script')).src = 'http://127.0.0.1:8000/static/js/bookmarklet.js?r=' + Math.floor(Math.random() * 99999999999999999999);
    }
})();

這段JavaScript代碼首先檢查myBookmarklet這個名稱是否存在於當前環境,這樣用戶反復點擊小書簽程序也不會多次運行相同程序。如果名稱不存在,就在當前的頁面中增加一個<script>標簽,也就是導入了我們網站的一段JavaScript程序並且執行。之后的r參數生成了一段隨機數,目的是讓瀏覽器每次都去請求實際的JavaScript文件,而不從緩存中直接讀取

新增的<script>標簽的src屬性為"http://127.0.0.1:8000/static/js/bookmarklet.js?r=xxxxxxxxxxxxxxxxxxxx",指向我們網站自己的JavaScript程序文件,這樣小程序每次執行的時候,都會將我們網站上的JavaScript程序在當前頁面執行。下邊我們把小程序鏈接加入到用戶登錄首頁,以讓用戶可以將其保存成書簽。

這就是一個啟動器,用於加載實際上位於我們站點上的bookmarklet.js然后在當前頁面運行。

編輯account應用的模板目錄中的account/dashboard.html,讓其看起來像下邊這樣:

{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
    <h1>Dashboard</h1>

    {% with total_images_created=request.user.images_created.count %}
        <p>Welcome to your dashboard. You have bookmarked {{ total_images_created }} image{{ total_images_created|pluralize }}.</p>
    {% endwith %}

    <p>Drag the following button to your bookmarks toolbar to bookmark images from other websites <a href="javascript:{% include "bookmarklet_launcher.js" %}" class="button">Bookmark it</a></p>

    <p>You can also <a href="{% url "edit" %}">edit your profile</a> or <a href="{% url "password_change" %}">change your password</a>.<p>
{% endblock %}

現在首頁已經當前用戶已經分享了多少圖片到本站,使用了{% with %}標簽用於設置一個變量名給圖片總數,可以避免反復查詢數據庫。然后包含了一個href屬性是小標簽啟動器程序的鏈接,供用戶將其拖動到瀏覽器的書簽欄上。這里使用了include將JavaScript文件的內容導入。

譯者注:這里靈活使用了include標簽,可見引入的模板文件不需要是HTML文件,只要是文本文件即可,這里就通過該標簽將bookmarklet_launcher.js文件引入,避免了在此處硬編碼JavaScript代碼。

在瀏覽器中打開http://127.0.0.1:8000/account/,可以看到如下頁面:

image

現在開始來編寫實際執行的JavaScript程序,在images應用下建立如下目錄和文件結構:

static/
    js/
        bookmarklet.js

在隨書代碼中可以看到images應用目錄下有static/css/目錄,將其中的css/目錄拷貝到你的應用的static/目錄下,小書簽程序將要使用其中的bookmarklet.css文件。

打開剛建立的bookmarklet.js文件,添加如下代碼:

(function () {
    let jquery_version = '3.3.1';
    let site_url='http://127.0.0.1:8000/';
    let static_url = site_url + 'static/';
    let min_width = 100;
    let min_height = 100;
    function bookmarklet(msg){
        //這里是分享圖片的代碼
    }

    // 檢查頁面是否加載了jQuery,如果沒有就進行加載,嘗試15次
    if(typeof window.jQuery !== 'undefined'){
        bookmarklet();
    }
    else {
        let conflict = typeof window.$ !== 'undefined';
        let script = document.createElement('script');
        script.src = '//ajax.googleapis.com/ajax/libs/jquery/' + jquery_version + '/jquery.min.js';
        document.head.appendChild(script);
        let attempts = 15;
        (function(){
            if(typeof window.jQuery === 'undefined'){
                if(--attempts>0){
                    window.setTimeout(arguments.callee, 250)
                }else {
                    alert("An error ocurred while loading jQuery")
                }
            }else {
                bookmarklet()
            }
        })();
    }
})();

這是加載jQuery的代碼。如果jQuery已經在當前頁面加載,則會使用當前頁面的jQuery,如果沒有加載,則將jQuery位於google的CDN地址加入到頁面中。當jQuery被成功加載的時候,就去執行bookmarklet()函數,該函數含有實際的分享圖片代碼。在文件開始的地方還定義了如下幾個全局變量:

  • jquery_version:jQuery的版本號
  • site_urlstatic_url:我們網站的地址和靜態文件地址
  • min_widthmin_height:用於控制程序尋找的最小圖片寬高,小於這個寬或高的圖片不會出現在供分享的清單中。

現在來編寫bookmarklet()函數,編輯文件里的bookmarklet()函數的代碼如下:

function bookmarklet(msg){
    // 加載CSS文件
    let css = jQuery('<link>');
    css.attr({
        rel:'stylesheet',
        type:'text/css',
        href:static_url + 'css/bookmarklet.css?r=' + Math.floor(Math.random()*99999999999999999999)
    });
    jQuery('head').append(css);

    // 加載HTML結構
    box_html = '<div id="bookmarklet"><a href="#" id="close">×</a><h1>Select an image to bookmark:</h1><div class="images"></div></div>';
    jQuery('body').append(box_html);

    // 關閉事件
    jQuery('#boorkmarklet #close').click(function () {
        jQuery("#bookmarklet").remove();
    });
};

這段代碼的邏輯如下:

  1. 加載bookmarklet.css,使用隨機數確保瀏覽器不從緩存中讀取
  2. 加入一塊HTML結構代碼到當前頁面的<body>標簽中,在頁面的右上方顯示一個浮動的圖片列表區域
  3. 加入了一個事件,用戶點擊新增的區域的關閉按鈕時,將我們添加的HTML結構代碼從當前頁面中刪除。使用jQuery,通過父元素ID為bookmarklet#bookmarklet#close選擇器定位我們的HTML元素。關於jQuery的選擇器,可以參考https://api.jquery.com/category/selectors/

在加載了HTML結構和對應的CSS樣式后,接下來要添加分享功能,將如下代碼追加在bookmarklet()函數的內部:

    // 尋找頁面內所有圖片然后顯示在新增的HTML結構中
    jQuery.each(jQuery('img[src$="jpg"]'), function(index, image) {
    if (jQuery(image).width() >= min_width && jQuery(image).height() >= min_height)
    {
        image_url = jQuery(image).attr('src');
        jQuery('#bookmarklet .images').append('<a href="#"><img src="'+ image_url +'" /></a>');
    }
});

這段代碼使用了img[src$="jpg"]選擇器來選擇所有jpg格式的<img>元素,然后使用each()方法,對其中每個圖片檢查是否大於最小寬高,如果大於就將其加入到我們HTML結構的<div class="images">標簽中。

在開始試驗編寫的功能之前,還必須進行最后的設置。現在HTTPS協議使用的很廣泛,為了安全起見,瀏覽器一般不會允許HTTP協議的小書簽程序運行,因此必須給我們自己的網站一個HTTPS地址,但是Django的測試服務器無法自動支持HTTPS,為了測試小書簽的功能,使用Ngrok可以建立一個隧道將自己的本機通過HTTP和HTTPS地址向外提供服務。

https://ngrok.com/download下載Ngrok,之后在系統命令行里運行如下命令:

./ngrok http 8000

Ngrok建立一個隧道連接到本機的8000端口,然后為其分配一個域名,可以看到窗口里顯示:

ngrok by @inconshreveable                                                                               (Ctrl+C to quit)

Session Status                online
Session Expires               7 hours, 58 minutes
Version                       2.2.8
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://d0de3ca5.ngrok.io -> localhost:8000
Forwarding                    https://d0de3ca5.ngrok.io -> localhost:8000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00

其中的https://d0de3ca5.ngrok.io就是可以訪問到本機Django服務的HTTPS地址,把這個地址加入到settings.py文件的的ALLOWED_HOSTS里:

ALLOWED_HOSTS = [
    'mysite.com',
    'localhost',
    '127.0.0.1',
    `'d0de3ca5.ngrok.io'`
]

譯者注:最好按照Ngrok官網的教程注冊一個用戶再使用,否則HTTPS的域名很快過期,需要重新啟動Ngrok並進行相關配置。

啟動站點,然后訪問這個HTTPS地址,應該可以看到站點的登錄頁面,說明HTTPS服務正常。

獲得HTTPS地址之后,編輯bookmarklet_launcher.js文件,將其中的http://127.0.0.1:8000/替換為新獲得的HTTPS地址:

(function () {
    if (window.myBookmarklet !== undefined) {
        myBookmarklet()
    }
    else {
        document.body.appendChild(document.createElement('script')).src = 'https://d0de3ca5.ngrok.io/static/js/bookmarklet.js?r=' + Math.floor(Math.random() * 99999999999999999999);
    }
})();

再將js/bookmarklet.js文件中的這一行:

let site_url='http://127.0.0.1:8000/';

修改為:

let site_url='https://d0de3ca5.ngrok.io/';

然后打開https://d0de3ca5.ngrok.io/account/,將頁面上的BOOKMART IT的綠色按鈕拖到瀏覽器的書簽欄上,如圖所示:

image

打開任意一個圖片比較多的網站,點擊小書簽,應該可以看到屏幕右上方顯示一塊新區域,里邊列出了當前站點可供分享的圖片,如下所示:

image

我們希望用戶點擊一張圖片,就可以將該圖片分享到我們的網站,進入之前編寫的視圖對應的表單填寫頁面上,編輯js/bookmarklet.js文件,在bookmarklet()函數底部追加:

    // 點擊圖片時按照指定URL訪問我們的網站
    jQuery('#bookmarklet .images a').click(function(e){
      let selected_image = jQuery(this).children('img').attr('src');
      // hide bookmarklet
      jQuery('#bookmarklet').hide();
      // open new window to submit the image
      window.open(site_url +'images/create/?url='
                  + encodeURIComponent(selected_image)
                  + '&title='
                  + encodeURIComponent(jQuery('title').text()),
                  '_blank');
    });

這個函數的邏輯如下:

  1. 為每個圖片元素綁定一個click()事件
  2. 當用戶點擊一個圖片時,設置一個變量selected_image,是這個圖片的URL地址。
  3. 之后隱藏新增的HTML結構,使用selected_image和網站的的<title>的內容外加我們的網站地址,生成一個鏈接然后在新窗口中打開鏈接,實現GET請求附帶參數訪問我們自己的網站。

打開一個網站,然后點擊小書簽,在右上方出現的窗口中點擊一張圖片,會被重定向到我們網站的圖片創建頁面,如下所示:

image

撒花慶祝,我們實現了第一個小書簽程序,然后將其集成到了我們的Django項目中。

3創建圖片詳情視圖

完成了圖片分享並保存的功能之后,現在需要建立一個詳情視圖用來展示具體圖片,編輯images應用的views.py文件,添加如下代碼:

from django.shortcuts import get_object_or_404
from .models import Image

def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id,slug=slug)
    return render(request, 'images/image/detail.html', {'section':'images','image':image})

這是一個簡單的用於展示某個圖片詳情的視圖,編輯images應用的urls.py文件為該視圖添加一行URL:

path('detail/<int:id>/<slug:slug>/', views.image_detail, name='detail'),

有過上個項目的經驗,此時可以知道必須編寫Image類的get_absolute_url()方法用於生成規范化鏈接,打開images應用的models.py文件,添加get_absolute_url()方法如下:

from django.urls import reverse

class Image(models.Model):
    # ...
    def get_absolute_url(self):
        return reverse('images:detail', args=[self.id, self.slug])

記住在每個編寫的模型中加入該方法,以快捷的生成對應的URL。

譯者注:在django 2里,urls.py文件中使用include()方法並通過namespace參數指定命名空間,還需要在對應的下一級urls.py里寫上app_name = 'namespace' 來設置命名空間。如果include()方法中設置了命名空間,其對應的urls.py文件中的app_name必須一致,否則會報錯。如果include()方法未設置命名空間,則以app_name的設置為准。

最后就是建立模板了,在images應用的模板目錄中的/images/image/路徑下創建detail.html文件並添加如下代碼:

{#/templates/images/image/detail.html#}
{% extends 'base.html' %}

{% block title %}
    {{ image.title }}
{% endblock %}

{% block content %}
    <h1>{{ image.title }}</h1>
    <img src="{{ image.image.url }}" class="image-detail">
    {% with total_likes=image.users_like.count %}
        <div class="image-info">
            <div>
        <span class="count">
            {{ total_likes }} like{{ total_likes|pluralize }}
        </span>
            </div>
            {{ image.description|linebreaks }}
        </div>
        <div class="image-likes">
            {% for user in image.users_like.all %}
                <div>
                    <img src="{{ user.profile.photo.url }}">
                    <p>{{ user.first_name }}</p>
                </div>
            {% empty %}
                Nobody likes this image yet.
            {% endfor %}
        </div>
    {% endwith %}
{% endblock %}

這是展示具體某個圖片的模板,其中使用{% with %}保存查詢結果到total_likes變量中避免了查詢兩次數據庫。然后展示圖片的discription字段,之后迭代image.users_like.all,顯示出所有喜歡該圖片的用戶。

在一個模板中反復使用某一個QuerySet時,可以通過{% with %}將其查詢結果保存到一個變量中,避免重復查詢。

譯者注:image.image.urluser.profile.photo.url:這兩個字段不是Image類中的url字段,而是在定義Imagefield字段時upload_to的路徑名稱。

現在可以通過小書簽程序再導入一個新圖片,保存成功之后,會被重定向到圖片的詳情頁,如下所示:

image

4創建圖片縮略圖

現在我們的圖片詳情頁展示的是原始的圖片,但是圖片的尺寸可能差異很大,而且原始圖片的大小可能會很大,載入時間較長。一般網站需要大量展示圖片的通用做法是生成圖片的縮略圖然后展示縮略圖。我們使用一個第三方應用sorl-thumbnail來生成縮略圖。

在系統命令行中輸入以下命令安裝sorl-thumbnail

pip install sorl-thumbnail==12.4.1

然后在settings.py文件中激活該應用:

INSTALLED_APPS = [
    # ...
    'sorl.thumbnail',
]

之后按照慣例執行數據遷移程序,可以看到數據庫中增添了該應用的一個數據表。

這個模塊采用了兩種方法顯示縮略圖:一是提供了新的模板標簽{% thumbnail %}直接在模板內顯示縮略圖,二是基於Imagefield自定義的圖片字段,用於在模型內設置縮略圖字段。這兩種方式都可以顯示縮略圖。

我們采用模板標簽的方式。編輯images/image/detail.html,找到如下這行:

<img src="{{ image.image.url }}" class="image-detail">

將其替換成下列代碼:

{% load thumbnail %}
{% thumbnail image.image "300" as im %}
    <a href="{{ image.image.url }}">
        <img src="{{ im.url }}" class="image-detail">
    </a>
{% endthumbnail %}

這里我們定義了個固定寬度為300像素的縮略圖,當用戶第一次打開圖片詳情頁時,一個縮略圖會被創建在靜態文件夾下,頁面的原圖片鏈接會被縮略圖鏈接所代替。啟動站點然后打開某個圖片詳情頁,可以在項目根目錄的media/cache/找到該圖片對應的縮略圖。

sorl-thumbnail可以使用很多算法生成各種縮略圖。如果生成不了縮略圖,在settings.py里增加一行THUMBNAIL_DEBUG=True,之后在命令行窗口中可以看到debug信息。具體文檔可以看https://sorl-thumbnail.readthedocs.io/

5使用jQuery發送AJAX請求

現在要給站點增加AJAX相關的功能,AJAX是Asynchronous JavaScript and XML的簡稱,這個技術使用一系列方式實現異步HTTP請求,可以從服務器異步取得數據並無需重載全部頁面。不像名字里邊必須采取XML格式,發送和收取數據可以采用JSON,HTML甚至純文本。

AJAX的相關內容可以參考在Django中使用jQuery發送AJAX請求使用原生JS發送AJAX請求的方法

我們將要給圖片詳情頁面增加一個按鈕,讓用戶可以點擊該按鈕表示喜歡該圖片,之后再點擊該按鈕可以取消喜歡該圖片。首先我們先為這個功能建立視圖函數,編寫images應用的views.py文件,添加如下代碼:

from django.http import JsonResponse
from django.views.decorators.http import require_POST

@login_required
@require_POST
def image_like(request):
    image_id = request.POST.get('id')
    action = request.POST.get('action')
    if image_id and action:
        try:
            image = Image.objects.get(id=image_id)
            if action == "like":
                image.users_like.add(request.user)
            else:
                image.users_like.remove(request.user)
            return JsonResponse({'status': 'ok'})
        except:
            pass
    return JsonResponse({'status': 'ko'})

這個視圖使用了兩個裝飾器,@login_required的作用是僅供已登錄用戶使用,@require_POST的作用是讓該視圖僅接受POST請求,否則返回一個HttpResponseNotAllowed對象,即HTTP 405錯誤。Django還提供了一個@require_GET裝飾器用於只接受GET請求,還提供了一個@require_http_methods裝飾器,可以指定允許哪些類型的HTTP請求。

在這個視圖中,我們還是用了兩個Post.get取得數據:

  • image_id:用戶正在喜歡/不喜歡的圖片的ID
  • action:用戶執行的動作,用字符串like表示喜歡,unlike表示不喜歡

這里還使用了多對多字段的管理器users_like查詢圖片與喜歡用戶之間的關系,然后使用add()remove()方法用於添加和去除多對多關系。add()方法即使傳入已經存在的數據對象,也不會重復建立關系,remove()即使傳入不存在的對象,也不會報錯。還有一個clear()方法可以快速的從關聯表中全部清除多對多關系。

最后,使用了JsonResponse類,這個類的作用是將一個HTTP請求附加上application/json請求頭,並將其中的內容序列化為JSON格式的字符串

編輯images應用的urls.py,為該視圖配置URL:

    path('like/', views.image_like, name='like'),

5.1加載jQuery

我們將使用jQuery來發送AJAX請求,為此需要在頁面內加載jQuery,為了可以讓jQuery在所有的模板內都生效,將其加載代碼放入base.html文件中,編輯account應用的base.html文件,在之前增加下列代碼:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script>
    $(document).ready(function () {
        {% block domready %}
        {% endblock %}
    });
</script>

我們從Google CDN中加載了jQuery,可以直接在https://jquery.com/下載jQuery並將其放入本應用的static文件夾內。

在引入jQuery之后,增加了一個<script>標簽,定義了一個$(document).ready(),這是一個jQuery方法,在DOM加載完畢后會執行該方法。DOM是Document Object Model的簡稱,由瀏覽器在加載頁面時生成,以樹形結構保存當前頁面的所有節點數據。這樣保證了JS代碼執行時,其要操作的對象已經全部生成。

domready塊,用於存放在DOM加載完畢后執行的JS代碼,我們將在需要執行JS代碼的具體模板中編寫該塊內容。

注意不要混淆JavaScript代碼和Djanog模板標簽。Django的模板語言在服務端進行處理,轉換最終的HTML字節流,瀏覽器取得HTML字節流創建頁面和DOM對象,並執行JavaScript代碼。有時候動態的生成JavaScript代碼非常方便。

在這一章里,我們直接將JS代碼通過模板內塊的形式編寫進來,這是為了教學方便。最好的方式是從靜態文件中導入.js文件,以做到有效解耦HTML與JS。

5.2AJAX中使用CSRF

在第二章中已經了解到POST請求中需要包含{% csrf_token %}生成的token數據,以防止跨站偽造請求攻擊。不過在AJAX中發送CRSF token有點不方便,所以Django允許在AJAX請求中設置一個X-CSRFToken請求頭,其中包含CSRF token的數據。jQuery在發送AJAX請求的時候設置上該請求頭,就可以完成CRSF的發送了。

為了在AJAX請求中設置CSRF token,需要做如下事情:

  1. csrftoken cookie中取得CSRF token,如果開啟了CSRF中間件,cookie中一直會有CSRF token數據
  2. 將CSRF token數據設置在AJAX請求的X-CSRFToken請求頭中

可以在https://docs.djangoproject.com/en/2.0/ref/csrf/#ajax閱讀更多關於Django中CSRF與AJAX的信息。

修改剛剛在base.html中增加的JS代碼部分,修改成下邊這樣:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
<script>
    let csrftoken = Cookies.get('csrftoken');

    function csrfSafeMethon(method) {
        // 如下的HTTP請求不需要設置CRSF信息
        return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
    }

    $.ajaxSetup({
        beforeSend: function (xhr, settings) {
            if (!csrfSafeMethon(settings.type) && !this.crossDomain) {
                xhr.setRequestHeader("X-CSRFToken", csrftoken);
            }
        }
    });
        $(document).ready(function () {
        {% block domready %}
        {% endblock %}
    });
</script>

以上代碼解釋如下::

  1. 通過外部CDN導入了一個JS庫js-cookie--一個輕量級的操作cookie的第三方庫,可以在https://github.com/js-cookie/js-cookie找到該庫的詳細信息。
  2. 通過Cookies.get()方法拿到csrftoken的值
  3. 創建csrfSafeMethod()函數,使用正則驗證HTTP請求種類,GET,HEAD,OPTIONS和TRACE類型的請求無需添加CSRF信息
  4. 調用$.ajaxSetup()方法,在AJAX請求發送之前,為請求設置X-CSRFToken請求頭信息,這個設置會影響到所有jQuery發送的AJAX請求。

這樣所有的不安全的HTTP請求,比如GETPUT,都會被添加上CRSF信息。

5.3jQuery發送AJAX請求

編輯images應用的images/image/detail.html文件,找到下邊這行:

{% with total_likes=image.users_like.count %}

將其修改成:

{% with total_likes=image.users_like.count users_like=image.users_like.all %}

然后修改<div class="image-info">其中的內容,如下:

<div class="image-info">
    <div>
        <span class="count">
             <span class="total">{{ total_likes }}</span>
             like{{ total_likes|pluralize }}
        </span>
        <a href="#" data-id="{{ image.id }}" data-action="{% if request.user in users_like %}un{% endif %}like" class="like button">
            {% if request.user not in users_like %}
                Like
            {% else %}
                Unlike
            {% endif %}
        </a>
    </div>
    {{ image.description|linebreaks }}
</div>

模板內首先通過{% with %}指定了新的變量users_like,用於存放所有喜歡該圖片的用戶,可以避免反復查詢。然后顯示總的喜歡該圖片的人數,還包含一個按鈕樣式的<a>標簽。這個按鈕根據當前用戶是否在users_like中,顯示likeunlike,還為<a>標簽設置了兩個HTML5自定義屬性:

  1. data-id:當前頁面顯示圖片的ID
  2. data-action:用戶的動作,喜歡或者不喜歡,值是likeunlike

我們將把這兩個HTML5自定義屬性的值通過AJAX發送給image_like視圖,當用戶點擊喜歡/不喜歡按鈕的時候,我們需要在客戶端做如下操作:

  1. 調用AJAX視圖,傳入兩個參數:idaction
  2. 如果AJAX請求成功返回,更新按鈕的data-action屬性為相反的操作(原來是like則更新為unlike,反之亦反)
  3. 更新喜歡當前圖片的用戶總數

為此來編寫頁面所需的JS代碼,在images/image/detail.html中添加domready塊的內容:

{% block domready %}
$('a.like').click(function (e) {
    e.preventDefault();
    $.post('{% url 'images:like' %}',
        {
            id: $(this).data('id'),
            action: $(this).data('action'),
        },
        function (data) {
            if (data['status'] === 'ok') {
                let previous_action = $('a.like').data('action');
                //切換 data-action 屬性
                $('a.like').data('action', previous_action === 'like' ? 'unlike' : 'like');
                //切換按鈕文本
                $('a.like').text(previous_action === 'like' ? 'Unlike' : 'Like');
                //更新總的喜歡人數
                let previous_likes = parseInt($('span.count.total').text());
                $('span.count.total').text(previous_action === 'like' ? previous_likes + 1 : previous_likes - 1);
            }
        }
    );
});
{% endblock %}

這段代碼的邏輯解釋如下:

  1. 使用$('a.like')選擇所有屬於like類的<a>標簽
  2. <a>標簽綁定click事件,每次點擊就發送AJAX請求。
  3. 在事件處理函數內,使用e.preventDefault()阻止<a>的默認功能,即阻止打開新的超鏈接
  4. 使用$.post()發送異步的POST請求。jQuery還提供了$.get()用於發送異步的GET請求,和一個更底層的$.ajax()方法。
  5. 使用{% url %}反向解析出AJAX的請求目標地址
  6. 創建要發送的數據字典,通過<a>標簽的data-iddata-action設置idaction鍵值對。
  7. 設置回調函數,當成功收到AJAX響應時執行,響應數據被包含在對象data中。
  8. 根據data中的status判斷值是否為ok,如果是則切換data-action和按鈕文本。
  9. 根據剛才執行的結果,對總喜歡人數增加1或者減少1

譯者注:原書這里的邏輯是為了讓讀者可以迅速看出操作結果。在多用戶的環境中,不能如此簡單的增減1,因為每次執行動作后,該人數的變化未必是1。

打開任意圖片詳情頁,可以看到新增的總人數和按鈕,如下所示:

image

點擊一下LIKE按鈕,可以看到如下所示:

image

如果再點擊UNLIKE按鈕,可以看到按鈕變回LIKE,人數也減少1

如果提示The 'photo' attribute has no file associated with it錯誤,原書作者在這里沒有講清楚,錯誤原因是detail.html頁面用了user.profile.photo.url,但沒有上傳用戶頭像。在管理后台給每個用戶上傳頭像,再訪問任意詳情圖片頁,就不會報錯了。直接修改多對多的關系再查看這張表,就能發現顯示出同樣喜歡了這張圖的用戶頭像和名稱。這里如果要完善的話,應該判斷用戶是否上傳頭像,如果沒有就用默認頭像代替。

當編寫JavaScript代碼發送AJAX請求時,為了方便調試,推薦使用開發工具而不是在Django中編寫代碼。現代瀏覽器都帶有開發工具用於調試頁面和JavaScript代碼,通常可以按F12或者在頁面上右擊選“檢查”來啟動開發工具。

6創建自定義裝飾器

在AJAX視圖中使用了@require_POST裝飾器以限制視圖僅接受POST請求,這顯然還不夠,需要讓這個視圖僅接受AJAX請求才行。Django對於HTTP請求對象提供了一個is_ajax()方法,通過HTTP請求頭部字段HTTP_X_REQUESTED_WITH HTTP判斷該請求是否是一個XMLHttpRequest對象,即一個AJAX請求。

我們准備自行編寫一個裝飾器,用於檢查HTTP請求的HTTP_X_REQUESTED_WITH頭部信息,從而限制我們的視圖僅接受AJAX請求。Python中的裝飾器是接受一個函數為參數的函數,為參數函數附加執行額外功能而不改變原函數的功能。 如果對裝飾器不太了解,可以參考Python官方文檔:https://www.python.org/dev/peps/pep-0318/

我們准備編寫的裝飾器是通用的,所以在bookmarks項目根目錄下建立一個common包,其中的文件如下:

common/
    __init__.py
    decorators.py

編輯decorators.py文件,添加下列代碼:

from django.http import HttpResponseBadRequest

def ajax_required(func):
    def wrap(request, *args, kwargs):
        if not request.is_ajax():
            return HttpResponseBadRequest()
        else:
            return func(request, *args, kwargs)
    wrap.__doc__ = func.__doc__
    wrap.__name__ = func.__name__
    return wrap

這段代碼就是自定義的ajax_required裝飾器函數。其中定義了一個wrap函數,如果請求不是AJAX請求,就返回HttpResponseBadRequest即HTTP 400錯誤。如果是AJAX請求,則原來視圖的功能正常執行。

然后編輯images應用的views.py文件,導入新的包然后為視圖添加自定義裝飾器:

from common.decorators import ajax_required

@ajax_required
@login_required
@require_POST
def image_like(request):
    # ......

如果用瀏覽器直接訪問http://127.0.0.1:8000/images/like/,會得到400錯誤。(未添加該裝飾器之前,得到的是由@require_POST返回的405錯誤)。

如果你發現項目中的很多視圖對同一個條件做判斷,可以考慮將該判斷邏輯編寫為一個自定義裝飾器。

7AJAX分頁

我們將制作一個圖片列表頁,用於列出我們網站所有的圖片。這里將使用AJAX動態的發送圖片數據,即當頁面滾動到底部的時候,就會繼續顯示新的圖片,直到全部圖片都顯示完畢。

為此我們將編寫一個圖片列表視圖,同時處理普通的HTTP請求和AJAX請求。當用戶一開始以GET請求方式訪問圖片列表頁時,會顯示第一頁圖片。當用戶滾動到頁面底部時,通過AJAX發送請求給該視圖,返回下一頁圖片顯示在頁面底部;如此反復直到所有圖片都顯示完畢。

編輯images應用的views.py文件,創建一個新的視圖:

from django.http import HttpResponse
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger

@login_required
def image_list(request):
    images = Image.objects.all()
    paginator = Paginator(images, 8)
    page = request.GET.get('page')
    try:
        images = paginator.page(page)
    except PageNotAnInteger:
        # 如果頁數不是整數,就返回第一頁
        images = paginator.page(1)
    except EmptyPage:
        # 如果是不存在的頁數,而且請求是AJAX請求,返回空字符串
        if request.is_ajax():
            return HttpResponse('')
        # 如果頁數超范圍,顯示最后一頁
        images = paginator.page(paginator.num_pages)
    if request.is_ajax():
        return render(request, 'images/image/list_ajax.html', {'section': 'images', 'images': images})
    return render(request, 'images/image/list.html', {'section': 'images', 'images': images})

在這個視圖中,先查詢所有圖片,然后使用內置的分頁功能創建Paginator對象,按照8個圖片一頁進行分組。當HTTP請求的頁面不存在的時候捕捉EmptyPage異常,判斷此時請求的種類,如果是AJAX請求,說明頁面到了底部,返回空字符串即可。我們將結果渲染到兩個不同的模板中:

  1. 對於AJAX請求,渲染list_ajax.html模板,這個模板僅包含圖片內容。
  2. 對於普通請求,渲染list.html,這個模板會繼承base.html,並且include``list_ajax.html模板

編輯images應用的urls.py文件,為新視圖添加一行URL:

    path('', views.image_list, name='list'),

最后來創建前述的兩個模板,在images/image/模板目錄下創建list_ajax.html,添加如下代碼:

{% load thumbnail %}

{% for image in images %}
  <div class="image">
    <a href="{{ image.get_absolute_url }}">
      {% thumbnail image.image "300x300" crop="100%" as im %}
        <a href="{{ image.get_absolute_url }}">
          <img src="{{ im.url }}">
        </a>
      {% endthumbnail %}
    </a>
    <div class="info">
      <a href="{{ image.get_absolute_url }}" class="title">
        {{ image.title }}
      </a>
    </div>
  </div>
{% endfor %}

上述模板顯示圖片列表,將使用這個模板渲染AJAX請求返回的結果。在相同目錄下創建list.html文件並添加如下代碼:

{% extends 'base.html' %}

{% block title %}
Images bookmarked
{% endblock %}

{% block content %}
<h1>Images bookmarked</h1>
<div id="image-list">
{% include 'images/image/list_ajax.html' %}
</div>
{% endblock %}

這個頁面繼承base.html,同時包含了list_ajax.html,這個模板中還必須包含發送AJAX的JS代碼,所以繼續在其中編寫domready塊的內容:

{% block domready %}
let page = 1;
let empty_page = false;
let block_request = false;
$(window).scroll(
    function () {
        let margin = $(document).height() - $(window).height() - 200;
        if ($(window).scrollTop() > margin && empty_page === false && block_request === false) {
            block_request = true;
            page += 1;
            $.get("?page=" + page, function (data) {
                if (data === "") {
                    empty_page = true;
                }
                else {
                    block_request = false;
                    $('#image-list').append(data)
                }
            });
        }
    }
);
{% endblock %}

這段代碼實現了滾動加載功能,其中的邏輯解釋如下:

  1. 首先創建如下變量:
    1. page:存儲當前頁數
    2. empty_page:判斷是否已經到達頁面底部。如果已經到達底部,阻止發送AJAX請求
    3. block_request:在已經發送AJAX請求但還未收到響應時阻止再發送AJAX請求
  2. 使用$(window).scroll()方法監聽滾動事件
  3. 計算頁面高度和窗口高度的差,記錄在margin變量中,表示未顯示的頁面的高度。再減去200表示當滾動到離窗口底部還有200像素的時候發送AJAX請求。
  4. 判斷block_requestempty_page同時為False的情況下發送AJAX請求。
  5. 發送AJAX請求之后將block_request設置為True,避免再次發送,同時將page增加1,下一次發送的時候就獲取下一個分頁結果。
  6. 使用$.get()方法發送類型為GET的AJAX請求,將響應數據保存到data中,然后處理以下兩種情況:
    1. 響應數據中無內容:說明視圖返回了空字符串,已經沒有更多的分頁結果可以加載,此時將empty_page設置為True,阻止后續所有AJAX請求發送
    2. 響應數據中有數據:說明得到了新的分頁結果,將其中的內容追加到id屬性為image-list的元素內部,頁面下方增加出新的圖片。

在瀏覽器中打開http://127.0.0.1:8000/images/,可以看到如下頁面(需要自行添加一些圖片):

image

滾動該頁面到底部,確保在數據庫中添加了超過8張圖片,會看到額外的圖片被加載並顯示出來

最后修改base.html文件中頂部導航欄的連接,添加下列代碼:

<li {% if section == "images" %}class="selected"{% endif %}>
    <a href="{% url "images:list" %}">Images</a>
</li>

現在就可以通過用戶首頁訪問圖片清單頁面了。

總結

這一章建立了一個小書簽程序,用於分享圖片到本站,還實現了jQuery發送AJAX請求和使用AJAX動態加載頁面。

下一章將學習建立關注系統,涉及到模型的通用關系,信號功能和數據庫的非規范化等知識,還將學習到在Django中使用Redis數據庫。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM