<style>
.markdown-body img:not(.emoji) {
border-radius: 5px;
box-shadow: 1px 1px 5px dimgrey;
}
</style>
案例:數位助理
===
## 前言
前面已經分別實作了數位助理專案的日誌以及記帳的功能,接著來進行版面美化的工作。在這個案例中,同樣直接透過 CDN 來引用 Bootstrap 框架以及 Font Awesome 來美化網站頁面。
## 修改網站基底範本
### 建立導覽列
新增 `templates/navbar.html`
``` html=
<!-- Navbar begin //-->
<nav class="navbar navbar-expand-sm bg-dark mb-3" data-bs-theme="dark">
<div class="container-fluid">
<!-- 網站標誌 -->
<div class="navbar-brand"><i class="far fa-address-card"></i> 數位助理</div>
<!-- 在小螢幕的設備上顯示可展開/收合導覽選單的按鈕 -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<!-- 導覽列選單內容(可收合) -->
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
{% if user.is_authenticated %}
<li class="nav-item"><a href="/" class="nav-link"><i class="fas fa-book"></i> 日記</a></li>
<li class="nav-item"><a href="/journal/create" class="nav-link"><i class="fas fa-edit"></i> 寫日誌</a></li>
<li class="nav-item"><a href="/expenses/" class="nav-link"><i class="fas fa-money-check"></i> 帳本</a></li>
<li class="nav-item"><a href="/expenses/create" class="nav-link"><i class="fas fa-money-check-alt"></i> 記帳</a></li>
<li class="nav-item">
<form action="{% url 'logout' %}" method="post">
{% csrf_token %}
<input type="submit" value="{{ user.username }} 登出" class="btn btn-sm btn-primary" />
</form>
</li>
{% else %}
<li class="nav-item"><a href="{% url 'login' %}" class="btn btn-sm btn-primary"><i class="fas fa-sign-in-alt"></i> 登入</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- Navbar end //-->
```
### 引用 Bootstrap、FontAwesome 以及導覽列
將網站基底頁面範本 `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">
<!-- FontAwesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<title>數位助理</title>
</head>
<body>
<div class="container">
{% include 'navbar.html' %}
<div>{% block content %}{% endblock %}</div>
</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>
```
- ++第 7 行++,引用 Bootstrap 框架
- ++第 9 行++,引用 FontAwesome 字型
- ++第 13, 16 行++,新增一組 `<div></div>` 標籤,套用 `container` 類別,用來放置頁面要呈現的內容
- ++第 14 行++,透過 Django 的 `{% include %}` 標符,將方才新增的導覽列加進頁面範本中
修改後,導覽列的顯示效果如下:

## 美化日誌功能
### 使用者登入
先前在表單所使用的頁面範本中,要產生每個欄位的輸入元件時,都是使用 `{{ form.as_p }}` 或 `{{ form.as_table }}` 來將頁面範本接收到的 `form` 變數中關於欄位的定義自動展開。這種方式的好處是方便,但是較難針對欄位的外觀做較細部的處理。
另一種方式則是自行撰寫所需欄位的 HTML 碼,只要輸入元件的 `name` 屬性與其所屬的資料模型或表單定義內的欄位名稱相同,就可以用來在頁面上接收使用者的輸入,在送出表單後,Django 也能正常處理。以下示範不透過 `form` 變數來自動產生輸入元件的 HTML 碼,而改採使用者自行撰寫的方式來修改使用者登入表單的頁面範本。
修改 `templates/registration/login.html`
``` html=
{% extends "base.html" %}
{% block content %}
{% if form.errors %}
<p class="alert alert-danger">帳號或密碼不符合,請再試一次。</p>
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<div class="h3 mb5">請輸入您的帳號密碼</div>
<div class="row row-cols-lg-auto align-items-center">
<div class="col-12">
<input name="username" autofocus="" required="" id="id_username" maxlength="254" type="text" class="form-control mr-2" placeholder="帳號">
</div>
<div class="col-12">
<input name="password" required="" id="id_password" type="password" class="form-control mr-2" placeholder="密碼">
</div>
<div class="col-12">
<input type="submit" value="login" class="btn btn-primary"/>
</div>
</div>
</form>
{% endblock %}
```
- ++第 4 - 6 行++,如果表單有錯誤的話,就顯示訊息。
- 當送出表單資料後,Django 在驗證使用者填寫的資料時發現有錯誤,就會返回原本的頁面,並在 `form` 變數的 `errors` 屬性填入相關錯誤訊息。
- 對登入表單來說,驗證失敗最主要的狀況就是帳號與密碼錯誤,所以可以直接將訊息寫死在頁面範本中。當然,也可以使用 `{{ form.errors }}` 將 Django 填入的錯誤訊息輸出,但是它的輸出樣式可能不符合需求。
- ++第 10, 20 行++,用來包覆欲行內輸入元件的容器,需套用 `row`、`row-cols-lg-auto` 類別讓其下的輸入元件可以接在同一行。
- ++第 11 - 19 行++,輸入元素的 HTML 原始碼
- 為了一致性的外觀,每個輸入元素的標籤(`<input>`)都套用 `form-control` 類別
- 帳號與密碼輸入標籤中的 `required` 屬性用來指示這兩個輸入欄位必須要輸入內容
- 帳號輸入標籤中的 `autofocus` 屬性是指頁面載入時,使用者輸入的焦點要自動移到這個元素上。使用者不需要事先以滑鼠點按這個輸入元件,按鍵盤登打的內容會直接填入這個入元件中。

