第十一章 博客文章
本章將實現Flasky博客的核心功能,允許用戶讀取和撰寫文章。你將學到一些關于模板重用、長項目列表的分頁和富文本等新知識。
提交和顯示文章
我們需要先準備好一個新的數據庫模型來支持文章發布。這個模型設計如例子11-1所示:
Example 11-1. app/models.py: Post model
class Post(db.Model):
__tablename__ = 'posts'
id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.Text)
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
class User(UserMixin, db.Model):
# ...
posts = db.relationship('Post', backref='author', lazy='dynamic')
文章由body,timestamp和一用戶對多文章的關系組成。body字段類型為db.Text——沒有長度限制。
撰寫文章的表單將被顯示在程序的主頁面上。這個表單也很簡單,僅包含了一個輸入文字的文本塊和一個提交按鈕。表單的定義如例子11-2所示:
Example 11-2. app/main/forms.py: Blog post form
class PostForm(Form):
body = TextAreaField("What's on your mind?", validators=[Required()])
submit = SubmitField('Submit')
視圖函數index()處理這個表單請求并將舊的文章列表傳遞給模板,如例子11-3:
Example 11-3. app/main/views.py: Home page route with a blog post
@main.route('/', methods=['GET', 'POST'])
def index():
form = PostForm()
if current_user.can(Permission.WRITE_ARTICLES) and form.validate_on_submit():
post = Post(body=form.body.data, author=current_user._get_current_object())
db.session.add(post)
return redirect(url_for('.index'))
posts = Post.query.order_by(Post.timestamp.desc()).all()
return render_template('index.html', form=form, posts=posts)
視圖函數把表單和完整的文章列表(list格式)傳遞給模板。文章列表根據文章的發表時間倒序排列。表單使用傳統方式進行處理:接收到提交請求就會創建一個新的Post實例。在允許創建新post前會檢查當前用戶的撰寫文章的權限。
注意,新的文章對象的author屬性被設置為current_user._get_current_object()。來自于flask-login的current_user變量就像所有的上下文變量一樣,作為一個線程本地代理對象存在。這個對象表現地像一個用戶對象,實際上也真的是一個內部包含了一個真實用戶對象的包裝器。數據庫需要一個真實用戶對象,通過調用_get_current_object()來取得。(譯注:這一段不理解
)
表單在index.html模板的歡迎信息下方顯示,接下來是文章列表。文章列表把數據庫里所有的文章按照從新到舊的順序依次排列,創建一個文章時間線。這部分模板變化請看例子11-4:
Example 11-4. app/templates/index.html: Home page template with blog posts
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
...
<div>
{% if current_user.can(Permission.WRITE_ARTICLES) %}
{{ wtf.quick_form(form) }}
{% endif %}
</div>
<ul class="posts">
{% for post in posts %}
<li class="post">
<div class="profile-thumbnail">
<a href="{{ url_for('.user', username=post.author.username) }}">
<img class="img-rounded profile-thumbnail" src="{{ post.author.gravatar(size=40) }}">
</a>
</div>
<div class="post-date">{{ moment(post.timestamp).fromNow() }}</div>
<div class="post-author">
<a href="{{ url_for('.user', username=post.author.username) }}">
{{ post.author.username }}
</a>
</div>
<div class="post-body">{{ post.body }}</div>
</li>
{% endfor %}
</ul>
...
注意,我們調用User.can()方法用來對不具備WRITE_ARTICLES權限的用戶隱藏表單。文章列表使用了css樣式表美化,顯示為HTML無序列表格式。作者的小頭像顯示在左側,頭像及作者名字都是連接到用戶檔案頁面的鏈接。我們使用的CSS樣式表保存在程序static文件夾下。你可以在GIthub倉庫中找到它。
譯注:css文件也需要做相應修改,可自行斟酌。
圖11-1是瀏覽器中顯示的帶有表單和文章列表的首頁。
檔案頁面的文章
我們來改進一下用戶檔案頁面,讓它顯示該用戶的所有文章列表。例子11-5顯示了對該視圖函數的更改來獲取文章列表:
Example 11-5. app/main/views.py: Profile page route with blog posts
@main.route('/user/<username>')
def user(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
posts = user.posts.order_by(Post.timestamp.desc()).all()
return render_template('user.html', user=user, posts=posts)
某用戶的文章列表可以依賴User.posts關系來獲取,這一關系是一個查詢對象,我們可以使用類似于order_by()的過濾器器來進行篩選。
user.html模板也需要<ul>
HTML列表結構來顯示文章列表,類似于index.html中那樣——所以,一個HTML列表片段存在兩個副本并不是一個好主意,So,Jinja2的include()指令就正好派上用場了。User.html模板可以從一個外部文件當中include這個列表,如例子11-6所示。
Example 11-6. app/templates/user.html: Profile page template with blog posts
...
<h3>Posts by {{ user.username }}</h3>
{% include '_posts.html' %}
...
為了完成這一重構,<ul>
樹形結構被從index.html中挪到了新的模板_post.html中,代之以include()指令。注意,_post.html模板命名中的前綴下劃線的使用并不是必須的,這樣只是一個區分獨立模板和局部模板的默認約定。
譯注:在template文件夾下,新建_posts.html。輸入如下代碼保存:
<ul class="posts">
{% for post in posts %}
<li class="post">
<div class="profile-thumbnail">
<a href="{{ url_for('.user', username=post.author.username) }}">
<img class="img-rounded profile-thumbnail" src="{{ post.author.gravatar(size=40) }}">
</a>
</div>
<div class="post-date">{{ moment(post.timestamp).fromNow() }}</div>
<div class="post-author">
<a href="{{ url_for('.user', username=post.author.username) }}">
{{ post.author.username }}
</a>
</div>
<div class="post-body">{{ post.body }}</div>
</li>
{% endfor %}
</ul>
準備長文章列表
隨著站點和文章數量的不斷增長,程序響應速度會變慢,要想在檔案頁面和首頁顯示所有的文章列表就顯得不切實際了。大頁面需要花費更多的時間生成、下載然后在瀏覽器中渲染顯示,所以隨著頁面變大用戶體驗質量就會下降。解決辦法就是對數據分頁(paginate)并按塊(chunks)顯示。
創建模擬文章數據
為了顯示多頁文章,我們需要先有一個大量數據的測試數據庫。手工添加新數據即耗時又乏味,我們需要一個自動方案。有好幾個Python包可以用來生成模擬數據信息,比較完整的一個就是ForgeryPy,可以用pip安裝:
(venv) $ pip install forgerypy
嚴格說起來,ForgeryPy并不是一個程序依賴包,因為它只是在開發過程中才用到。為了將開發依賴和生產依賴區分開,我們把requirements.txt替換為requirements文件夾,分別存儲不同環境下的依賴。這個文件夾里用dev.txt和prod.txt分別存儲開發環境依賴和生產環境依賴。由于二者會有大部分依賴都一樣,所以把共同的部分獨立成common.txt,而dev.txt和prod.txt分別使用-r 前綴包含common.txt,例子11-7展示了dev.txt:
Example 11-7. requirements/dev.txt: Development requirements file
-r common.txt
ForgeryPy==0.1
例子11-8,通過分別給user和post添加類方法后,user和Post模型就可以生成模擬數據了:
Example 11-8. app/models.py: Generate fake users and blog posts
class User(UserMixin, db.Model):
# ...
@staticmethod
def generate_fake(count=100):
from sqlalchemy.exc import IntegrityError
from random import seed
import forgery_py
seed()
for i in range(count):
u = User(email=forgery_py.internet.email_address(),username=forgery_py.internet.user_name(True),password=forgery_py.lorem_ipsum.word(),confirmed=True,name=forgery_py.name.full_name(),
location=forgery_py.address.city(),about_me=forgery_py.lorem_ipsum.sentence(),member_since=forgery_py.date.date(True))
db.session.add(u)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
class Post(db.Model):
# ...
@staticmethod
def generate_fake(count=100):
from random import seed, randint
import forgery_py
seed()
user_count = User.query.count()
for i in range(count):
u = User.query.offset(randint(0, user_count - 1)).first()
p = Post(body=forgery_py.lorem_ipsum.sentences(randint(1, 3)),timestamp=forgery_py.date.date(True),author=u)
db.session.add(p)
db.session.commit()
我們使用ForgeryPy的隨機信息生成器生成了很多模擬對象屬性,這一生成器可以生成逼真的names,email,setences和更多屬性。
email地址和用戶名必須唯一,但由于ForgeryPy完全使用隨機生成,所以有一定的幾率會出現重復。一旦出現這種意外,數據庫會話提交時就會拋出一個IntegrityError例外錯誤。這個錯誤將導致會話回滾,因此這一產生重復的操作就不會將用戶寫入數據庫,所以模擬用戶數就可能會比我們指定的數量要少。
隨機生成文章時需要為每篇文章指定一個隨機的用戶。此處使用了offset()查詢過濾器。這個過濾器只保留指定數量的結果,通過設置一個隨機偏移值然后再調用first(),我們就可以每次取得一個隨機用戶。這個新方法使得我們從shell中生成大量的模擬用戶和文章變得非常輕松:
(venv) $ python manage.py shell
>>> User.generate_fake(100)
>>> Post.generate_fake(100)
譯注:要運行shell命令,你需要補充相關引用導入——只要shell中報錯未定義xxx,你就需要檢查了。這里,在manage.py中修改如下:
from app.models import User,Role,Permission,Post
#...
def make_shell_context():
return dict(app=app,db=db,User=User,Role=Role,Permission=Permission,Post=Post)
在頁面中顯示數據
例子11-9展示了首頁路由為了顯示分頁數據所做的更改:
Example 11-9. app/main/views.py: Paginate the blog post list
@main.route('/', methods=['GET', 'POST'])
def index():
# ...
page = request.args.get('page', 1, type=int)
pagination = Post.query.order_by(Post.timestamp.desc()).paginate(page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],error_out=False)
posts = pagination.items
return render_template('index.html', form=form, posts=posts,pagination=pagination)
顯示的頁碼來自于請求查詢字符串,可以從request.args中獲取。如果沒有有效的頁碼,就會使用默認值1(第一頁)。type=int
參數確保了一旦參數不能轉換成整數,則返回一個默認值。
為了載入一頁記錄,應該用Flask-SQLAlchemy的paginate()替換all()方法。paginate()方法把當前頁碼作為第一個且必要的一個參數。可選參數per_page用來確定每頁的記錄數,如果沒有指定,則默認每頁顯示20條。另外一個可選參數是error_out默認設置為True,它用來觸發404錯誤,如果請求的頁碼超過有效范圍就會觸發該錯誤。如果error_out被設置為False,超出范圍的頁碼請求將返回一個空列表。為了配置每頁顯示數,我們在程序配置中單獨指定了FLASK_POSTS_PER_PAGE,可以從這里讀取它。
經過這些更改,首頁上的文章列表將以一個指定數量顯示。如果希望看第二頁,可以在瀏覽器地址欄URL中添加?page=2
回車。
添加一個Pagination插件
paginate()的返回值是一個Paginatin類的對象。這個對象包含了一些很有用的特性,可以用來在模板中生成鏈接,所以它應該以參數的形式傳遞給模板。pagination對象的特性列表如表11-1所示:
屬性 說明
items 當前頁面上的記錄
query 用來分頁的源查詢
page 當前頁碼
prev_num 上一頁頁碼
next_num 下一頁頁碼
has_next 如果有下一頁則為True
has_prev 如果有上一頁則為True
pages 查詢得到的所有頁數
per_page 每頁記錄數
total 查詢結果總數
pagination()的方法列表如下,表11-2:
方法 說明
iter_pages(left_edge=2, 一個迭代器,用來返回分頁插件中要顯示的頁碼序列
left_current=2, 這個列表中的參數分別如下:left_edge,左側2頁碼數,left_current 當前頁碼向左偏移兩頁,
right_current=5, right_current 當前頁碼向右偏移五頁right_edge 右側邊界2頁的頁碼
right_edge=2) 例如:對于第50頁(當前頁),共100頁,這個迭代器按照左側的默認配置將返回
如下頁: 1, 2,None , 48, 49, 50, 51, 52, 53, 54, 55, None ,
99, 100。 None值界定了頁碼序列的前后缺口(在模板中就顯示為...)。
prev() 上一頁的分頁對象.
next() 下一頁的分頁對象
武裝上這個強大對象和bootstrap樣式表類,就可以輕松創建一個分頁導航。例子11-10顯示了可復用的Jinja2宏格式的實現:
Example 11-10. app/templates/_macros.html: Pagination template macro
{% macro pagination_widget(pagination, endpoint) %}
<ul class="pagination">
<li{% if not pagination.has_prev %} class="disabled"{% endif %}>
<a href="{% if pagination.has_prev %}{{ url_for(endpoint,page = pagination.page - 1, **kwargs) }}{% else %}#{% endif %}">
?
</a>
</li>
{% for p in pagination.iter_pages() %}
{% if p %}
{% if p == pagination.page %}
<li class="active">
<a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
</li>
{% else %}
<li>
<a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
</li>
{% endif %}
{% else %}
<li class="disabled"><a href="#">…</a></li>
{% endif %}
{% endfor %}
<li{% if not pagination.has_next %} class="disabled"{% endif %}>
<a href="{% if pagination.has_next %}{{ url_for(endpoint,page = pagination.page + 1, **kwargs) }}{% else %}#{% endif %}">
?
</a>
</li>
</ul>
{% endmacro %}
這個宏創建了一個Bootstrap無序列表風格的分頁元素。其內部的定義鏈接如下:
- "Previous Page"上一頁,如果當前頁是第一頁的話這個鏈接就會被設置為CSS 的disable 類(
譯注:呈現禁用狀態
)。 - 鏈接到分頁對象的iter_pages()迭代器返回來的所有頁面。這些頁面作為明確頁碼名稱的鏈接,通過傳遞給url_for()明確的頁碼生成。當前頁使用activecss類來高亮顯示,而頁碼序列的"缺口"(Gaps in the sequence of pages)則用省略號顯示。
- "Next page"下一頁鏈接,如果當前是最后一頁則這個鏈接不可用(disable css類)
Jinja2宏總是接收鍵值參數,不帶有必須包含**kwargs
的參數列表(?譯注:不太理解這一句:Jinja2 macros always receive keyword arguments without having to include **kwargs in the argument list
)。Pagination宏把所有接收到的鍵值參數都傳遞給url_for()來生成分頁導航鏈接。這個方法可以被用在路由上,如檔案頁面的動態部分。
pagination_widget宏可以被添加在index.html和user.html中,位于被引用的_post.html模板下方。例子11-11展示了首頁中它的用法:
Example 11-11. app/templates/index.html: Pagination footer for blog post lists
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% import "_macros.html" as macros %}
...
{% include '_posts.html' %}
<div class="pagination">
{{ macros.pagination_widget(pagination, '.index') }}
</div>
{% endif %}
圖11-2就是分頁鏈接在頁面中的樣子:
使用Markdown和Flask-PageDown的富文本文章
對于短信息和狀態更新,純文本就足夠了,但對于希望撰寫長篇文字的用戶來說就缺乏格式支持非常不便。本節中,我們將升級文本域,來支持Markdown語法,并且提供文章的富文本預覽。
為了實現這一要求,我們需要幾個新的包:
- PageDown,客戶端Markdown-HTML轉換的js實現
- Flask-PageDown,PageDown與Flask-WTF集成后的封裝
- Markdown ,服務器端的Markdown-HTML轉換的Python實現
- Bleach, HTML清除器的Python實現
安裝命令(同時安裝多個擴展
)如下:
(venv) $ pip install flask-pagedown markdown bleach
使用Flask-pagedown
Flask-pagedown擴展定義了一個pagedownField字段類,其外觀與WTForms的TextAreField一樣。在使用這個字段之前,我們需要先初始化這個擴展,如例子11-12:
Example 11-12. app/__init__.py: Flask-PageDown initialization
from flask.ext.pagedown import PageDown
# ...
pagedown = PageDown()
# ...
def create_app(config_name):
# ...
pagedown.init_app(app)
# ...
為了把首頁中的文本域(textarea)轉換成Markdown 富文本編輯器,我們需要先把PostForm中的body字段轉成pagedownField,如例子11-13:
Example 11-13. app/main/forms.py: Markdown-enabled post form
from flask.ext.pagedown.fields import PageDownField
class PostForm(Form):
body = PageDownField("What's on your mind?", validators=[Required()])
submit = SubmitField('Submit')
在pageDown庫的幫助下生成Markdown預覽,這也需要添加到模板中。Flask-pagedown簡化了這一任務,它通過CDN提供了一個包含必要文件的宏模板,如例子11-14所示:
Example 11-14. app/index.html: Flask-PageDown template declaration
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}
現在,我們實現了在文本域中錄入Markdown格式的文字,立即就可以在預覽區看到HTML格式的顯示。圖11-3顯示了帶有富文本的博客提交表單:
在服務器端處理富文本
客戶端提交表單時,將通過POST請求發送Markdown的源文本——頁面上顯示的HTML預覽是被忽略的。通過表單傳遞HTML預覽具有很大的安全風險,因為通過構造不符合markdown源文本的HTML代碼片段,攻擊者能很容易地向服務器非法提交內容。為了防止風險,我們只提交markdown源文本,在服務器端使用Markdown包(python編寫的markdown-html轉換器)轉成html,再使用Bleach清理這一html結構,確保其中只使用了我們允許的html標記。
Markdown轉HTML這一動作也可以在_post.html模板中完成,但非常沒有效率,因為每次顯示頁面時都需要對文章進行轉換。為了解決這一問題,我們只在創建文章時執行一次轉換——把文章的HTML代碼格式緩存在一個Post模型的新字段里,這樣模板可以直接訪問。Markdown原格式文章被保存在數據庫里,以備修改之用。例子11-15顯示了models.py中Post模型的變化:
Example 11-15. app/models.py: Markdown text handling in the Post model#原文誤寫models/post.py
from markdown import markdown
import bleach
class Post(db.Model):
# ...
body_html = db.Column(db.Text)
# ...
@staticmethod
def on_changed_body(target, value, oldvalue, initiator):
allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code','em', 'i', 'li', 'ol', 'pre', 'strong', 'ul','h1', 'h2', 'h3', 'p']
target.body_html = bleach.linkify(bleach.clean(markdown(value, output_format='html'),tags=allowed_tags, strip=True))
db.event.listen(Post.body, 'set', Post.on_changed_body)
on_change_body函數被注冊為監聽SQLAlchemy對body字段的"set"事件,也就是說,一旦這個類任意實例中的body字段被設置為新值,這個函數都會被自動調用。這個函數渲染html格式的body并把它存儲在body_html中,有效的自動完成從markdown到html的格式轉換。
實際上轉換有三個步驟。首先,markdown()函數完成初步轉換。然后,結果傳遞給clean(),只允許指定的html標記。clean()函數將清除所有不在白名單中的標記。最終轉換由Bleach提供的linkify()函數完成,它會把純文本格式的URL轉換成正確的<a>
連接。最后一步是必要的,因為markdown本身沒有提供自動連接轉換。PageDown作為一個擴展進行了支持,所以linkify()必須在服務器端進行匹配。
最后一個改變是在模板中用post.body_html替換了post.body,如例子11-16所示:
Example 11-16. app/templates/_posts.html: Use the HTML version of the post bodies in the template
...
<div class="post-body">
{% if post.body_html %}
{{ post.body_html | safe }}
{% else %}
{{ post.body }}
{% endif %}
</div>
...
|safe
后綴用來在渲染顯示html內容是告訴Jinja2不要對這一html元素進行轉義——Jinja2默認會對所有模板變量進行轉義以確保安全。由于markdown轉html是在服務器端完成的,所以可以安全的顯示。
譯注:效果上一條是markdown格式,下一條未寫任何格式(markdown將僅添加一個
<p>
標志)。
Paste_Image.png
博客文章的永久性連接
用戶可能希望在社交網絡上和朋友分享某篇文章,因此,我們需要為每一篇文章分配一個可以引用的唯一URL。例子11-17展示了支持永久鏈接的路由和視圖函數代碼:
Example 11-17. app/main/views.py: Permanent links to posts
@main.route('/post/<int:id>')
def post(id):
post = Post.query.get_or_404(id)
return render_template('post.html', posts=[post])
這個URL是由文章的主鍵id(向數據庫插入文章時自動生成,唯一)構成的。
提醒:對于某些類型的程序,使用一個可讀的URL而不是數字id來創建永久鏈接會更好一些。作為數字id方案的另外方案,(可讀的URL)實際上就是給每一篇文章分配一個slug(塊?)——指向這篇文章的唯一字符串。
注意,post.html模板接收并用來顯示的是一個列表(list),它實際上只包含指定的這篇文章。這是因為,我們在post.html中引用的_post.html模板要用到的變量是一個文章列表(如同index.html和user.html中一樣,它們都是傳遞了文章列表給_post.html)。
我們把永久鏈接添加在_post.html通用模板中每一篇文章的底部,如例子11-18所示:
Example 11-18. app/templates/_posts.html: Permanent links to posts
<ul class="posts">
{% for post in posts %}
<li class="post">
...
<div class="post-content">
...
<div class="post-footer">
<a href="{{ url_for('.post', id=post.id) }}">
<span class="label label-default">Permalink</span>
</a>
</div>
</div>
</li>
{% endfor %}
</ul>
新的帶有永久鏈接的post.html模板如例子 11-19所示,它包含了上例模板。
Example 11-19. app/templates/post.html: Permanent link template
{% extends "base.html" %}
{% block title %}Flasky - Post{% endblock %}
{% block page_content %}
{% include '_posts.html' %}
{% endblock %}
文章編輯器
關于博客文章的最后一個功能就是創建文章編輯器,允許用戶修改自己文章。這個編輯器位于一個獨立頁面,在頁面頂部,顯示當前文章的版本,接下來是markdown編輯器——用來修改markdown源文本。這個編輯器基于Flask-PageDown,所以頁面底部將會顯示一個文章預覽。edit_post.html模板如例子11-20所示。
Example 11-20. app/templates/edit_post.html: Edit blog post template
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Edit Post{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Edit Post</h1>
</div>
<div>
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}
相關的路由實現如例子11-21所示,
Example 11-21. app/main/views.py: Edit blog post route
@main.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit(id):
post = Post.query.get_or_404(id)
if current_user != post.author and not current_user.can(Permission.ADMINISTER):
abort(403)
form = PostForm()
if form.validate_on_submit():
post.body = form.body.data
db.session.add(post)
flash('The post has been updated.')
return redirect(url_for('.post', id=post.id))
form.body.data = post.body
return render_template('edit_post.html', form=form)
視圖函數只允許作者修改自己的文章,管理員除外——他可以修改所有人的文章。如果用戶試圖修改別人的文章,視圖函數將返回一個403錯誤代碼的響應。這里使用的PostForm表單類跟首頁中引用的那個是一樣的。
最后,我們給每篇文章底下在永久鏈接后面再添加一個編輯器鏈接,這個功能就完工了。請看例子11-22:
Example 11-22. app/templates/_posts.html: Edit blog post links
<ul class="posts">
{% for post in posts %}
<li class="post">
...
<div class="post-content">
...
<div class="post-footer">
...
{% if current_user == post.author %}
<a href="{{ url_for('.edit', id=post.id) }}">
<span class="label label-primary">Edit</span>
</a>
{% elif current_user.is_administrator() %}
<a href="{{ url_for('.edit', id=post.id) }}">
<span class="label label-danger">Edit [Admin]</span>
</a>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
這里增加了一個到當前用戶所編寫的任意文章的"Edit"鏈接。對于管理員,這個鏈接在所有文章上都會顯示,并且具有特殊的風格,從視覺上提醒這是一個管理員功能。圖11-4展示了edit和永久鏈接在瀏覽器里的樣子:
Over,下章見。
<<第十章 用戶資料 第十二章 關注者>>