Log
1 产品 Product
1.1 创建 Product
创建名为 project
的 rails 应用
rails new project
创建 Product
模型
rails generate scaffold Product title:string description:text image_url:string price:decimal
这会生成一个 migration
,我们需要进一步修改这个迁移,保证价格拥有 8 位有效数字,同时小数点后保留两位。修改迁移文件
class CreateProducts < ActiveRecord::Migration[7.0]
def change
create_table :products do |t|
t.string :title
t.text :description
t.string :image_url
t.decimal :price, precision: 8, scale: 2
t.timestamps
end
end
end
然后就可以进行 migrate
rake db:migrate
这里的 rake
可以被理解为一个脚本的管理器,db:migrate
是其中的一个脚本。还有一种说法是 rake
类似与 C 中的 make
最终我们对于数据库的修改,都会被记录在 db/schema.rb
中,比如说现在
ActiveRecord::Schema[7.0].define(version: 2022_12_31_030243) do
create_table "products", force: :cascade do |t|
t.string "title"
t.text "description"
t.string "image_url"
t.decimal "price", precision: 8, scale: 2
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
end
可以看到基本上与 migration
是一致的,原来的 t.timestamps
时间戳变成了 created_at
和 updated_at
两个属性。此外主键被叫做 product_id
,并没有在这里显示,这应该是一种默认配置。
1.2 本地服务器
我们输入如下命令,就可以在本地启动服务器
rails s
会看到如下字样
=> Booting Puma
=> Rails 7.0.4 application starting in development
其中的 Puma
似乎是一个线程管理器,每个线程都用于处理来自客户端的一个 request
。
1.3 表单
app/views/products/_form.html.erb
是一个局部渲染文件,用于当做 product
信息的表单,这个表单会在 new.html.erb, edit.html.erb
这两个文件中用到,如下所示
<h1>New product</h1>
<!-- 这里对表单进行局部渲染,并且传递局部参数 product -->
<%= render "form", product: @product %>
<br>
<div>
<%= link_to "Back to products", products_path %>
</div>
关于局部渲染,有如下知识:https://blog.csdn.net/weixin_30621711/article/details/96260112
表单的具体内容如下
<!-- form_with 是 rails 所带的一种表单形式,类似的还有 form_for -->
<%= form_with(model: product) do |form| %>
<!-- product 如果存在问题 -->
<% if product.errors.any? %>
<div style="color: red">
<h2><%= pluralize(product.errors.count, "error") %> prohibited this product from being saved:</h2>
<ul>
<% product.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<!-- product 的 title -->
<div>
<!-- title 表项 -->
<%= form.label :title, style: "display: block" %>
<!-- title 具体内容 -->
<%= form.text_field :title %>
</div>
<!-- product 的 description -->
<div>
<%= form.label :description, style: "display: block" %>
<!-- 这里将产品描述的行数和列数进行了自定义增大 -->
<%= form.text_area :description, rows: 10, cols: 60 %>
</div>
<!-- product 的 image_url -->
<div>
<%= form.label :image_url, style: "display: block" %>
<%= form.text_field :image_url %>
</div>
<!-- product 的 price -->
<div>
<%= form.label :price, style: "display: block" %>
<%= form.text_field :price %>
</div>
<!-- 填完表单后提交 -->
<div>
<%= form.submit %>
</div>
<% end %>
需要注意我们将表单的产品描述部分的输入框变大了
<!-- 这里将产品描述的行数和列数进行了自定义增大 -->
<%= form.text_area :description, rows: 10, cols: 60 %>
1.4 seeds
如果数据库不是同一个(一般本地开发多个,云端一个),那么测试数据就成了“个人私有”的,显然是低效的,我们可以给数据库一组“初始值“(也就是种子,seeds),这组初始值我们可以在 db/seeds.rb
中给出,如下所示
Product.delete_all
Product.create(title: 'Programming Ruby 1.9',
description:
%{
<p>
Ruby is the fastest growing and most exciting dynamic language out there.
</p>
},
image_url: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fwww.kfzimg.com%2Fsw%2Fkfzimg%2F1575%2F012f2857fe9a2966a5_b.jpg&refer=http%3A%2F%2Fwww.kfzimg.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1668567051&t=61ed25128b60ad15cbe2c21729511f99',
price: 49.50
)
然后运行
rake db:seed
就可以添加这个数据。
1.5 SCSS
当前的 products
页面过于丑陋,可以考虑给所有的产品界面一组样式,这里我们用 scss
实现,在 app/assets/stylesheets/
中创建 products.scss
并写入下面的内容
.products {
table {
border-collapse: collapse;
}
table tr td{
padding: 5px;
vertical-align: top;
}
.list_image {
width: 60px;
height: 70px;
}
.list_description {
width: 60%;
dl {
margin: 0;
}
dt {
color: #244;
font-weight: bold;
font-size: larger;
}
dd {
margin: 0;
}
}
.list_actions {
font-size: x-small;
text-align: right;
padding-left: 1em;
}
.list_line_even {
background: #e0f8f8;
}
.list_line_odd {
background: #e2c3e2;
}
}
然后就会发现运行不了,这是因为 rails
默认不能处理 scss
,需要在 gemfile
中加入如下依赖
# Use Sass to process CSS
gem "sassc-rails"
gem 'bootstrap-sass'
然后命令行运行
bundle install
这是因为
rake是Ruby语言的构建工具,它的配置文件是Rakefile。
gem是Ruby语言的包管理工具,它的配置文件后缀是.gemspec。
bundler是Ruby语言的外部依赖管理工具,它有一个别名叫”bundle”,它的配置文件是Gemfile。
然后在 views/layouts/application.html.erb
中需要进行修改,加上每个类对应不同的 scss
。
<body class='<%= controller.controller_name %>'>
<%= yield %>
</body>
views/layouts/application.html.erb
是一个布局页面,会对每一个页面都适用。
最后写一下 index.html.erb
的具体信息
<div id="product_list">
<h1>Products</h1>
<table>
<% @products.each do |product| %>
<tr class="<%= cycle('list_line_odd', 'list_line_even') %>">
<td>
<%= image_tag(product.image_url, class: 'list_image') %>
</td>
<td class="list_description">
<dl>
<dt><%= product.title %></dt>
<dd><%= truncate(strip_tags(product.description), :length => 80) %></dd>
</dl>
</td>
<td class="list_actions">
<%= link_to 'Show', product %><br/>
<%= link_to 'Edit', edit_product_path(product) %><br/>
<%= link_to 'Destroy', product, :confirm => 'Are you sure?', :method => :delete %>
</td>
</tr>
<% end %>
</table>
</div>
<br/>
<%= link_to 'New product', new_product_path %>
1.6 验证
可以在模型层对于模型的属性添加验证,对于 Product
来说有如下验证
class Product < ApplicationRecord
validates :title, :description, :image_url, presence: true
validates :price, numericality: {:greater_than_or_equal_to => 0.01}
validates :title, uniqueness: true
validates :image_url, format: {
:with => %r{\.(gif|jpg|png)}i,
:message => 'must be a URL for GIF, JPG or PNG image.'
}
end
验证的格式如下
validate [属性名], [验证内容]
具体的验证内容有
presence
numericality
uniqueness
uniqueness
1.7 路由设置
为了更好的展示产品(而不是需要通过 get
路由访问产品列表),我们可以另外再从用户的角度完善一个页面,这需要借助一个一个新的控制器(在后面的开发中,它被定义为“付费购买用户所使用的控制器”),在终端输入
rails generate controller Store index
它的意思是生成一个叫做 Store
的控制器,同时只有一个 index
动作。
然后我们希望当访问根目录的时候,可以访问到 Store#index
对应的界面,所以我们在 router.rb
中加上这句话
root 'store#index', as: 'store_index'
至于这个是怎么来的,可以这样理解,在路由中,标准写法是这样的
get '/test/:id', to: 'test#test', as 'test_test'
这个意思是,用户用 get
的方式访问 test/:id
这个 ulr 的时候,实际访问的是 Test
控制器对应的 test
动作对应的 view
。当我们有了 as
之后,我们可以通过 test_test_path
来指代 xxxx/test/:id
,用 test_test_url
指代 http:/xxxx/test/:id
。也就是说path 类方法是对应的路径,不带协议部分。url 生成的带 http。两者差别在此。
这样看上面的 root
,只是某种意义的简写。
我们常见的
resoureces: products
其实就是一堆标准格式的声明,如下所示
HTTP Verb | Path | Action | Used for |
---|---|---|---|
GET | /products | index | display a list of all products |
GET | /products/new | new | return an HTML form for creating a new product |
POST | /products | create | create a new product |
GET | /products/:id | show | display a specific product |
GET | /products/:id/edit | edit | return an HTML form for editing a product |
PATCH/PUT | /products/:id | update | update a specific product |
DELETE | /products/:id | destroy | delete a specific product |
其中 PATCH, PUT, POST
都会被转换成 POST
- PATCH: 实体中包含一个表,表中说明与该URI所表示的原内容的区别
- PUT:上传资源
- DELETE:删除资源
1.8 美化商品目录
在 store#index
中补充如下代码,表示按字典序展示所有的 Product
class StoreController < ApplicationController
def index
@products = Product.order(:title)
end
end
同时调整相应的视图
<p id="notice"><%= notice %></p>
<h1>Your Pragmatic Catalog</h1>
<% @products.each do |product| %>
<div class="entry">
<%= image_tag(product.image_url) %>
<h3><%= product.title %></h3>
<%= sanitize(product.description) %>
<div class="price_line">
<span class="price">
<%= number_to_currency(product.price) %>
</span>
</div>
</div>
<% end %>
相应的 scss 表
.store {
h1 {
margin: 0;
padding-bottom: 0.5em;
font: 150% Sans-Serif;
color: #226;
border-bottom: 3px dotted #77d;
}
.entry {
overflow: auto;
margin-top: 1em;
border-bottom: 1px dotted #77d;
height: 100px;
}
img {
width: 80px;
margin-right: 5px;
margin-bottom: 5px;
height: 100px;
position: absolute;
}
h3 {
font-size: 120%;
font-family: sans-serif;
margin-left: 100px;
margin-top: 0;
margin-bottom: 2px;
color: #277;
}
p, div.price_line {
margin-left: 100px;
margin-top: 0.5em;
margin-bottom: 0.8em;
}
.price {
color: #44a;
font-weight: bold;
margin-right: 3em;
}
}
1.9 页面布局
修改 layouts/application.html.erb
加入侧边栏和顶栏
<!DOCTYPE html>
<html>
<head>
<title>Pragprog Books Online Store</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body class='<%= controller.controller_name %>'>
<div id="banner">
<span class="title"><%= @page_title %></span>
</div>
<div id="columns">
<div id="side">
<ul>
<li><a href="#">Home</a></li>
<li><a href="#">Question</a></li>
<li><a href="#">News</a></li>
<li><a href="#">Contact</a></li>
</ul>
</div>
<div id="main">
<%= yield %>
</div>
</div>
</body>
</html>
同时修改 scss
文件
body, body > p, body > ol, body > ul, body > td {
margin: 8px !important;
}
#banner {
position: relative;
min-height: 40px;
background: #9c9;
padding: 10px;
border-bottom: 2px solid;
font: small-caps 40px/40px "Times New Roman", serif;
color: #282;
text-align: center;
img {
position: absolute;
top: 5px;
left: 5px;
width: 60px;
height: 60px;
}
}
#notice {
color: #000 !important;
border: 2px solid red;
padding: 1em;
margin-bottom: 2em;
background-color: #f0f0f0;
font: bold smaller sans-serif;
}
#notice:empty {
display: none;
}
#columns {
background: #141;
display: flex;
#main {
padding: 1em;
background: white;
flex: 1;
}
#side {
padding: 1em 2em;
background: #141;
ul {
padding: 0;
li {
list-style: none;
a {
color: #bfb;
font-size: small;
}
}
}
}
}
@media all and (max-width: 800px) {
#columns {
flex-direction: column-reverse;
}
}
@media all and (max-width: 500px) {
#banner {
height: 1em;
}
#banner .title {
display: none;
}
}
2 购物车 Cart
2.1 Cart 模型
创建购物车
rails generate scaffold Cart
rake db:migrate
可以看到 Cart
基本上没有任何属性,这是因为当前开发的时候我们还不需要它们。
2.2 LineItem 商品模型
我们称在购物车中东西为“商品”,与之对应的还有“产品 Product”,两者的区别是 Product 具有某种静态的属性,没有办法说“两种香皂”,但是很容易形容“两个香皂”。LineItem
依附 Product
存在,同时也依附 Cart
。
所以我们这样定义它
rails generate scaffold LineItem product:references cart:belongs_to
rake db:migrate
这种定义方式会在模型层自动生成如下代码
class LineItem < ApplicationRecord
belongs_to :product
belongs_to :cart
end
同时我们还需要在 Cart
和 Product
处进一步完善这种关系
class Cart < ApplicationRecord
has_many :line_items, dependent: :destroy
end
其中的 dependent: :destroy
表示当 Cart
销毁的时候,其中的 LineItem
都会被销毁。
# 与 line_item 关系
has_many :line_items
before_destroy :ensure_not_referenced_by_any_line_item
private
def ensure_not_referenced_by_any_line_item
unless line_items.empty?
errors.add(:base, 'Line Items present')
throw :abort
end
end
这里说的是,在 Product
被销毁前,必须执行 ensure_not_referenced_by_any_line_item
这个方法,这个方法检测如果没有商品关联,才可以删除,否则报错。
我们可以对这个功能进行测试,有
test "can't delete product in cart" do
assert_difference('Product.count', 0) do
delete product_url(products(:two))
end
end
2.3 会话
出于一些原因,我们需要在会话中保存 cart_id
,用户每添加一个商品,我们需要从会话中把 cart_id
取出来,然后通过标识符在数据库中查找购物车。
我们在 app/controllers/concerns/current_cart.rb
中写入如下代码
module CurrentCart
private
# 用 session 中的 cart_id 去查找 cart,如果没有找到,就创建一个新的 cart,并在 session 中存储 cart_id
def set_cart
@cart = Cart.find(session[:cart_id])
rescue ActiveRecord::RecordNotFound
@cart = Cart.create
session[:cart_id] = @cart.id
end
end
app/controllers/concerns/
这个文件夹中一般来说是一些独立的逻辑模块或者是重复使用的功能模块,这样可以提升代码的可读性以及维护性。
2.4 “加入购物车”
我们可以将某个 Product
加入某个 Cart
,其本质是利用 Product
产生一个 LineItem
。
所以需要现在商品目录加上这个按钮,最终达到调用 LineItem#create
的目的。
<div class="price_line">
<!-- 显示价格 -->
<span class="price"><%= number_to_currency(product.price) %></span>
<!-- 加入购物车 -->
<%= button_to 'Add to Cart', line_items_path(product_id: product) %>
</div>
其中
line_items_path(product_id: product)
就是调用 LineItem
创建方法 create
的意思,同时给它传参 product_id
然后我们来完善 LineItem
的 create
方法。
首先在 LintItemController.rb
中引入 CurrentCart
模块,并且在每次的 create
方法前都调用 :set_cart
方法
class LineItemsController < ApplicationController
include CurrentCart
before_action :set_cart, only: [:create]
因为在 :set_cart
中会对 @cart
赋值,让其为当前对话 session
独有的 cart
,所以最终的效果就是 LineItem
创建前就有一个 @cart
属性了。
然后修改 create
方法
# POST /line_items or /line_items.json
def create
# 根据传入的 product_id 查找 product
product = Product.find(params[:product_id])
# build 方法与 new 方法类似,会创建一个与 @cart 和 product 都相关的 @line_item
@line_item = @cart.line_items.build(product: product)
respond_to do |format|
if @line_item.save
# 跳转的对象不再是 @line_item,而是它的购物车
format.html { redirect_to @line_item.cart, notice: "Line item was successfully created." }
format.json { render :show, status: :created, location: @line_item }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @line_item.errors, status: :unprocessable_entity }
end
end
end
最后修改 cart
的 show
页面,让其可以显示里面的 LineItem。
<p id="notice"><%= notice %></p>
<h2>Your Pragmatic Cart</h2>
<ul>
<% @cart.line_items.each do |item| %>
<li><%= item.product.title %></li>
<% end %>
</ul>
2.5 加入数量
对于一个商品来说,之前的设计是有问题的,比如说我们买了两个香皂,那么不应该是“香皂,香皂”的显示两遍,而是应该“2 x 香皂”这样的显示,所以对于 LineItem
来说,数量是极其必要的。
所以创建迁移
rails generate migration add_quantity_to_line_items quantity:integer
但是还需要接着修改这个迁移,因为要设置其默认值为 1
class AddQuantityToLineItems < ActiveRecord::Migration[7.0]
def change
# 默认值是 1
add_column :line_items, :quantity, :integer, default: 1
end
end
然后需要修改 add_cart
的行为,并不是每次“加入购物车”,都是会产生一个新的 LineItem
的。首先在 app/models/cart.rb
中加入方法
def add_product(product)
# 根据 product_id 查找 current_item
current_item = line_items.find_by(product: product.id)
if current_item
# 查找到了,就数量增加 1
current_item.quantity += 1
else
# 没查找到,就创建 line_item
current_item = line_items.build(product_id: product.id)
end
# 返回 current_item
current_item
end
然后修改 line_item#create
@line_item = @cart.add_product(product)
同时为了让已有的 LineItem
数据依然显示正确,需要创建一个迁移进行修改
rails generate migration combine_items_in_cart
这个迁移没法按照“约定”自动产生 change
,所以需要自己手写 up, down
(这两个方法似乎也是某种约定)
class CombineItemsInCart < ActiveRecord::Migration[7.0]
def up
Cart.all.each do |cart|
# 把购物车中同一个产品的多个商品替换为单个商品
sums = cart.line_items.group(:product_id).sum(:quantity)
sums.each do |product_id, quantity|
if quantity > 1
# 删除同一个产品的多个商品
cart.line_items.where(product_id: product_id).delete_all
# 替换为单个商品
item = cart.line_items.build(product_id: product_id)
item.quantity = quantity
item.save!
end
end
end
end
def down
LineItem.where("quantity>1").each do |line_item|
line_item.quantity.times do
LineItem.create(
cart_id: line_item.cart_id,
product_id: line_item.product_id,
quantity: 1
)
end
line_item.destroy
end
end
end
2.6 清空购物车
清空购物车的本质是将当前的购物车删除,所以先加入“清空按钮”在 show.html
中
<p id="notice"><%= notice %></p>
<h2>Your Cart</h2>
<!-- 购物清单 -->
<table>
<% @cart.line_items.each do |item| %>
<tr>
<td><%= item.quantity %> × </td>
<td><%= item.product.title %></td>
<td class="item_price"><%= number_to_currency(item.total_price) %></td>
</tr>
<% end %>
<!-- 总金额 -->
<tr class="total_line">
<td colspan="2">Total</td>
<td class="total_cell"><%= number_to_currency(@cart.total_price) %></td>
</tr>
</table>
<!-- 清空购物车 -->
<%= button_to 'Empty Cart', @cart, method: :delete, data: {confirm: 'Are you sure?'} %>
并且补充相应的方法即可。
2.7 局部渲染
我们希望在侧边栏也有购物车信息,所以我们考虑利用局部渲染。具体的知识在前面有,所以按照递归的思路,我们需要在 application.html.erb
中加入购物车
<div id="cart">
<%= render @cart %>
</div>
这个东西会去渲染 _cart.html.erb
文件,所以需要将它的内容改得和 carts/show.html.erb
一样
<h2>Your Cart</h2>
<!-- 购物清单 -->
<table>
<%= render(cart.line_items) %>
<!-- 总金额 -->
<tr class="total_line">
<td colspan="2">Total</td>
<td class="total_cell"><%= number_to_currency(cart.total_price) %></td>
</tr>
</table>
<!-- 清空购物车 -->
<%= button_to 'Empty Cart', @cart, method: :delete, data: {confirm: 'Are you sure?'} %>
这里面有一个
<%= render(cart.line_items) %>
这是因为对于 line_items
的渲染,在 cart_html.erb
中也出现了。这种局部渲染是一种集合渲染,所以渲染的模板在 _line_item.html.erb
中,修改如下
<tr>
<td><%= line_item.quantity %> × </td>
<td><%= line_item.product.title %></td>
<td class="item_price"><%= number_to_currency(line_item.total_price) %></td>
</tr>
最终意识到其实没有必要对 carts/show.html.erb
重复内容,所以将其改为
<p id="notice"><%= notice %></p>
<%= render @cart %>
因为在侧边栏中现在也有了 cart
,所以同样需要 set_cart
,所以对于 StoreController
中加入如下代码
class StoreController < ApplicationController
include CurrentCart
before_action :set_cart
def index
@products = Product.order(:title)
end
end
2.8 Ajax 购物车
现在每次进行 Add Cart
操作,本质都是在渲染整个 application.html.erb
页面,这无疑是低效的,所以考虑只渲染侧边栏的购物车部分。
首先需要在 Add Cart
按钮上添加 remote
参数
<%= button_to 'Add to Cart', line_items_path(product_id: product), remote:true %>
然后在 create
方法上加入神秘的 format.js
,我暂时还理解不了为啥,可以理解为启用了 js 脚本
# POST /line_items or /line_items.json
def create
# 根据传入的 product_id 查找 product
product = Product.find(params[:product_id])
# build 方法与 new 方法类似,会创建一个与 @cart 和 product 都相关的 @line_item
@line_item = @cart.add_product(product)
# respond_to 是一个方法,其参数为一个 block
# 我现在的理解是 respond_to 描述的是服务器对于客户端的反应,或者说,这是浏览器上将执行的步骤
# 这里就是先对于 html 进行一个重定向操作,然后调用 js,最后 json
respond_to do |format|
if @line_item.save
# 跳转的对象不再是 @line_item,而是 store 页面
format.html { redirect_to store_index_url }
# 调用 js 脚本
format.js
format.json { render :show, status: :created, location: @line_item }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @line_item.errors, status: :unprocessable_entity }
end
end
end
然后就会调用 views/line_items/create.js.erb
这个文件,将这个文件写入以下内容
$('#cart').html("<%= j render(@cart) %>")
这个脚本描述了将 id = cart
的节点替换成 render(@cart)
的操作。
2.9 突出显示
为了增强美工性,考虑引入 jQuery-ui
。
jQuery
是 JavaScript
的一个好用的库,里面有常见的 html 操作和一组简单的 UI,我们需要更改 Gemfile
来安装它
# Use jquery as the JavaScript library
gem 'jquery-rails'
gem 'jquery-ui-rails'
然后执行
bundle install
然后新建 app/assets/javascripts/application.js
内容为
//= require jquery
//= require jquery_ujs
//= require jquery-ui/effect.all
//= require_tree .
至于为啥是这个,并不知道为啥。这样之后我们就可以使用 jquery-ui
了。
然后考虑如何对“刚刚点击过”的 LineItem
做一个突出显示,可以考虑在 create
中维护一个 @current_item
format.js { @current_item = @line_item }
然后在 _line_item.html.erb
中,将 current
标出来
<% if line_item == @current_item %>
<tr id="current_item">
<% else %>
<tr>
<% end %>
<td><%= line_item.quantity %> ×</td>
<td><%= line_item.product.title %></td>
<td class="item_price"><%= number_to_currency(line_item.total_price) %></td>
</tr>
然后在 create.js.erb
中进行渲染
$('#current_item').css({'background-color': '#88ff88'}).
animate({'background-color': '#114411'}, 1000)
2.10 辅助方法
我们希望可以在购物车内商品数量为 0 的时候,不显示购物车。
可以利用购物车长度作为判断,这里我们用到了辅助方法(主要是为了让代码更加整洁)
<!-- 购物车 -->
<div id="cart">
<% if @cart %>
<%= hidden_div_if(@cart.line_items.empty?, id: 'cart') do %>
<%= render @cart %>
<% end %>
<% end %>
</div>
辅助方法为在 app/helpers
下的方法,脚手架会自动帮我们建好这些文件。
module ApplicationHelper
def hidden_div_if(condition, attributes={}, &block)
# 将 html 中具有 attributes 并满足 condition 条件的 div 设置为 display: none
if condition
attributes["style"] = "display: none"
end
content_tag("div", attributes, &block)
end
end
同时我们需要修改 js 文件,使得购物车显示的时候比较平滑
if ($('#cart tr').length === 1) { $('cart').show('blind', 1000) }
3 订单 Order
3.1 Order 模型
订单模型本质上信息全都是收货的信息,订单具体有什么商品,其实并不是由 Order 决定的,而是由 LineItem 决定的。因此建立如下如下模型
rails generate scaffold Order name address:text email phone pay_type:integer
同时给 LineItem
添加外键
rails generate migration add_order_to_line_item order:references
最后融合迁移
rails db:migrate
然后就会发现融合不了,这是因为对于 LineItem,他有三个外键,分别是 Product, Cart, Order
,在迁移中自动生成的外键,都是不允许为空的,而现在,对于一个 LineItem
,它要么在 Cart
中,要么在 Order
中,所以总会有一个外键为空,所以需要修改多处地方。
首先要修改 LineItem
的两个 migration
# 20230101081559_create_line_items.rb
class CreateLineItems < ActiveRecord::Migration[7.0]
def change
create_table :line_items do |t|
t.references :product, null: false, foreign_key: true
t.belongs_to :cart, null: true, foreign_key: true
t.timestamps
end
end
end
# 20230102121647_add_order_to_line_item.rb
class AddOrderToLineItem < ActiveRecord::Migration[7.0]
def change
add_reference :line_items, :order, null: true , foreign_key: true
end
end
然后还要在 model
中进行数据关系的定义
class LineItem < ApplicationRecord
belongs_to :product
# optional 表示外键可以为空
belongs_to :cart, optional: true
belongs_to :order, optional: true
def total_price
product.price * quantity
end
end
class Order < ApplicationRecord
enum pay_type: {
"Check" => 0,
"Credit card" => 1,
"Purchase order" => 2
}
has_many :line_items, dependent: :destroy
end
3.2 生成订单
生成订单的过程是一个将购物车中所有的 LineItem
都放到 Order
中的一个过程,我们可以用一个方法描述这个过程,定义在 model
中。
class Order < ApplicationRecord
enum pay_type: {
"Check" => 0,
"Credit card" => 1,
"Purchase order" => 2
}
has_many :line_items, dependent: :destroy
validates :name, :address, :email, :phone, presence: true
validates :pay_type, inclusion: pay_types.keys
def add_line_items_from_cart(cart)
cart.line_items.each do |item|
item.cart_id = nil
line_items << item
end
end
end
可以看到还新增了一些字段的验证约束,然后考虑生成表格,首先在 _cart.html.erb
加上生成 Order
的按钮
<!-- 结算购物车 -->
<%= button_to 'Checkout', new_order_path, method: :get %>
这个方法会调用 new
方法,所以我们需要写一下 new.html.erb
这个模板
<div class="project_form">
<fieldset>
<h2>Please Enter Your Details</h2>
<%= render 'form', order: @order %>
</fieldset>
</div>
还有与之相关的 _form.html.erb
<%= form_with(model: order) do |form| %>
<% if order.errors.any? %>
<div style="color: red">
<h2><%= pluralize(order.errors.count, "error") %> prohibited this order from being saved:</h2>
<ul>
<% order.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :name, style: "display: block" %>
<%= form.text_field :name, size: 40 %>
</div>
<div>
<%= form.label :address, style: "display: block" %>
<%= form.text_area :address, rows: 3, cols: 37 %>
</div>
<div>
<%= form.label :email, style: "display: block" %>
<%= form.text_field :email, size: 40 %>
</div>
<div>
<%= form.label :phone, style: "display: block" %>
<%= form.text_field :phone, size: 40 %>
</div>
<div>
<%= form.label :pay_type, style: "display: block" %>
<%= form.select :pay_type, Order.pay_types.keys, prompt: 'Select a payment method' %>
</div>
<div class="actions">
<%= form.submit 'Place Order'%>
</div>
<% end %>
并且美化样式
.project_form {
fieldset {
background: #efe;
h2 {
color: #dfd;
background: #141;
font-family: sans-serif;
padding: 0.2em 1em;
}
div {
margin-bottom: 0.3em;
}
}
form {
label {
width: 5em;
float: left;
text-align: right;
padding-top: 0.2em;
margin-right: 0.1em;
display: block;
}
select, textarea, input {
margin-left: 0.5em;
}
.submit {
margin-left: 4em;
}
br {
display: none;
}
}
}
在 Order#create
的过程中,需要清空购物车,所以需要修改一下 create
方法
# POST /orders or /orders.json
def create
@order = Order.new(order_params)
@order.add_line_items_from_cart(@cart)
respond_to do |format|
if @order.save
# 清空购物车
Cart.destroy(session[:cart_id])
session[:cart_id] = nil
format.html { redirect_to store_index_url(@order), notice: "Thank you for your order." }
format.json { render :show, status: :created, location: @order }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @order.errors, status: :unprocessable_entity }
end
end
end
3.3 订单展示
作为管理端,需要看到所有的订单,所以考虑修改 index.html.erb
这个模板,将其改成表格形式会更漂亮一些,同时加上一些操作和跳转。
<%= form_with(model: order) do |form| %>
<% if order.errors.any? %>
<div style="color: red">
<h2><%= pluralize(order.errors.count, "error") %> prohibited this order from being saved:</h2>
<ul>
<% order.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :name, style: "display: block" %>
<%= form.text_field :name, size: 40 %>
</div>
<div>
<%= form.label :address, style: "display: block" %>
<%= form.text_area :address, rows: 3, cols: 37 %>
</div>
<div>
<%= form.label :email, style: "display: block" %>
<%= form.text_field :email, size: 40 %>
</div>
<div>
<%= form.label :phone, style: "display: block" %>
<%= form.text_field :phone, size: 40 %>
</div>
<div>
<%= form.label :pay_type, style: "display: block" %>
<%= form.select :pay_type, Order.pay_types.keys, prompt: 'Select a payment method' %>
</div>
<div class="actions">
<%= form.submit 'Place Order'%>
</div>
<% end %>
对于订单的详情展示,可以仿照 _cart.html.erb
书写
<table>
<%= render(order.line_items) %>
<!-- 总金额 -->
<tr class="total_line">
<td colspan="2">Total</td>
<td class="total_cell"><%= number_to_currency(order.total_price) %></td>
</tr>
</table>
4 用户 User
4.1 User 模型
考虑用户具有用户名,密码,角色三个属性,模型如下
rails generate scaffold User name:string password:digest role:integer
rails db:migrate
对于密码部分,可以借助插件完成“确认密码的功能”
class User < ApplicationRecord
has_secure_password
end
同时在 gemfile
中填入这个插件
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
gem "bcrypt", "~> 3.1.7"
然后使用
bundle install
同时还有关于 role
的设计,目前有两个角色,一个是 Buyer
,一个是 Admin
,其中 Admin
的权限更高。
class User < ApplicationRecord
enum role: {
"Buyer" => 0,
"Admin" => 1
}
validates :name, presence: true, uniqueness: true
validates :role, presence: true
validates :role, inclusion: roles.keys
has_secure_password
end
注意如果希望 role
作为一个枚举变量,那么这里一定要定义 enum
的名字为 role
,不能叫 role_type
或者其他任何的名字,都不会让其具有枚举的效果,这大概就是神秘的 rails 吧。
4.2 控制器与页面
身份验证就是登录相关的功能,这里需要新建两个控制器,sessions
用于为登录和登出提供支持,admin
用于为管理员提供欢迎界面。
其中 sessions
只有两个动作,new, create
对应登入,destroy
对应登出
rails generate controller Sessions new create destroy
Admin
只有一个动作 index
,代表欢迎界面。
rails generate controller Admin index
这也启发我,其实写一个页面就是写一个 controller 和一个 view 而已,这是因为 view 似乎没有办法单独成为一个路由资源。
4.3 登录登出
登入功能就是填一个表单,所以在 new.html.erb
中写入
<div class="project_form">
<% if flash[:alert] %>
<p id='notice'><%= flash[:alert] %></p>
<% end %>
<%= form_tag do %>
<fieldset>
<legend>Please Log In</legend>
<div>
<%= label_tag :name, 'Name:' %>
<%=text_field_tag :name, params[:name] %>
</div>
<div>
<%= label_tag :password, 'Password:' %>
<%= password_field_tag :password, params[:password] %>
</div>
<div>
<%= submit_tag 'Login' %>
</div>
</fieldset>
<% end %> %>
</div>
可以看到,因为 Sessions
并没有与模型关联,所以我们并没有用 form_for
,只是一个普通的 form_tag
。收集的信息进入了 params
。
然后我们在 create
中利用 params
中的信息保存到 session
中
def create
# 这里更加明显,create 会有一个 params,params 的来历就是 new 填的表单
user = User.find_by(name: params[:name])
# try 对于为 nil 的 user,会直接进入 else
if user.try(:authenticate, params[:password])
session[:user_id] = user.id
session[:user_role] = user.role
# 如果是 Buyer ,就定向到商店,否则定向到 admin 的欢迎界面
if user.role == "Buyer"
redirect_to store_index_url
else
redirect_to admin_url
end
else
redirect_to login_url, alert: "Invalid user/password combination"
end
end
同时完成相应界面的跳转,我们在 session
中保存了用户的 id
和权限信息。
退出登录的状态就很简单,就是将 session 中的信息注销掉即可
def destroy
session[:user_id] = nil
session[:user_role] = nil
redirect_to store_index_url, notice: "Logged out"
end
对于管理界面,可以自己设计一个
<h1>Welcome</h1>
<div>
It's <%= Time.now %>
We hava <%= pluralize(@total_orders, 'order') %>
</div>
最后需要修改路由
get 'admin' => 'admin#index'
controller :sessions do
get 'login' => :new
post 'login' => :create
delete 'logout' => :destroy
end
4.4 访问限制
访问限制可以在 application_controller.rb
中利用 before_action
实现
class ApplicationController < ActionController::Base
before_action :authorize
protected
def authorize
unless User.find_by(id: session[:user_id])
redirect_to login_url, notice: "Pleas log in."
end
end
end
然后在各个控制器,选择是否跳过 authorize
即可
class SessionsController < ApplicationController
skip_before_action :authorize
这时会发现所有的 test 基本上都瘫痪了,这是因为 test 不会自动登录,所有在 test/test_helper.rb
中写入如下代码即可
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
class ActionDispatch::IntegrationTest
def login_as(user)
post login_url, params: {name: user.name, password: 'secret'}
end
def logout
delete logout_url
end
def setup
login_as(users(:one))
end
end
class ActiveSupport::TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
# Add more helper methods to be used by all tests here...
end
4.5 权限显示
我们希望对于管理者,可以看到更多的界面,而对于非管理者,则不需要看到这些界面
<!-- 管理链接 -->
<% if current_user and current_user.role == 'Admin' %>
<ul>
<li><%= link_to 'Orders', orders_path %></li>
<li><%= link_to 'Products', products_path %></li>
<li><%= link_to 'Users', users_path %></li>
</ul>
<% end %>
<!-- 登录登出 -->
<div class="log">
<% if current_user %>
<%= current_user.name %> Logged in.<br/>
<%= link_to 'Logout', logout_path, method: :delete %>
<% else %>
Please <%= link_to 'Login', login_url %>
<% end %>
</div>
这些需要借助 current_user
这个方法,这个方法定义在 application_controller.rb
中
helper_method :current_user
def current_user
@current_user ||= User.find(session[:user_id]) if session[:user_id]
end
5 收藏夹 Favourite
5.1 Favourite 模型
每个用户都有一个收藏夹,二者是一对一关系,所以没有必要单独做一个实体,可以直接使用 User
模型。但是在实际思考的时候,却应当有收藏夹这个模型,比较方便思考。
5.2 FavorItem 收藏品模型
收藏品模型描述的是 Product
和 Favourite
之间的“多对多关系”,所以需要这样建立模型
rails generate scaffold FavorItem product:references user:belongs_to
rake db:migrate
对于 User
模型,补充如下代码,也就是当 User 持有一个 favor_items
集合,同时当 User
销毁的时候,会销毁所有的 favor_items
。
has_many :favor_items, dependent: :destroy
对于 Product
模型,补充如下代码,同样 Product
持有一个 favor_item
集合,同时在销毁前要检验是否可以销毁。
has_many :favor_items
before_destroy :ensure_not_referenced_by_any_favor_item
def ensure_not_referenced_by_any_favor_item
unless favor_items.empty?
errors.add(:base, 'Line Items present')
throw :abort
end
end
5.3 加入收藏夹
在 store
界面上,除了有“加入购物车”之外,应当有“加入收藏夹”的功能,可以如此修改 store
界面
<!-- 加入收藏夹 -->
<%= button_to 'Add to Favourite', favor_items_path(product_id: product) %>
可以看到需要修改 favor_item
的 create
方法
def create
# 根据传入的 product_id 查找 product
product = Product.find(params[:product_id])
@favor_item = @user.add_product(product)
respond_to do |format|
if @favor_item.save
format.html { redirect_to store_index_url }
format.json { render :show, status: :created, location: @favor_item }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @favor_item.errors, status: :unprocessable_entity }
end
end
end
可以看到需要获得一个收藏夹的 @user
,与 @cart
类似,所以需要一个 set_user
的过程
def set_user
@user = User.find(session[:user_id])
rescue ActiveRecord::RecordNotFound
redirect_to store_index_url
end
5.4 收藏夹展示
类似于一个产品目录的子集,可以写 index.html.erb
<p style="color: green"><%= notice %></p>
<h1>Favourite Collections</h1>
<div id="favor_items">
<table>
<% @favor_items.each do |favor_item| %>
<%= render favor_item %>
<% end %>
</table>
</div>
可以看到除了外面套了一个表之外,主体是对于每个 favor_item
的渲染,所以需要修改 _favor_item.html.erb
<tr class="<%= cycle('list_line_odd', 'list_line_even') %>">
<td>
<%= image_tag(favor_item.product.image_url, class: 'list_image') %>
</td>
<td class="list_description">
<dl>
<dt><%= favor_item.product.title %></dt>
<dd><%= strip_tags(favor_item.product.description) %></dd>
</dl>
</td>
<td>
<dl>
<%= number_to_currency(favor_item.product.price) %>
</dl>
<dl>
<%= button_to 'Add to Cart', line_items_path(product_id: favor_item.product), remote:true %>
</dl>
<dl>
<%= button_to 'Remove', favor_item, :method => :delete %>
</dl>
</td>
</tr>
6 促销活动 Activity
6.1 Activity 模型
促销活动只有一个属性就是名字,具体的促销也在这里体现体现,所以应当在终端中输入如下示例
rails generate scaffold Activity name:string disconut:integer
rake db:migrate
6.2 Prompt 促销项模型
促销项都是外键,用于关联 Product
产品和 Activity
活动,形成“活动-产品” 的多对多关系。
rails generate scaffold Prompt product:references activity:belongs_to
rake db:migrate
同时完善 Product
模型
has_many :prompts, dependent: :destroy
和 Activity
模型
has_many :prompts, dependent: :destroy
6.3 新建活动
一个活动由本身和它包括的商品组成,在创建的时候,可以先创建好活动,确定活动的名称和折扣力度,然后再向这个活动添加涉及的商品。创建活动这个操作只有管理员可以干,所以我们用一个管理链接指向 activities
的展示页面
<li><%= link_to 'Prompt', activities_path %></li>
然后对于这个页面,我们只是展示其名称和折扣
<p style="color: green"><%= notice %></p>
<h1>Activities</h1>
<div id="activities">
<% @activities.each do |activity| %>
<span>
Name:
<%= activity.name %>
</span>
<spac>
Discount:
<%= number_to_percentage(activity.discount, precision: 0) %>
</spac>
<p>
<%= link_to "Show this activity", activity %>
</p>
<% end %>
</div>
<%= link_to "New activity", new_activity_path %>
在活动中添加商品的操作,可以考虑在具体的活动界面进行添加,也就是 _activity.html.erb
中添加
<div id="<%= dom_id activity %>">
<!-- 活动的基本信息 -->
<div>
<strong>Name:</strong>
<%= activity.name %>
</div>
<div>
<strong>Discount:</strong>
<%= number_to_percentage(activity.discount, precision: 0) %>
</div>
<!-- 这里展示了产品列表,可以将产品添加到活动中 -->
<% Product.all.each do |product| %>
<div class="entry">
<h3><%= product.title %></h3>
<div class="price_line">
<!-- 加入活动 -->
<%= button_to 'Add to Activity', prompts_path(product_id: product, activity_id: activity)%>
</div>
</div>
<% end %>
<!-- 已经加入活动的产品 -->
<% activity.prompts.each do |prompt| %>
<div>
<%= prompt.product.title %>
</div>
<% end %>
</div>
对于创建一个 prompt
,与以往不同,需要传入两个参数,也就是 prompt
的两端 product
和 activity
,也就是这样
<!-- 加入活动 -->
<%= button_to 'Add to Activity', prompts_path(product_id: product, activity_id: activity)%>
所以需要修改 prompt
的 create
方法
# POST /prompts or /prompts.json
def create
# 根据传入的 product_id 查找 product
product = Product.find(params[:product_id])
activity = Activity.find(params[:activity_id])
@prompt = activity.add_product(product)
respond_to do |format|
if @prompt.save
format.html { redirect_to activity_url(activity), notice: "Prompt was successfully created." }
format.json { render :show, status: :created, location: @prompt }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @prompt.errors, status: :unprocessable_entity }
end
end
end
可以看到进行了两次查找,最终利用两次查找的信息建立了 prompt
。和 favor_item
的 add_product
一致,对于加入活动的产品,只能加入一次
def add_product(product)
# 根据 product_id 查找 current_item
current_item = prompts.find_by(product: product.id)
if current_item
# 查找到了,就啥都不干
else
# 没查找到,就创建 prompt
current_item = prompts.build(product_id: product.id)
end
# 返回 current_item
current_item
end
6.4 实现折扣
促销大概需要两个两个机制
- 在
store
界面上展示折扣 - 在加入购物车或者订单的时候实际发生折扣
展示折扣这个功能很好实现,只需要在 store
界面中利用 product
检索 prompt
进而检索 activity.discount
即可。
<!-- 显示折扣 -->
<% product.prompts.each do |prompt| %>
<span> × <%= number_to_percentage(prompt.activity.discount, precision: 0) %></span>
<% end %>
注意这里用到了 number_to_percentage
方法,可以将普通整数转换成百分制(除以 100 加百分号)。
发生实际折扣,需要更改 price
的计算方式,原来对于价格的计算,是 Cart
或者 Order
对于其所有的 item
的 price
进行求和,line_item
的价格计算,是 Product.price
和 quantity
的乘积,如下所示
def total_price
product.price * quantity
end
可见,只要修改 price
即可,所以在 product
中新写一个方法去获得折扣价格
def getDiscountPrice
discount = price
prompts.each do |prompt|
discount *= (prompt.activity.discount / 100.0)
end
discount
end
然后修改 total_price
为
def total_price
product.getDiscountPrice * quantity
end
即可。
6.5 展示折扣
对于普通用户来说,没有权限建立活动,但是有权限浏览活动,所以可以实现一个活动界面用于浏览,但是考虑到 index.html.erb
已经用于给管理者新建活动使用了,所以考虑新开设一个界面,在控制器中新定义一个方法
def show_activity
@show_activities = Activity.all
end
然后建立对应的 show_activity.html.erb
这个页面,其内容如下
<div class="show_activities">
<h1>促销活动</h1>
<% @show_activities.each do |activity| %>
<div class="entry">
<div class="name">
<%= activity.name %>
</div>
<div class="discount">
折扣力度:
<%= number_to_percentage(activity.discount, precision: 0) %>
</div>
<div class="items">
促销产品:
<% activity.prompts.each do |prompt| %>
<span class="product"><%= prompt.product.title %>,</span>
<% end %>
</div>
</div>
<% end %>
</div>
其后完善链接即可。