### 使用者登出
修改 `templates/registration/logged_out.html`
``` html=
{% extends "base.html" %}
{% block content %}
<p class="alert alert-info">您已登出!!</p>
<a href="{% url 'login'%}" class="btn btn-primary">請按此處重新<i class="fas fa-sign-in-alt"></i> 登入</a>
{% endblock %}
```
- ++第 4 行++,修改訊息外觀。Bootstrap 框架的 `alert` 類別是套用在包覆警告、通知訊息用的容器上,需額外搭配相對應的類別,以產生不同的前背景配色組合:
|搭配類別|外觀示例|
|-|-|
|`alert-primary`||
|`alert-secondary`||
|`alert-success`||
|`alert-danger`||
|`alert-warning`||
|`alert-info`||
|`alert-light`||
|`alert-dark`||
- ++第 5 行++,將連結外觀變更為按鈕樣式(套用 `btn btn-primary` 類別),並加入圖示

### 日誌列表
修改 `templates/journal/journal_list.html` :
``` html=
{% extends "base.html" %}
{% block content %}
<h2><i class="fas fa-book"></i> 我的日誌:</h2>
<table class="table table-sm">
<tr><td>時間</td><td>項目</td><td>功能</td></tr>
{% for journal in journal_list %}
<tr>
<td>({{ journal.created|date:"l" }}){{ journal.created }}</td>
<td><a href="{% url 'journal_edit' journal.id %}">{{journal.content}}</a></td>
<td><a href="{% url 'journal_delete' journal.id %}" class="btn btn-sm btn-danger py-0 px-1"><i class="fas fa-trash-alt"></i> 刪除</a></td>
</tr>
{% endfor %}
</table>
{% include 'pagination.html' %}
{% endblock %}
```
- ++第 4 行++,加上圖示
- ++第 5 行++,表格套用 `table table-sm` 類別
- ++第 11 行++,修改「刪除」連結外觀
- 套用 `btn btn-sm btn-danger py-0 px-1` 類別
`py-0` 表示不留上下內邊界(padding),`px-1` 表示使用第 1 級左右內邊界
- 加上垃圾桶圖示

#### 修正時間格式
日誌列表的時間格式目前顯示的語言是英文,若要修正為中文的話,需要修改專案設定值。
修改 `assistant/settings.py` ,將 `LANGUAGE_CODE` 以及 `TIME_ZONE` 的值修正如下:
``` python=105
# Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/
LANGUAGE_CODE = 'zh-hant'
TIME_ZONE = 'Asia/Taipei'
```
* `LANGUAGE_CODE` 設為 `zh-hant` 表示要使用正體中文來顯示時間字串
* `TIME_ZONE` 設為 `Asia/Taipei` 表示要使用臺北所在的時區來處理時間欄位

