<style>
.markdown-body img:not(.emoji) {
border-radius: 5px;
box-shadow: 1px 1px 5px dimgrey;
}
</style>
案例:線上投票
===
先前我們已經建立了一個簡單的線上投票網站,看起來有點陽春,接下來試著將輸出的頁面稍加美化一下。
如果對於 CSS 已經很熟悉了,可以直接自己撰寫樣式表。在這個案例中,我們直接引入市面上很多人使用的 CSS 框架 -- Bootstrap 來美化網站的外觀。
Bootstrap 官網:[https://getbootstrap.com/](https://getbootstrap.com/)
## 引用 Bootstrap 框架
在專案中加入 Bootstrap 框架有好幾種方式,可以將至官網下載後,將其當成專案靜態檔案的一部份。最簡便的方式則是透過網路上的 CDN 服務,直接引用所需要的檔案。以下兩種方式請選擇一種套用即可。
### 透過 CDN 服務引用
參考 Bootstrap 官網上的說明,將 `default/templates/base.html` 修改如下:
``` html=
<!DOCTYPE html>
<html lang="zh-hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 引用 Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<title>線上投票</title>
</head>
<body>
<div id="main-content" class="container">
{% block content %}{% endblock %}
</div>
<!-- Bootstrap library -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>
```
- 插入++第 6 - 7 行
- ++第 7 行++的作用就是直接從 Bootstrap 的 CDN 引用 Bootstrap CSS 框架
- 修改++第 11, 13 行,為 `div` 元素套用 `container` 類別,讓內容在顯示的時候在周圍留些間隙,視覺上會比較舒適一些,不會太過擁擠
- 插入++第 14 - 15 行++,由 CDN 載入 Bootstrap 框架的 Javascript 函式庫,在本例中沒使用特殊的互動效果,這 2 行亦可省略

:::info
:bulb: **CDN 是什麼**
內容傳遞網路(英語:Content delivery network或Content distribution network,縮寫:CDN)是指一種透過網際網路互相連接的電腦網路系統,利用最靠近每位使用者的伺服器,更快、更可靠地將音樂、圖片、影片、應用程式及其他檔案傳送給使用者,來提供高效能、可擴展性及低成本的網路內容傳遞給使用者。
來源:https://zh.wikipedia.org/wiki/內容傳遞網路 ↩︎
:::
### 下載為專案的靜態檔案
#### 指定靜態檔案存放路徑
先修改專案設定檔 `poll/settings.py`,指定靜態檔案存放的資料夾:
``` python=116
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = 'static/'
STATICFILES_DIRS = [
BASE_DIR / "static",
]
```
- 新增++第 121 - 123 行++,透過 `STATICFILES_DIRS` 來指定靜態檔案的存放路徑
- ++第 124 行++的意思是,專案所在的資料夾下的 `static` 目錄
- `BASE_DIR` 在 `settings.py` 前面有定義過,它的內容就是這個專案所在的資料夾的路徑
接著在專案資料夾下建立 `static` 資料夾,建立完後,整個專案的目錄結構如下:
```
poll/
├── default/
│ ├── migrations/
│ └── templates/
│ └── default/
├── poll/
└── static/
```
#### 將 Bootstrap 相關檔案放到靜態檔案存放路徑之下
1. 到 Bootstrap 官網:https://getbootstrap.com/

點選「`Download`」連結進入下載頁面。
2. 點按下載頁面的 __Compiled CSS and JS__ 下方的「`Download`」按鈕

3. 將下載的壓縮檔解開後,把得到的 `css` 與 `js` 兩個資料夾複製到專案的靜態檔案存放路徑(`static`)下,完成後整個專案的目錄結構如下:
```
poll/
├── default/
│ ├── migrations/
│ └── templates/
│ └── default/
├── poll/
└── static/
├── css/
└── js/
```
#### 修改網站基底頁面範本
將網站基底頁面範本 `default/templates/base.html` 修改如下:
``` html=
<!DOCTYPE html>
<html lang="zh-hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 引用 Bootstrap CSS -->
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<title>線上投票</title>
</head>
<body>
<div id="main-content" class="container">
{% block content %}{% endblock %}
</div>
<!-- Bootstrap library -->
<script src="/static/js/bootstrap.min.js"></script>
</body>
</html>
```
- ++第 7 行++,引用專案靜態檔案資料夾內的 Bootstrap CSS 框架
- ++第 11 行++,將 div 套用 `container` 類別,讓其內容在顯示的時候在周圍留些間隙,視覺上會比較舒適一些,不會太過擁擠
- ++第 17 - 18 行++,載入專案內 Bootstrap 框架的 Javascript 函式庫,在本例中沒使用特殊的互動效果,這 2 行亦可省略

:arrow_up: 引用 Bootstrap 框架前

:arrow_up: 引用 Bootstrap 框架後
上面兩張擷圖顯示了引用 Bootstrap 框架前後的差異:
- `<H1></H1>` 的字體大小
- 連結文字的顏色不一樣了,另外,連結的底線也消失了
- 顯示的內容左邊多了留白的空間,讓版面看起來比較不那麼擁擠
## 美化頁面上的元件
使用 CSS 框架的好處是,這些框架已將事先定義好一整套的樣式規則,我們僅需要將網頁上的元件套用欲使用的 CSS 類別,就能輕鬆地增進網頁的美觀,並維持網站頁面外觀的一致性。
### 按鈕(Button)
若想把問題列表頁面上的「新增投票主題」、「修改」、「刪除」等連結改得像是按鈕的外觀的話,可以將這些連結套用 `btn` 以及與其語意相符的按鈕樣式類別即可。預先定義的按鈕樣式的類別如下表:
|按鈕樣式|外觀範例|按鈕樣式|外觀範例|
|-|-|-|-|
|btn-primary||btn-secondary||
|btn-success||btn-danger||
|btn-warning||btn-info||
|btn-light||btn-dark||
|btn-link||
另外,除了預設尺寸外,也可以額外套用 `btn-lg` 或 `btn-sm` 來指定按鈕的尺寸為「大」或「小」。
依上述說明修改 `default/templates/default/poll_list.html`,將「新增投票主題」、「修改」、「刪除」等連結套用 CSS 類別:
``` html=
{% extends "base.html" %}
{% block content %}
<h1>投票主題</h1>
<p><a href="create/" class="btn btn-primary">新增投票主題</a></p>
<ul>
{% for poll in poll_list %}
<li>
{{ poll.date_created }}
<a href="{{ poll.id }}/">{{ poll.subject }}</a> |
<a href="{{ poll.id }}/update/" class="btn btn-sm btn-secondary">修改</a> |
<a href="{{ poll.id }}/delete/" class="btn btn-sm btn-danger">刪除</a> |
</li>
{% endfor %}
</ul>
{% endblock %}
```
- 修改++第 5, 11, 12 行++,為 `<a>` 標籤新增 `class` 屬性
- ++第 5 行++,為「新增投票主題」這個連結同時套用 `btn` 以及 `btn-primary` 這兩種 CSS 類別。
- ++第 11, 12 行++額外指定要套用 `btn-sm` 類別,因此每個問題列表項目的「修改」與「刪除」連結的按鈕尺寸會比上方的「新增投票主題」來得小。修改後頁面外觀如下圖:

### 列表、清單(List group)
接下來美化一下問題列表的外觀。將 `<ul>` 套用 `list-group` 類別,並將其內的 `<li>` 元素皆套用 `list-group-item` 類別:
``` html=
{% extends "base.html" %}
{% block content %}
<h1>投票主題</h1>
<p><a href="create/" class="btn btn-primary">新增投票主題</a></p>
<ul class="list-group">
{% for poll in poll_list %}
<li class="list-group-item">
{{ poll.date_created }}
<a href="{{ poll.id }}/">{{ poll.subject }}</a>
<a href="{{ poll.id }}/update/" class="btn btn-sm btn-secondary">修改</a>
<a href="{{ poll.id }}/delete/" class="btn btn-sm btn-danger">刪除</a>
</li>
{% endfor %}
</ul>
{% endblock %}
```
- 在++第 6, 8 行++分別套用 `list-group` 與 `list-group-item` 類別後,每個 `<li>` 前方的黑點消失了,每個投票主題項目都加上了外框,並增加了內邊界(padding)。

### 資料卡片(Card)
接下來試著美化檢視問題的頁面,在此我們選用另一種方式-資料卡片(Card)-來呈現問題的詳細內容。
資料卡片的結構大致如下:
``` html=
<div class="card">
<div class="card-header">卡片標題</div>
<div class="card-body">
卡片內容
</div>
<div class="card-footer">卡片頁腳</div>
</div>
```
每張卡片可包含 `card-header` 、 `card-body` 以及 `card-footer` 3 個部份,前面兩個比較容易理解, `card-footer` 通常是用來放說明或註解用的。
修改 `default/templates/default/poll_detail.html`,將投票主題當成資料卡片的標題,而附屬於這個主題的投票選項就當成資料卡片的內容,而新增選項的連結則當成頁腳。
``` html=
{% extends "base.html" %}
{% block content %}
<p><a href="/" class="btn btn-primary">回首頁</a></p>
<div class="card">
<div class="card-header">
<h1>{{ poll.subject }}</h1>
</div>
<div class="card-body">
<div>小提示!直接按選項文字就可以投票囉!</div>
<ul class="list-group">
{% for option in options %}
<li class="list-group-item">
<a href="{% url 'option_edit' option.id %}" class="btn btn-sm btn-secondary">修改</a>
<a href="{% url 'option_delete' option.id %}" class="btn btn-sm btn-danger">刪除</a>
<a href="{% url 'poll_vote' option.id %}">{{ option.title }}</a> : {{ option.count }} 票
</li>
{% endfor %}
</ul>
</div>
<div class="card-footer">
<a href="{% url 'option_create' poll.id %}" class="btn btn-sm btn-primary">新增選項</a>
</div>
</div>
{% endblock %}
```

### 修改其它頁面範本
接著美化 `default/templates/default/poll_form.html`:
``` html=
{% extends "base.html" %}
{% block content %}
{% if backpath %}
<a href="{{ backpath }}" class="btn btn-primary">返回前一頁</a><BR>
{% endif %}
<h1>{{ title }}</h1>
<form action="" method="post">
{% csrf_token %}
<table>
{{ form.as_table }}
</table>
<input type="submit" value="送出" class="btn btn-sm btn-primary">
</form>
{% endblock %}
```
- 因為在「新增投票主題」、「修改投票主題」、「新增投票選項」以及「修改投票選項」等 4 個頁面都會共用這個範本,為了讓使用者更容易識別目前正在進行哪一種操作,在這裡增加了第 7 行的程式碼:
``` html=7
<h1>{{ title }}</h1>
```
- 另外,為了方便使用者返回前一個頁面,範本中也增加了第 4 - 6 行的內容:
``` html=4
{% if backpath %}
<a href="{{ backpath }}" class="btn btn-primary">返回前一頁</a><BR>
{% endif %}
```
由於上述的需求,需要修改 `default/views.py` 中用來處理「新增投票主題」、「修改投票主題」、「新增投票選項」以及「修改投票選項」的頁面視圖,以下僅顯示相對應的 `PollCreate` 、 `PollUpdate` 、 `OptionCreate` 與 `OptionUpdate` 等 4 個處理類別,主要是為這 4 個類別將 `title` 與 `backpath` 加入要傳給頁面範本的資料清單內。
```python=29
# 新增投票主題
class PollCreate(CreateView):
model = Poll
fields = ['subject'] # 指定要顯示的欄位
success_url = '/poll/' # 成功新增後要導向的路徑
extra_context = {'title': '新增投票主題', 'backpath': '/'}
# 修改投票主題
class PollUpdate(UpdateView):
model = Poll
fields = ['subject'] # 指定要顯示的欄位
success_url = '/poll/' # 成功新增後要導向的路徑
extra_context = {'title': '修改投票主題', 'backpath': '/'}
```
- 新增++第 34, 41 行++,如果要額外傳遞給頁面範本的資料都是固定的內容,可以透過 `extra_context` 這個屬性,以字典的方式指派要傳遞給頁面範本的額外資料,以此例來說,我們要額外傳遞 `title` 跟 `backpath` 這 2 項資料給頁面範本
``` python=48
## 新增投票選項
class OptionCreate(CreateView):
model = Option
fields = ['title']
template_name = 'default/poll_form.html'
# 成功新增選項後要導向其所屬的投票主題檢視頁面
def get_success_url(self):
return '/poll/'+str(self.kwargs['pid'])+'/'
# 表單驗證,在此填上選項所屬的投票主題 id
def form_valid(self, form):
form.instance.poll_id = self.kwargs['pid']
return super().form_valid(form)
# 新增 title 與 backpath,以便在 default/poll_form.html 中使用
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['title'] = '新增投票選項'
ctx['backpath'] = reverse('poll_view', kwargs={'pk': self.kwargs['pid']})
return ctx
## 修改投票選項
class OptionUpdate(UpdateView):
model = Option
fields = ['title']
template_name = 'default/poll_form.html'
# 修改成功後返回其所屬投票主題檢視頁面
def get_success_url(self):
return reverse('poll_view', args=[self.object.poll_id])
# 新增 title 與 backpath,以便在 default/poll_form.html 中使用
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['title'] = '修改投票選項'
ctx['backpath'] = reverse('poll_view', kwargs={'pk': self.object.poll_id})
return ctx
```
- 新增++第 63 - 68, 80 - 85行++,為 `OptionCreate` 以及 `OptionUpdate` 類別新增 `get_context_data()` 成員函式,將 `title` 與 `backpath` 傳遞給頁面範本。因為這兩個功能返回前一頁的路徑是會變動的,因此無法單純透過指定 `extra_context` 屬性來達到目的,必須以定義 `get_context_data()` 成員函式的方式,在函式內取得 `backpath` 的內容,再傳遞給頁面範本。
- ++第 65, 82 行++,先取得原本要傳給頁面範本的內容,將其暫存在 `ctx` 變數中,其型態為「字典」
- ++第 66, 67, 83, 84 行++,在 `ctx` 字典中,再加入 `title` 與 `backpath` 這兩組資訊
- ++第 68, 85 行++,回傳加工後的字典 `ctx` 當做要傳給頁面範本的資料


頁面範本剩下 `default/templates/default/poll_confirm_delete.html` 還沒處理,修改如下:
``` html=
{% extends "base.html" %}
{% block content %}
{% if backpath %}
<a href="{{ backpath }}" class="btn btn-primary">返回前一頁</a>
{% endif %}
<h1>{{ title }}</h1>
<form action="" method="post">
<p class="list-group-item">確定要刪除 {{ object }} 這筆紀錄嗎?</p>
{% csrf_token %}
<input type="submit" value="是的,我要刪除" class="btn btn-sm btn-danger">
</form>
{% endblock %}
```
- 新增++第 4-6 行++,若 views 有傳遞 `backpath` 變數的話,顯示「返回前一頁」按鈕,以方便使用者返回前一頁。
- 修改++第 7 行++,將「刪除紀錄」改為顯示由 views 傳來的 `title` 變數的內容。
因為上述的修改,需要 views 額外傳遞 `backpath` 以及 `title` 這 2 個參數,所以需要修改 `default/views.py` 裡的 `PollDelete` 以及 `OptionDelete` 這 2 個處理類別以傳遞 `title` 與 `backpath` 給頁面範本,以下僅顯示 `PollDelete` 及 `OptionDelete`:
``` python=43
# 刪除投票主題
class PollDelete(DeleteView):
model = Poll
success_url = '/poll/'
extra_context = {'title': '刪除投票主題', 'backpath': '/'}
```
- 新增++第 47 行++,加入 `extra_context` 屬性來指定要傳給頁面範本的額外資訊
``` python=88
## 刪除投票選項
class OptionDelete(DeleteView):
model = Option
template_name = 'default/poll_confirm_delete.html'
# 刪除成功後返回其所屬投票主題檢視頁面
def get_success_url(self):
return reverse('poll_view', kwargs={'pk': self.object.poll_id})
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['title'] = '刪除投票選項'
ctx['backpath'] = reverse('poll_view', kwargs={'pk': self.object.poll_id})
return ctx
```
- 新增++第 97 - 101 行++,新增 `get_context_data()` 成員函式來加入額外傳遞的內容。

