对其他人来说也许没什麽,但对他而言这可真是不容易。因为这个男人认为,打从他有记忆以来就这麽相信,某部分的他是用快速回覆按钮 (QuickReplyButton) 做出来的。他唯恐一个错误的回发动作 (PostbackAction) 被触发,然後他将会在她面前分崩离析。
~节录自《聊天机器人的历史:我妈的忧伤》
延伸自系列文 《从LINE BOT到资料视觉化:赖田捕手》
《赖田捕手:追加篇》:
根据可靠的?科学研究,草泥马是一群视觉系的动物。草泥马心理分析权威是这麽说的:「良好而适当的视觉刺激具有稳定草泥马情绪波动,促进正向思考的能力,是培育积极且强韧的草泥马的不二法门」。这应该是个生理影响心理,而後心理又影响生理的最佳例子。那麽这就让人好奇了,到底什麽是良好而适当的视觉刺激呢?答案是,草泥马们相当享受观赏双色打光影像的时刻。
所谓的双色打光,顾名思义,就是在一个主要观赏目标的两侧,分别打上两种不同的单色光,如图一。
图一、双色打光!
身为一个专业的草泥马训练师,我当然义不容辞要为我所饲养的草泥马们打造出最舒适的环境,也就是将所有的图片转为双色打光的图片,供草泥马们惬意的欣赏。这件事说难不难,说简单,好像也没那麽容易。要将所有的图片转为双色打光的图片?那我岂不是一整天忙着修图就饱了,这样根本没时间照顾草泥马啊。幸好我是一个懂得写 LINE BOT 的草泥马训练师。是的,只要写出一个专门将一般图片转为双色打光图片的 LINE BOT,那所有的任务都交给 LINE BOT 就搞定了。是不是很吸引人呢?那麽,接下来请容我娓娓道来,我是如何建构出一个擅长双色打光的 LINE BOT。
关於双色打光的概念,鼓励各位读者参考 Logos By Nick 的影片分享。相当感谢他的热情教学,才有我的双色打光 LINE BOT。
工欲善其事,必先利其器。为了完成我们了不起的双色打光 LINE BOT,将程序码好好的分门别类是一件相当重要的工作。在第 31 天当中,我们已经将程序码做了一个大略的分类,把初始化 LINE BOT 的程序码、处理handler
相关的程序码、处理route
相关的程序码拆开变成了三个档案。档案结构如下:
D:\appendix>tree /f
Folder PATH listing
Volume serial number is 9C33-6XXD
D:.
│ runtime.txt
│ requirements.txt
│ Procfile
│ Alma.py
│
└───app
__init__.py
models_for_line.py
routes.py
为了要做出功能强大的 LINE BOT,我们会在处理handler
相关的任务中写下更多更长更繁琐的程序码。为了保持乾净的程序码,便於继续扩充和维护,我打算把models_for_line.py
这个档案中的详细功能拉出来,另外创立几个档案,因此资料夹的档案结构会变成这样:
D:\appendix>tree /f
Folder PATH listing
Volume serial number is 9C33-6XXD
D:.
│ runtime.txt
│ requirements.txt
│ Procfile
│ Alma.py
│
└───app
│ __init__.py
│ models_for_line.py
│ routes.py
│
└───custom_models
AlmaTalks.py
详细回覆使用者的方法被放到app/custom_models/AlmaTalks.py
:
app/custom_models/AlmaTalks.py
from app import line_bot_api
from linebot.models import TextSendMessage
def default_reply(event):
name = line_bot_api.get_profile(event.source.user_id).display_name
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(text=f"Hello {name}!")
)
而原本的models_for_line.py
则剩下:
app/models_for_line.py
from app import handler
from app.custom_models import AlmaTalks
from linebot.models import MessageEvent, TextMessage
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
AlmaTalks.default_reply(event)
为什麽选择这麽做呢?因为我希望models_for_line.py
这个档案简单一点,让人一目了然。而真正困难而仔细的各种任务就放到app/custom_models
底下的不同档案里。
那麽,实际上最核心的双色打光程序码可以怎麽做呢?概念上来说,应该也不难:
两步骤完成!
而 Python 在影像处理以及数据处理方面有非常丰富的资源库,我们可以简单的用 PIL 和 numpy 这两个资源库来完成这个任务。
import numpy as np
from PIL import Image
from PIL import ImageDraw
def sigmoid(x, alpha):
return 1 /(1 + np.exp(-x * alpha))
def create_gradient_layer(layer_im, first_tone, second_tone, gradient_factor):
layer_gradient = Image.new('RGB', layer_im.size)
draw = ImageDraw.Draw(layer_gradient)
for i in range(layer_im.size[0]):
value = sigmoid(i - layer_im.size[0] / 2, gradient_factor / layer_im.size[0])
fill_color = np.array(first_tone) * value + np.array(second_tone) * (1 - value)
draw.line([(i, 0), (i, layer_im.size[1]-1)], fill=tuple(fill_color.astype('int')))
return layer_gradient
所谓的渐层,即是色彩由深而浅的变化,於是我们可以先创造一个sigmoid
函式来模拟这种变化,并且设计两个参数,x
和alpha
,x
与位置有关,根据位置来做出不同颜色的深浅变化。而第二个参数alpha
则让我们可以控制渐层变化的幅度。以我们设计的sigmoid
函式来说,alpha
越小,梯度变化越平缓,而alpha
越大,梯度变化越急遽,具体结果可以用matplotlib
这个用来作图的资源库来检视 (图二):
import matplotlib.pyplot as plt
fig, axes = plt.subplots(3, 1, figsize=(10, 10))
x = np.linspace(0, 10, 101)
for ax, g in zip(axes.flatten(), [1, 10, 100]):
ax.plot(x, sigmoid(x-5, g), label=f'gradient={g}')
ax.legend(fontsize='x-large')
图二、sigmoid
函式运作方式
在create_gradient_layer
这个函式里,我们先用Image.new()
来产生一张新的图像,接着再用ImageDraw.Draw()
为这张新的图像上色。
layer_gradient = Image.new('RGB', layer_im.size)
:
产生一张以'RGB'
做为资料储存格式的图像layer_gradient
,图像大小则参考原始图像layer_im.size
。
draw = ImageDraw.Draw(layer_gradient)
:
准备在新的图像layer_gradient
上面作画。
draw.line([(i, 0), (i, layer_im.size[1]-1)], fill=tuple(fill_color.astype('int')))
:
从座标(i, 0)
开始到座标(i, layer_im.size[1]-1)
为止,画一条线,该线条的颜色则由fill=tuple(fill_color.astype('int'))
来定义。由於我们的layer_gradient
这个新图像是用'RGB'
做为资料储存的格式,因此fill
也要用相同的形式来表示,如红色应该表示为(255, 0, 0)
,绿色是(0, 255, 0)
,诸如此类。
这边假定有一张demo_image.jpg
,那麽我们执行create_ gradient_layer
:
create_gradient_layer(Image.open('demo_image.jpg'), (0, 255, 0), (0, 0, 255), 1)
图三、gradient_factor=1
创造出来的双色渐层图像
create_gradient_layer(Image.open('demo_image.jpg'), (0, 255, 0), (0, 0, 255), 10)
图四、gradient_factor=10
创造出来的双色渐层图像
create_gradient_layer(Image.open('demo_image.jpg'), (0, 255, 0), (0, 0, 255), 100)
图五、gradient_factor=100
创造出来的双色渐层图像
blend
或是composite
两种方式将双色渐层图像与原始图像进行叠图。Image.blend
:Image.blend(layer_im, layer_gradient, alpha=0.5)
这边的alpha
可以调整两张影像是用何种比例进行叠图的。若希望第一张影像layer_im
所占的比例高一些,则alpha
要小一点,若希望第二张影像layer_gradient
所占的比例高一些,则alpha
就设定大一点。极端一点来说,若alpha=0
则会直接得到第一张影像layer_im
,而alpha=1
会直接得到第二张影像layer_gradient
。
图六、Image.blend
叠图
Image.composite
:# 用layer_im当滤镜,深色的地方双色打光效果明显
Image.composite(layer_im, layer_gradient, layer_im.convert('L'))
# 用ImageOps.invert(layer_im)当滤镜,浅色的地方双色打光效果明显
Image.composite(layer_im, layer_gradient, ImageOps.invert(layer_im).convert('L'))
第二种方式composite
则提供了有趣的叠图选择。一样是将两张影像layer_im
和layer_gradient
叠在一起,不过第二张影像要透过一个带有透明讯息的滤镜来叠图。而最直觉的滤镜,就是layer_im.convert('L')
,这样可以做出如图七的影像,深色的地方会有较强的双色打光效果。
图七、Image.composite
叠图
另外我们也可以用ImageOps.invert
,将影像黑白反转,这样的滤镜可以做出如图八的影像,浅色的地方会有较强的双色打光效果。
图八、Image.composite
加上ImageOps.invert
叠图
完成之後,我们可以把所有程序码串在一起了:
import numpy as np
from PIL import Image
from PIL import ImageDraw
from PIL import ImageOps
def sigmoid(x, alpha):
return 1 /(1 + np.exp(-x * alpha))
def create_gradient_layer(layer_im, first_tone, second_tone, gradient_factor):
layer_gradient = Image.new('RGB', layer_im.size)
draw = ImageDraw.Draw(layer_gradient)
for i in range(layer_im.size[0]):
value = sigmoid(i - layer_im.size[0] / 2, gradient_factor / layer_im.size[0])
fill_color = np.array(first_tone) * value + np.array(second_tone) * (1 - value)
draw.line([(i, 0), (i, layer_im.size[1]-1)], fill=tuple(fill_color.astype('int')))
return layer_gradient
def dual_tone_run(file, mode, gradient_factor, first_tone, second_tone):
layer_im = Image.open(file).convert('RGBA')
layer_gradient = create_gradient_layer(layer_im, first_tone, second_tone, gradient_factor).convert('RGBA')
if mode == 'blend':
dual_tone = Image.blend(layer_im, layer_gradient, 0.5)
elif mode == 'composite':
dual_tone = Image.composite(layer_im, layer_gradient, layer_im.convert('L'))
elif mode == 'composite_invert':
dual_tone = Image.composite(layer_im, layer_gradient, ImageOps.invert(layer_im.convert('L')).convert('L'))
return dual_tone
完成了双色打光的运作之後,就可以开始来设计我们的 LINE BOT 了。大家在实作双色打光的程序码时,应该注意到这段程序码其实拥有许多参数,而这些参数都可以大大的影响最後产生出来的图像。因此我们在建构这个影像处理 LINE BOT 的时候,其实是可以给出不少选项供使用者依照个人偏好或是情境做出选择的。而在这方面,LINE 相当贴心的推出了各种功能,让使用者可以更容易的与 LINE BOT 进行互动,像我们今天要介绍的快速回覆 (QuickReply
) 就是其中一种。
快速回覆有点像 LINE BOT 丢给使用者的选择题。LINE BOT 提供选项,而使用者从选择其中一个作为回答,回传给 LINE BOT,完成一次互动,操作起来简单明了。不过,快速回覆最大的缺点是只会显示在手机介面上。因此目前没办法在电脑版的介面上透过快速回覆与 LINT BOT 进行互动。
要使用快速回覆也不难,先看一段 LINE 官方给出的使用范例:
text_message = TextSendMessage(
text='Hello, world',
quick_reply=QuickReply(items=[
QuickReplyButton(action=MessageAction(label="label", text="text"))
])
)
这段操作的结果是,LINE BOT 会传送文字讯息'Hello, world'
给使用者,而使用者会看到一个快速回覆按钮 (QuickReplyButton
)。如果我们想要提供更多的快速回覆按钮,可以这麽做:
quick_reply=QuickReply(
items=[
QuickReplyButton(),
QuickReplyButton(),
QuickReplyButton(),
…
]
)
只要在代表选项的items
这个清单当中放入更多的QuickReplyButton
就可以了。
回到我们的范例。这边所提供的快速回覆按钮的文字显示为"label"
。使用者可以点击这个按钮,当作对 LINE BOT 的回答。点下去的时候,该按钮所设定的动作被触发,也就是讯息动作MessageAction(label="label", text="text")
,这时候系统会为使用者传送文字讯息"text"
给 LINE BOT。换句话说,点击带有MessageAction(label="label", text="text")
动作的按钮,就好像使用者亲自发送文字讯息"text"
给 LINE BOT 一样。
常用的几种动作包括MessageAction
、PostbackAction
、URIAction
等。回发动作PostbackAction
可以看做进阶的MessageAction
,除了可以发送文字讯息之外,还会多传送隐藏的资料 (不会显示在 LINE 的对话视窗当中),方便 LINE BOT 根据这些资料做出适当的回应。而URIAction
则是为使用者开启指定连结的动作。
这边我打算用回发动作PostbackAction
,使用范例如下:
action=PostbackAction(
label='postback',
display_text='postback text',
data='action=buy&itemid=1'
)
label='postback'
:
用来设定代表触发该动作的按钮的显示文字。
display_text='postback text'
:
触发该动作之後,系统为使用者传送的文字讯息。也就是说,点击此按钮,就好像使用者向 LINE BOT 传送了'postback text'
这样的文字讯息。但跟MessageAction
不同的是,LINE BOT 并不会因此收到MessageEvent
。相对的,LINE BOT 会收到的是回发事件PostbackEvent
,因为这可是PostbackAction
啊。所以说,跟MessageAction
不同,这边的文字讯息只是假的,是显示文字 (display_text
) 而已。
data='action=buy&itemid=1'
:
这个才是PostbackAction
真正传送到 LINE BOT 的资讯。LINE BOT 会收到PostbackEvent
,而我们可以藉由event.postback.data
来拿到这些资讯。
好的,既然我们已经知道怎麽做出双色打光,也了解怎麽使用QuickReply
,那现在就可以开始来规划一下整个 LINE BOT 跟使用者的互动流程,看看我们的 LINE BOT 怎麽替使用者客制化作出双色打光影像处理。
这部分大家当然可以自由发挥,我就提一个简单的流程来说明我会如何架构:
图九、LINE BOT 与使用者互动流程
图九是我打算采用的流程概念图,整个互动从使用者向 LINE BOT 传送图像开始,也就是当 LINE BOT 接收到ImageEvent
,整段流程就开始了。使用者依序设定好模式 (mode)、梯度 (gradient_factor)、第一种颜色 (first_tone)、以及第二种颜色 (second_tone),接着 LINE BOT 就根据这些使用者给出的条件,去对使用者一开始传送过来的图像做影像处理。按照这个规划,我们就得为models_for_line.py
这个档案添加几段程序码:
app/models_for_line.py
from app import handler
from app.custom_models import AlmaTalks
from linebot.models import ImageMessage, PostbackEvent
@handler.add(MessageEvent, message=ImageMessage)
def handle_image(event):
AlmaTalks.phase_start(event)
@handler.add(PostbackEvent)
def handle_postback(event):
if not event.postback.data.startswith('second_tone='):
AlmaTalks.phase_intermediate(event)
else:
AlmaTalks.phase_finish(event)
按照这样的设计,当使用者向 LINE BOT 传送图片 (ImageMessage
),任务开始,启动AlmaTalks.phase_start
这个函式。接着 LINE BOT 会依序接收到使用者透过QuickReplyButton
传送过来的PostbackEvent
,而这些就交给AlmaTalks.phase_intermediate
和AlmaTalks.phase_finish
来处理。所以现在我们就要来着手撰写这几个函式。
app/custom_models/AlmaTalks.py
def phase_start(event):
# 初始化表格
CallDatabase.init_table()
# 检查使用者资料是否存在
if CallDatabase.check_record(event.source.user_id):
_ = CallDatabase.update_record(event.source.user_id, 'message_id', event.message.id)
else:
_ = CallDatabase.init_record(event.source.user_id, event.message.id)
mode_dict = {'blend': '线性叠图', 'composite': '滤镜叠图', 'composite_invert': '反式滤镜叠图'}
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(
text=f"[1/4] 今晚,我想来点双色打光!\n请选择双色打光模式:",
quick_reply=QuickReply(
items=[QuickReplyButton(action=PostbackAction(
label=v,
display_text=f'打光模式:{v}',
data=f'mode={k}')) for k, v in mode_dict.items()
]
)
)
)
为了让 LINE BOT 能够记得使用者选择的设定,我们在第 31 天添加了一个扩充元件 Heroku Postgres 当作资料库,将我们需要保留的资料,也就是使用者的设定,储存起来。所有与资料库的互动,包括初始化表格、检查资料、放入资料、更新资料等等,我都打算放进另一个档案里,也就是app/custom_models/CallDatabase.py
,等等会再详细介绍。
根据这段程序码,函式phase_start
要做的事就是当接收到使用者传来的图片时,为使用者在资料库中的表格初始化一笔资料 (或是更新资料),接着透过QuickReplyButton
提供不同的打光模式选择给使用者。
app/custom_models/AlmaTalks.py
def phase_intermediate(event):
color_dict = {
'red': '红',
'orange': '橙',
'yellow': '黄',
'green': '绿',
'blue': '蓝',
'purple': '紫'
}
reply_dict = {
'mode': '[2/4] 今晚,继续来点双色打光!\n请选择色彩变化梯度:',
'gradient_factor': '[3/4] 今晚,还想来点双色打光!\n请选择第一道色彩:',
'first_tone': '[4/4] 今晚,最後来点双色打光!\n请选择第二道色彩:'
}
quick_button_dict = {
'mode':
[QuickReplyButton(
action=PostbackAction(
label=i,
display_text=f'变化梯度:{i}',
data=f'gradient_factor={i}')) for i in (5, 10, 50, 100)
],
'gradient_factor':
[QuickReplyButton(
action=PostbackAction(
label=j,
display_text=f'第一道色彩:{j}',
data=f'first_tone={i}')) for i, j in color_dict.items()
],
'first_tone':
[QuickReplyButton(
action=PostbackAction(
label=j,
display_text=f'第二道色彩:{j}',
data=f'second_tone={i}')) for i, j in color_dict.items()
]
}
user_id = event.source.user_id
postback_data = event.postback.data
current_phase = postback_data.split('=')[0]
# 依照使用者的选择更新资料
CallDatabase.update_record(user_id, current_phase, postback_data.split('=')[1])
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(
text=reply_dict[current_phase],
quick_reply=QuickReply(
items=quick_button_dict[current_phase]))
)
这边应该也难不倒大家。先建构好reply_dict
和quick_button_dict
,按照流程准备好不同阶段相对应的回答跟快速回覆按钮。这边我在QuickReplyButton
的PostbackAction
里藏了不同阶段的暗示,让 LINE BOT 在收到PostbackEvent
时,可以藉由event.postback.data
来判断这一连串的互动是进行到哪一阶段了。
app/custom_models/AlmaTalks.py
def phase_finish(event):
user_id = event.source.user_id
postback_data = event.postback.data
current_phase = postback_data.split('=')[0]
# 更新资料并取得最後的完整设定
record = CallDatabase.update_record(user_id, current_phase, postback_data.split('=')[1])
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(text=str(record))
)
最後一个阶段,在使用者选择好第二道色彩的同时,我们让 LINE BOT 更新这一笔设定,并从资料库中提取该名使用者先前的所有设定,包括打光模式、变化梯度、以及选择的两种色彩。我们可以简单的用TextSendMessage
来将设定的内容传回给使用者,检查 LINE BOT 是否真的记下了这些内容。
在前面我们设计的互动过程中,LINE BOT 是利用app/custom_models/CallDatabase.py
来操作 Heroku Postgres,纪录、更新、提取使用者的设定。在互动流程大致底定之後,现在该是时候把CallDatabase
给生出来了。
app/custom_models/CallDatabase.py
import os
import psycopg2
def access_database():
DATABASE_URL = os.environ['DATABASE_URL']
conn = psycopg2.connect(DATABASE_URL, sslmode='require')
cursor = conn.cursor()
return conn, cursor
def init_table():
conn, cursor = access_database()
postgres_table_query = "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname != 'pg_catalog' AND schemaname != 'information_schema'"
cursor.execute(postgres_table_query)
table_records = cursor.fetchall()
table_records = [i[0] for i in table_records]
if 'user_dualtone_settings' not in table_records:
create_table_query = """CREATE TABLE user_dualtone_settings (
user_id VARCHAR ( 50 ) PRIMARY KEY,
message_id VARCHAR ( 50 ) NOT NULL,
mode VARCHAR ( 20 ) NOT NULL,
gradient_factor VARCHAR ( 20 ) NOT NULL,
first_tone VARCHAR ( 20 ) NOT NULL,
second_tone VARCHAR ( 20 ) NOT NULL
);"""
cursor.execute(create_table_query)
conn.commit()
return True
先写好两个函式:连接 Heroku Postgres 资料库的起手式access_database
,以及在资料库中初始化表格init_table
。先跟大家道个歉。在第 31 天当中,我也写了一个init_table
这个函式,用来创造我们需要用的表格'user_dualtone_settings'
。不过经过一个星期的修订之後,我想要更改表格的栏位和资料类型,改动如下:
CREATE TABLE user_dualtone_settings (
user_id VARCHAR ( 50 ) PRIMARY KEY,
message_id VARCHAR ( 50 ) NOT NULL,
mode VARCHAR ( 20 ) NOT NULL,
gradient_factor VARCHAR ( 20 ) NOT NULL,
first_tone VARCHAR ( 20 ) NOT NULL,
second_tone VARCHAR ( 20 ) NOT NULL
);
所以说,如果有人已经根据上星期的内容在资料库里新增了一个表格,那较简单的方法可能是把 Heroku Postgres 这个扩充元件给删了,再重新新增一个。当然,如果对 SQL 语法以及psycopg2
熟悉的朋友,也可以用删掉表格 (DROP TABLE
,详细内容可以参考第 15 天)、改动表格 (ALTER TABLE
) 等等方式来做修改。对於造成的不便,再次向大家道歉。
接着我们需要一个检查使用者资料是否存在的函式check_record
:
app/custom_models/CallDatabase.py
def check_record(user_id):
conn, cursor = access_database()
postgres_select_query = f"SELECT * FROM user_dualtone_settings WHERE user_id = '{user_id}';"
cursor.execute(postgres_select_query)
user_settings = cursor.fetchone()
return user_settings
如果没有纪录,那就先初始化一笔暂时的纪录:
app/custom_models/CallDatabase.py
def init_record(user_id, message_id):
conn, cursor = access_database()
table_columns = '(user_id, message_id, mode, gradient_factor, first_tone, second_tone)'
postgres_insert_query = f"INSERT INTO user_dualtone_settings {table_columns} VALUES (%s,%s,%s,%s,%s,%s)"
record = (user_id, message_id, 'blend', '50', 'red', 'blue')
cursor.execute(postgres_insert_query, record)
conn.commit()
cursor.close()
conn.close()
return record
以及更新纪录的方法:
app/custom_models/CallDatabase.py
def update_record(user_id, col, value):
conn, cursor = access_database()
postgres_update_query = f"UPDATE user_dualtone_settings SET {col} = %s WHERE user_id = %s"
cursor.execute(postgres_update_query, (value, user_id))
conn.commit()
postgres_select_query = f"SELECT * FROM user_dualtone_settings WHERE user_id = '{user_id}';"
cursor.execute(postgres_select_query)
user_settings = cursor.fetchone()
cursor.close()
conn.close()
return user_settings
全部写好之後,我们的档案架构看起来会像这样:
D:\appendix>tree /f
Folder PATH listing
Volume serial number is 9C33-6XXD
D:.
│ runtime.txt
│ requirements.txt
│ Procfile
│ Alma.py
│
└───app
│ __init__.py
│ models_for_line.py
│ routes.py
│
└───custom_models
AlmaTalks.py
CallDatabase.py
好了,那麽把完成的资料夹丢到 Heroku 上面吧!
棒!是不是真的记住了我们的设定呢。
图十、记住所有设定的 LINE BOT
等等,说好今晚的双色打光呢?
抱歉,今晚已经有点晚了。这个我们留到下星期,讨论如何利用 Heroku 暂存空间的同时,再一起把所有东西补上。有兴趣的读者,也可以试着利用上面我们讨论出来的双色打光程序码,装备到 LINE BOT 上,看看 Heroku 是否跑得动这个双色打光的杰出操作 (当然是跑得动,不然这个系列文就?)。
好的,相信大家知道接下来又要进入最重要的工商时间了。是的,感谢 iT邦帮忙 和 博硕文化,LINE Bot by Python 全攻略 集结成书了,欢迎有兴趣的大家前往预购喔。
<<: iOS APP 开发 OC 第五天, OC 数据类型
>>: 欸! 我觉得自动化测试的架构应该长这样,测试应该这样写。
今天继续介绍如何在云端服务器上持续开启bot 但在进入replit之前需要在GitHub专案放进两个...
今天来学学Vue里面的判别式v-if 跟v-show 1.v - if 在这里我们将条件设定为Sho...
补充一点HTML的资讯,HTML从1995年至今已经发展了多个版本,目前主流使用为HTML5,每个版...
前情提要: 继续讲着工具力的源头 我:所以你们这些工具人,跟她的姐妹差别就是有没有能力? //pri...
今天我们稍微调动一下顺序,先解 General Skills 系列的最後一题, 因为跟昨天的题目算...