使用Flask构建一个web站点

发表于 2年以前  | 总阅读数:283 次

flask—website,是flask曾经的主站源码,使用flask制作,包含模版渲染,数据库操作,openID认证, 全文检索等功能。对于学习如何使用flask制作一个完备的web站点,很有参考价值,我们一起来学习它。

项目结构

flask-website已经归档封存,我们使用最后的版本8b08,包括如下几个模块:

模块 描述
run.py 启动脚本
websiteconfig.py 设置脚本
update-doc-searchindex.py 更新索引脚本
database.py 数据库模块
docs.py 索引文档模块
openid_auth.py oauth认证
search.py 搜素模块
utils.py 工具类
listings 一些展示栏
views 蓝图模块,包括社区,扩展,邮件列表,代码片段等
static 网站的静态资源
templates 网站的模版资源

flask-website的项目结构,可以作为flask的脚手架,按照这个目录规划构建自己的站点:

.
├── LICENSE
├── Makefile
├── README
├── flask_website
│   ├── __init__.py
│   ├── database.py
│   ├── docs.py
│   ├── flaskystyle.py
│   ├── listings
│   ├── openid_auth.py
│   ├── search.py
│   ├── static
│   ├── templates
│   ├── utils.py
│   └── views
├── requirements.txt
├── run.py
├── update-doc-searchindex.py
└── websiteconfig.py
  • run.py作为项目的启动入口
  • requirements.txt描述项目的依赖包
  • flask_website是项目的主模块,里面包括:存放静态资源的static目录; 存放模版文件的templates目录;存放一些蓝图模块的views模块,使用这些蓝图构建网站的不同页面。

网站入口

网站的入口run.py代码很简单,导入app并运行:

from flask_website import app
app.run(debug=True)

app是基于flask,使用websiteconfig中的配置进行初始化

app = Flask(__name__)
app.config.from_object('websiteconfig')

app中设置了一些全局实现,比如404页面定义,全局用户,关闭db连接,和模版时间:

@app.errorhandler(404)
def not_found(error):
    return render_template('404.html'), 404

@app.before_request
def load_current_user():
    g.user = User.query.filter_by(openid=session['openid']).first() \
        if 'openid' in session else None

@app.teardown_request
def remove_db_session(exception):
    db_session.remove()

@app.context_processor
def current_year():
    return {'current_year': datetime.utcnow().year}

加载view部分使用了两种方式,第一种是使用flask的add_url_rule函数,设置了文档的搜索实现,这些url执行docs模块:

app.add_url_rule('/docs/', endpoint='docs.index', build_only=True)
app.add_url_rule('/docs/<path:page>/', endpoint='docs.show',
                 build_only=True)
app.add_url_rule('/docs/<version>/.latex/Flask.pdf', endpoint='docs.pdf',
                 build_only=True)

第二种是使用flask的蓝图功能:

from flask_website.views import general
from flask_website.views import community
from flask_website.views import mailinglist
from flask_website.views import snippets
from flask_website.views import extensions
app.register_blueprint(general.mod)
app.register_blueprint(community.mod)
app.register_blueprint(mailinglist.mod)
app.register_blueprint(snippets.mod)
app.register_blueprint(extensions.mod)

最后app还定义了一些jinja模版的工具函数:

app.jinja_env.filters['datetimeformat'] = utils.format_datetime
app.jinja_env.filters['dateformat'] = utils.format_date
app.jinja_env.filters['timedeltaformat'] = utils.format_timedelta
app.jinja_env.filters['displayopenid'] = utils.display_openid

模版渲染

现在主流的站点都是采用前后端分离的结构,后端提供纯粹的API,前端使用vue等构建。这种结构对于构建小型站点,会比较复杂,有牛刀杀鸡的感觉。对个人开发者,还需要学习更多的前端知识。而使用后端的模版渲染方式构建页面,是比较传统的方式,对小型站点比较实用。

本项目就是使用模版构建,在general蓝图中:

mod = Blueprint('general', __name__)

@mod.route('/')
def index():
    if request_wants_json():
        return jsonify(releases=[r.to_json() for r in releases])

    return render_template(
        'general/index.html',
        latest_release=releases[-1],
        # pdf link does not redirect, needs version
        # docs version only includes major.minor
        docs_pdf_version='.'.join(releases[-1].version.split('.', 2)[:2])
    )

可以看到首页有2种输出方式,一种是json化的输出,另一种是html方式输出,我们重点看看第二种方式。函数render_template传递了模版路径,latest_release和docs_pdf_version两个变量值。

