Flask Web DEvelopment翻译10

第十一章 博客文章

本章将实现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是浏览器中显示的带有表单和文章列表的首页。

Paste_Image.png

档案页面的文章

我们来改进一下用户档案页面,让它显示该用户的所有文章列表。例子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就是分页链接在页面中的样子:

Paste_Image.png

使用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显示了带有富文本的博客提交表单:

Paste_Image.png
在服务器端处理富文本

客户端提交表单时,将通过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和永久链接在浏览器里的样子:

Paste_Image.png

Over,下章见。
<<第十章 用户资料 第十二章 关注者>>

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容