#### 美化分頁連結
修改 `templates/pagination.html`
``` html=
{% if is_paginated %}
<div>
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}" class="btn btn-sm btn-primary"><i class="fas fa-chevron-circle-left"></i>上一頁</a>
{% endif %}
{% for page in paginator.page_range %}
{% if page == page_obj.number %}
<button class="btn btn-sm btn-primary" disabled>{{ page }}</button>
{% else %}
<a href="?page={{ page }}" class="btn btn-sm btn-primary">{{ page }}</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}" class="btn btn-sm btn-primary">下一頁<i class="fas fa-chevron-circle-right"></i></a>
{% endif %}
</div>
{% endif %}
```
修改項目主要有 2 個:
- 為「上一頁」與「下一頁」加上適合的圖示
- 為所有的連結套用 `btn btn-sm btn-primary` 類別
<kbd></kbd>
### 我的帳本
修改 `templates/expenses/expense_list.html`
``` html=
{% extends "base.html" %}
{% block content %}
<h2><i class="fas fa-money-check"></i> 我的帳本:</h2>
<table class="table table-sm">
<tr>
<th>時間</th>
<th>項目</th>
<th>類別</th>
<th>金額</th>
<th>功能</th>
</tr>
{% for expense in expense_list %}
<tr>
<td>({{ expense.time|date:"l" }}) {{ expense.time }}</td>
<td><a href="{{ expense.id }}/update/">{{ expense.item }}</a></td>
<td>{{ expense.get_category_display }}</td>
<td>{{ expense.amount }}</td>
<td><a href="{{ expense.id }}/delete/" class="btn btn-sm btn-danger py-0 px-1"><i class="fas fa-trash-alt"></i> 刪除</a></td>
</tr>
{% endfor %}
</table>
{% include 'pagination.html' %}
{% endblock content %}
```
主要修改以下項目:
- ++第 5 行++,表格套用 `table` 以及 `table-sm` 類別
- ++第 19 行++,修改「刪除」連結外觀
- 套用 `btn btn-sm btn-danger py-0 px-1` 類別
`py-0` 表示不留上下內邊界(padding),`px-1` 表示使用第 1 級左右內邊界
- 加上垃圾桶圖示