模版也是模块化的,一般是根据页面布局而来。比如分成左右两栏的结构,或者上下结构,布局定义的模版一般叫做layout。比如本项目的模版就从上至下定义成下面5块:

  • head 一般定义html页面标题(浏览器栏),css样式/js-script的按需加载等
  • body_title 定义页面的标题
  • message 定义一些统一的通知,提示类的展示空间
  • body 页面的正文部分
  • footer 统一的页脚

使用layout模版定义,将网站的展示风格统一下来,各个页面可以继承和扩展。下面是head块和message块的定义细节:

<!doctype html>
{% block head %}
<title>{% block title %}Welcome{% endblock %} | Flask (A Python Microframework)</title>
<meta charset=utf-8>
<link rel=stylesheet type=text/css href="{{ url_for('static', filename='style.css') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script type=text/javascript
  src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script>
{% endblock %}
<div class=box>
  ...
  <p class=nav>
    <a href="{{ url_for('general.index') }}">overview</a> //
    <a href="{{ url_for('docs.index') }}">docs</a> //
    <a href="{{ url_for('community.index') }}">community</a> //
    <a href="{{ url_for('extensions.index') }}">extensions</a> //
    <a href="https://psfmember.org/civicrm/contribute/transact?reset=1&id=20">donate</a>
  {% for message in get_flashed_messages() %}
    <p class=message>{{ message }}
  {% endfor %}
  ...

本项目首页的general/index继承自全局的layout,并对其中的body部分进行覆盖,使用自己的配置:

{% extends "layout.html" %}
    ....
{% block body %}
  <ul>
    <li><a href="{{ latest_release.detail_url }}">Download latest release</a> ({{ latest_release.version }})
    <li><a href="{{ url_for('docs.index') }}">Read the documentation</a>
    <li><a href="{{ url_for('mailinglist.index') }}">Join the mailinglist</a>
    <li><a href=https://github.com/pallets/flask>Fork it on github</a>
    <li><a href=https://github.com/pallets/flask/issues>Add issues and feature requests</a>
  </ul>
  ...
  • 这个列表主要使用了蓝图中传入的latest_release变量,展示最新文档(pdf)的url

数据库操作

网站有交互,必定要持久化数据。本项目使用的sqlite的数据库,比较轻量级。数据库使用sqlalchemy封装的ORM实现。下面的代码展示了如何创建一个评论:

@mod.route('/comments/<int:id>/', methods=['GET', 'POST'])
@requires_admin
def edit_comment(id):
    comment = Comment.query.get(id)
    snippet = comment.snippet
    form = dict(title=comment.title, text=comment.text)
    if request.method == 'POST':
        ...
        form['title'] = request.form['title']
        form['text'] = request.form['text']
        ..
        comment.title = form['title']
        comment.text = form['text']
        db_session.commit()
        flash(u'Comment was updated.')
        return redirect(snippet.url)
    ...
  • 创建comment对象
  • 从html的form表单中获取用户提交的title和text
  • 对comment对象进行赋值和提交
  • 刷新页面的提示信息(在模版的message部分展示)
  • 返回到新的url

借助sqlalchemy,数据模型的操作API简单易懂。要使用数据库,需要先创建数据库连接,构建模型等, 主要在database模块:

DATABASE_URI = 'sqlite:///' + os.path.join(_basedir, 'flask-website.db')
# 创建引擎
engine = create_engine(app.config['DATABASE_URI'],
                       convert_unicode=True,
                       **app.config['DATABASE_CONNECT_OPTIONS'])
# 创建session(连接)                
db_session = scoped_session(sessionmaker(autocommit=False,
                                         autoflush=False,
                                         bind=engine))
# 初始化
def init_db():
    Model.metadata.create_all(bind=engine)

# 定义基础模型
Model = declarative_base(name='Model')
Model.query = db_session.query_property()

Comment数据模型定义:

class Comment(Model):
    __tablename__ = 'comments'
    id = Column('comment_id', Integer, primary_key=True)
    snippet_id = Column(Integer, ForeignKey('snippets.snippet_id'))
    author_id = Column(Integer, ForeignKey('users.user_id'))
    title = Column(String(200))
    text = Column(String)
    pub_date = Column(DateTime)

    snippet = relation(Snippet, backref=backref('comments', lazy=True))
    author = relation(User, backref=backref('comments', lazy='dynamic'))

    def __init__(self, snippet, author, title, text):
        self.snippet = snippet
        self.author = author
        self.title = title
        self.text = text
        self.pub_date = datetime.utcnow()

    def to_json(self):
        return dict(author=self.author.to_json(),
                    title=self.title,
                    pub_date=http_date(self.pub_date),
                    text=unicode(self.rendered_text))

    @property
    def rendered_text(self):
        from flask_website.utils import format_creole
        return format_creole(self.text)