### 寫日誌與記帳
#### 修改頁面範本
修改 `templates/form.html`
``` html=
{% extends "base.html" %}
{% block content %}
<form action="" method="post">
{% csrf_token %}
<table class="table table-sm">
{{ form.as_table }}
</table>
<input type="submit" value="送出" class="btn btn-sm btn-primary"/>
</form>
{% endblock %}
```
<kbd></kbd>
#### 美化表單
頁面範本 `templates/form.html` 中採用了 `{{ form.as_table }}` 的方式來產生輸入元件的 HTML 碼,所以才能讓新增、修改日誌以及記帳等功能共用同一個頁面範本。但是這樣一來,也無法直接在頁面範本中指定 `<input>` 標籤來套用 `form-control` 類別來修改它們的外觀。
如果想要在頁面範本中保持採用 `{{ form.as_table }}` 讓多項輸要輸入資料的功能可以共用同一個頁面範本的話,該如何針對自動產生的輸入元件的標籤做相關的調整呢?官方的標準做法是自行撰寫表單類別,在類別中指定每個輸入欄位的相關設定。示範如下:
新增 `journal/forms.py`,在其中自行定義表單類別:
``` python=
from django.forms import ModelForm, Textarea
from .models import Journal
class JournalForm(ModelForm): # 自訂表單定義
class Meta: # 表單元類別
model = Journal # 指定參考的資料模型
fields = ['content'] # 在表單中要顯示哪些輸入欄位
widgets = {
'content': Textarea(attrs={'class': 'form-control'}),
}
```
- ++第 4 - 10 行,自訂一個表單類別 `JournalForm` 繼承自 `ModelForm` 類別
`django.forms` 套件中定義了一個 `ModelForm` 的類別,可以由指定的資料模型(`Model`)定義產生相對應的表單定義
- ++第 5 - 10 行++,定義 `JournalForm` 類別的元類別(`class Meta`),注意,`Meta` 不可以改成其他名稱
- ++第 6 行++,`model` 屬性用來指定參考的資料模型
- ++第 7 行++,`fields` 屬性用來指定在表單中要包含參考的資料模型中的哪些欄位
- ++第 8 - 10 行++,`widgets` 屬性用來指定輸入欄位要使用的小工具的定義,以字典的方式來指定欄位與其使用的小工具
- ++第 9 行++,`content` 欄位以多行文字輸入元件 `Textarea` 的型式(也就是 HTML 中的 `<textarea></textarea>` 標籤)呈現在頁面上。
要修改輸入元件產生的 HTML 標籤的屬性,可以透過關鍵字參數 `attrs` 來指定,這個參數接受字典形式的內容,鍵值的部份欲修改的屬性名稱,對應值的部份則是該屬性欲指定的設定值。以此例來說,我們想要讓 `<textarea></textarea>` 套用 `form-control` 類別,若是自行撰寫 HTML 碼的話,就需要在 `<textarea>` 標籤中指定 `class` 屬性為 `form-control`,例:
``` html
<textarea class="form-control">
</textarea>
```
在自訂表單定義類別中,就要以 `{'class': 'form-control'}` 的方式來指定。若想修改多個屬性,則在 `attrs` 字典中加入其它鍵值與對應值的配對即可,例:
``` python
attrs={'class': 'form-control', 'autofocus': true}
```
自訂了表單定義後,接著修改通用視圖,指定使用自訂的表單定義。
請修改 `journal/views.py`,指定 `JournalCreate` 、 `JournalUpdate` 等 2 個 class 的 `form_class` 屬性:
``` python=4
from .models import Journal
from django.contrib.auth.mixins import LoginRequiredMixin
from .forms import JournalForm # 引用自訂表單定義
```
``` python=15
## 新增日誌
class JournalCreate(LoginRequiredMixin, CreateView):
form_class = JournalForm
success_url = reverse_lazy('journal_list') # 操作成功後重新導向日誌列表頁面
template_name = 'form.html'
## 修改日誌
class JournalEdit(LoginRequiredMixin, UpdateView):
model = Journal
form_class = JournalForm
success_url = reverse_lazy('journal_list') # 操作成功後重新導向日誌列表頁面
template_name = 'form.html'
```
修改處如下:
- 新增++第 6 行++,引用方才定義的自訂表單 `JournalForm`
- 新增++第 17, 24 行++,透過 `form_class` 屬性指定使用自訂表單定義 `JournalForm`
- 刪除 `JournalCreate` 以及 `JournalUpdate` 下的 `fields` 屬性設定
- 由於 `fields` 屬性在自訂表單 `JournalForm` 中已經指定過了,不需要重覆指定,可以移除。
- 刪除 `JournalCreate` 類別下的 `model` 屬性設定
- 理由同上
- `JournalEdit` 需要先載入欲修改的紀錄,再放到表單上,所以 `JournalEdit` 裡的 `model` 屬性設定不可以移除
同理,要修改記帳功能表單的外觀,也要修改 `expenses` 應用程式的 `forms.py` 以及 `views.py`。新增自訂表單定義 `expenses/forms.py`:
``` python=1
from django.forms import ModelForm, TextInput, Select, NumberInput
from .models import Expense
class ExpenseForm(ModelForm):
class Meta:
model = Expense # 參考資料模型 Expense
fields = '__all__' # 顯示*所有*欄位
widgets = {
'item': TextInput(attrs={'class': 'form-control'}),
'category': Select(attrs={'class': 'form-control'}),
'amount': NumberInput(attrs={'class': 'form-control'}),
}
```
|小工具(Widget)|說明|
|-|-|
|`TextInput`|單行文字框(`<input type='text'>`)|
|`Select`|下拉選單(`<select></select>`)|
|`NumberInput`|單行數字框(`<input type='number'>`)|
修改 `expenses/view.py`,指定 `ExpenseCreate` 與 `ExpenseUpdate` 這兩個通用視圖使用方才自定義的 `ExpenseForm`:
``` python=3
from django.urls import reverse_lazy
from .models import Expense
from .forms import ExpenseForm
```
``` python=14
# 新增支出紀錄
class ExpenseCreate(LoginRequiredMixin, CreateView):
form_class = ExpenseForm
template_name = 'form.html' # 指定使用 form.html 這個頁面範本
success_url = reverse_lazy('expense_list') # 新增成功返回支出紀錄列表頁面
# 修改支出紀錄
class ExpenseUpdate(LoginRequiredMixin, UpdateView):
model = Expense
form_class = ExpenseForm
template_name = 'form.html'
success_url = reverse_lazy('expense_list') # 新增成功返回支出紀錄列表頁面
```
修改方式請參考前一段關於 `JournalCreate` 與 `JournalEdit` 的修改說明。