Comment模型按照结构化的方式定义了表名,6个字段,2个关联关系和json化和文本化的展示方法。

sqlalchemy的使用,在之前的文章中有过介绍,本文就不再赘述。

openID认证

一个小众的网站,构建自己的账号即麻烦也不安全,使用第三方的用户体系会比较合适。本项目使用的是Flask-OpenID这个库提供的optnID登录认证。

用户登录的时候,会根据用户选择的三方登录站点,跳转到对应的网站进行认证:

@mod.route('/login/', methods=['GET', 'POST'])
@oid.loginhandler
def login():
    ..
    openid = request.values.get('openid')
    if not openid:
        openid = COMMON_PROVIDERS.get(request.args.get('provider'))
    if openid:
        return oid.try_login(openid, ask_for=['fullname', 'nickname'])
    ..

从对应的模版上更容易理解这个过程, 可以看到默认支持AOL/Google/Yahoo三个账号体系认证:

{% block body %}
  <form action="" method=post>
    <p>
      For some of the features on this site (such as creating snippets
      or adding comments) you have to be signed in.  You don't need to
      create an account on this website, just sign in with an existing
      <a href=http://openid.net/>OpenID</a> account.
    <p>
      OpenID URL:
      <input type=text name=openid class=openid size=30>
      <input type=hidden name=next value="{{ next }}">
      <input type=submit value=Login>
    <p>
      Alternatively you can directly sign in by clicking on one of
      the providers here in case you don't know the identity URL:
    <ul>
      <li><a href=?provider=aol>AOL</a>
      <li><a href=?provider=google>Google</a>
      <li><a href=?provider=yahoo>Yahoo</a>
    </ul>
  </form>
{% endblock %}

在三方站点认证完成后,会建立本站点的用户和openid的绑定关系:

@mod.route('/first-login/', methods=['GET', 'POST'])
def first_login():
    ...
        db_session.add(User(request.form['name'], session['openid']))
        db_session.commit()
        flash(u'Successfully created profile and logged in')
    ...
  • session中的openid是第三方登录成功后写入session

三方登录的逻辑过程大概就如上所示,先去三方平台登录,然后和本地站点的账号进行关联。其具体的实现,主要依赖Flask-OpenID这个模块, 我们大概了解即可。

全文检索

全文检索对于一个站点非常重要,可以帮助用户在网站上快速找到适合的内容。本项目展示了使用whoosh这个纯python实现的全文检索工具,构建网站内容检索,和使用ElasticSearch这样大型的检索库不一样。总之,本项目使用的都是小型工具,纯python实现。

全文检索从/search/入口进入:

@mod.route('/search/')
def search():
    q = request.args.get('q') or ''
    page = request.args.get('page', type=int) or 1
    results = None
    if q:
        results = perform_search(q, page=page)
        if results is None:
            abort(404)
    return render_template('general/search.html', results=results, q=q)
  • q是搜素的关键字,page是翻页的页数
  • 使用perform_search方法对索引进行查询
  • 如果找不到内容展示404;如果找到内容,展示结果

在search模块中提供了search方法,前面调用的perform_search函数是其别名:

def search(query, page=1, per_page=20):
    with index.searcher() as s:
        qp = qparser.MultifieldParser(['title', 'content'], index.schema)
        q = qp.parse(unicode(query))
        try:
            result_page = s.search_page(q, page, pagelen=per_page)
        except ValueError:
            if page == 1:
                return SearchResultPage(None, page)
            return None
        results = result_page.results
        results.highlighter.fragmenter.maxchars = 512
        results.highlighter.fragmenter.surround = 40
        results.highlighter.formatter = highlight.HtmlFormatter('em',
            classname='search-match', termclass='search-term',
            between=u'<span class=ellipsis> … </span>')
        return SearchResultPage(result_page, page)
  • 从ttile和content中搜素关键字q
  • 设置使用unicode编码
  • 将检索结果封装成SearchResultPage

重点在index.searcher()这个索引, 它使用下面方法构建:

from whoosh import highlight, analysis, qparser
from whoosh.support.charset import accent_map
...
def open_index():
    from whoosh import index, fields as f
    if os.path.isdir(app.config['WHOOSH_INDEX']):
        return index.open_dir(app.config['WHOOSH_INDEX'])
    os.mkdir(app.config['WHOOSH_INDEX'])
    analyzer = analysis.StemmingAnalyzer() | analysis.CharsetFilter(accent_map)
    schema = f.Schema(
        url=f.ID(stored=True, unique=True),
        id=f.ID(stored=True),
        title=f.TEXT(stored=True, field_boost=2.0, analyzer=analyzer),
        type=f.ID(stored=True),
        keywords=f.KEYWORD(commas=True),
        content=f.TEXT(analyzer=analyzer)
    )
    return index.create_in(app.config['WHOOSH_INDEX'], schema)

index = open_index()
  • whoosh创建本地的索引文件
  • whoosh构建搜素的数据结构,包括url,title,,关键字和内容
  • 关键字和内容参与检索

索引需要构建和刷新:

def update_documentation_index():
    from flask_website.docs import DocumentationPage
    writer = index.writer()
    for page in DocumentationPage.iter_pages():
        page.remove_from_search_index(writer)
        page.add_to_search_index(writer)
    writer.commit()

文档索引构建在docs模块中:

DOCUMENTATION_PATH = os.path.join(_basedir, '../flask/docs/_build/dirhtml')
WHOOSH_INDEX = os.path.join(_basedir, 'flask-website.whoosh')

class DocumentationPage(Indexable):
    search_document_kind = 'documentation'

    def __init__(self, slug):
        self.slug = slug
        fn = os.path.join(app.config['DOCUMENTATION_PATH'],
                          slug, 'index.html')
        with open(fn) as f:
            contents = f.read().decode('utf-8')
            title, text = _doc_body_re.search(contents).groups()
        self.title = Markup(title).striptags().split(u'—')[0].strip()
        self.text = Markup(text).striptags().strip().replace(u'¶', u'')

    @classmethod
    def iter_pages(cls):
        base_folder = os.path.abspath(app.config['DOCUMENTATION_PATH'])
        for dirpath, dirnames, filenames in os.walk(base_folder):
            if 'index.html' in filenames:
                slug = dirpath[len(base_folder) + 1:]
                # skip the index page.  useless
                if slug:
                    yield DocumentationPage(slug)
  • 文档读取DOCUMENTATION_PATH目录下的源文件(项目文档)
  • 读取文件的标题和文本,构建索引文件

小结

本文我们走马观花的查看了flask-view这个flask曾经的主站。虽然没有深入太多细节,但是我们知道了模版渲染,数据库操作,OpenID认证和全文检索四个功能的实现方式,建立了相关技术的索引。如果我们需要构建自己的小型web项目,比如博客,完全可以以这个项目为基础,修改实现。

经过数周的调整,接下我们开始进入python影响力巨大的项目之一: Django。敬请期待。

小技巧

本项目提供了2个非常实用的小技巧。第1个是json化和html化输出,这样用户可以自由选择输出方式,同时站点也可以构建纯API的接口。这个功能是使用下面的request_wants_json函数提供:

def request_wants_json():
    # we only accept json if the quality of json is greater than the
    # quality of text/html because text/html is preferred to support
    # browsers that accept on */*
    best = request.accept_mimetypes \
        .best_match(['application/json', 'text/html'])
    return best == 'application/json' and \
       request.accept_mimetypes[best] > request.accept_mimetypes['text/html']

request_wants_json函数中判断头部的mime类型,进行根据是application/json还是text/html决定展示方式。

第2个小技巧是认证装饰器, 前面一个是登录验证,后一个是超级管理认证:

def requires_login(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if g.user is None:
            flash(u'You need to be signed in for this page.')
            return redirect(url_for('general.login', next=request.path))
        return f(*args, **kwargs)
    return decorated_function

def requires_admin(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not g.user.is_admin:
            abort(401)
        return f(*args, **kwargs)
    return requires_login(decorated_function)

这两个装饰器,在view的API上使用, 比如编辑snippet需要登录,评论需要管理员权限:

@mod.route('/edit/<int:id>/', methods=['GET', 'POST'])
@requires_login
def edit(id):
    ...

@mod.route('/comments/<int:id>/', methods=['GET', 'POST'])
@requires_admin
def edit_comment(id):
    ...

参考链接

  • https://github.com/pallets/flask-website

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/hIq0nGjccGYw1C6eaTVFOg

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237273次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8108次阅读
 目录