18. 資料篩選和搜尋
18-1 製作後台管理報名資料
接下來製作報名資料的後台管理頁面。
編輯 config/routes.rb
,增加後台用的 registrations 路由:
namespace :admin do
root "events#index"
resources :events do
+ resources :registrations, :controller => "event_registrations"
修改 app/views/admin/events/index.html.erb
,加上一個連結可以前往 registration index 頁面。
+ <%= link_to "Registrations", admin_event_registrations_path(event), :class => "btn btn-default" %>
<%= link_to "Tickets", admin_event_tickets_path(event), :class => "btn btn-default" %>
執行 rails g controller admin::event_registrations
編輯 app/controllers/admin/event_registrations_controller.rb
,新增 index 和 destroy action
class Admin::EventRegistrationsController < AdminController
+ before_action :find_event
+
+ def index
+ @registrations = @event.registrations.includes(:ticket).order("id DESC")
+ end
+
+ def destroy
+ @registration = @event.registrations.find_by_uuid(params[:id])
+ @registration.destroy
+
+ redirect_to admin_event_registrations_path(@event)
+ end
+
+ protected
+
+ def find_event
+ @event = Event.find_by_friendly_id!(params[:event_id])
+ end
+
+ def registration_params
+ params.require(:registration).permit(:status, :ticket_id, :name, :email, :cellphone, :website, :bio)
+ end
end
新增 app/views/admin/event_registrations/index.html.erb
<h1><%= @event.name %> / Registrations</h1>
<p class="text-right">
<%= link_to "New Registration", new_admin_event_registration_path(@event),
:class => "btn btn-primary" %>
</p>
<table class="table">
<tr>
<th>ID</th>
<th>Ticket</th>
<th>Name</th>
<th>Status</th>
<th>E-mail</th>
<th>建立時間</th>
<th>Actions</th>
</tr>
<% @registrations.each do |registration| %>
<tr>
<td><%= registration.id %></td>
<td><%= registration.ticket.name %></td>
<td><%= registration.name %></td>
<td><%= t( registration.status, :scope => "registration.status") %></td>
<td><%= registration.email %></td>
<td><%= registration.created_at %></td>
<td>
<%= link_to "Edit", edit_admin_event_registration_path(@event,
registration), :class => "btn btn-default" %> <%= link_to "Delete",
admin_event_registration_path(@event, registration), :method => "delete",
:data => { :confirm => "Are you sure?" }, :class => "btn btn-danger" %>
</td>
</tr>
<% end %>
</table>
這裡就不練習做新增和編輯了,你應該做了 100 遍了。
沒有資料不好玩,讓我們新增一些假資料。請編輯 lib/tasks/dev.rake
新增一個任務
namespace :dev do
+ task :fake_event_and_registrations => :environment do
+ event = Event.create!( :status => "public", :name => "Fullstack Meetup", :friendly_id => "fullstack-meetup")
+ t1 = event.tickets.create!( :name => "Guest", :price => 0)
+ t2 = event.tickets.create!( :name => "VIP 第一期", :price => 199)
+ t3 = event.tickets.create!( :name => "VIP 第二期", :price => 199)
+
+ 1000.times do |i|
+ event.registrations.create!( :status => ["pending", "confirmed"].sample,
+ :ticket => [t1,t2,t3].sample,
+ :name => Faker::Creature::Cat.name,
+ :email => Faker::Internet.email,
+ :cellphone => "12345678", :bio => Faker::Lorem.paragraph,
+ :created_at => Time.now - rand(10).days - rand(24).hours )
+ end
+
+ puts "Let's visit http://localhost:3000/admin/events/fullstack-meetup/registrations"
+ end
執行 rake dev:fake_event_and_registrations
,就會新建一個活動,會有三種票和隨機產生 1000 位報名人。
瀏覽 http://localhost:3000/admin/events/fullstack-meetup/registrations
18-2 分頁機制
回想看看為什麽要做分頁呢?讓我們想想看:
- 資料庫的資料會存在硬盤上,當我們用 Ruby 程序從資料庫讀出來時,就會放在記憶體,記憶體的讀取速度比硬盤快非常非常多。所有正在執行的程序,都會放到記憶體。記憶體是有限的,例如大家的 Macbook 可能有 8G,但是硬盤空間很大,例如 (SSD)可能有 256G,傳統(磁盤式)硬盤的話現在已經發展到 1T、2T 以上,如果把硬盤的數據都讀出來放到記憶體上,不但放不下也很浪費。
- 用戶下載所有資料需要傳輸時間,瀏覽器加載野需要時間,如果資料很多,就會花很久時間才能看到畫面。
因此,如果資料量多達數萬筆時,就一定要製作分頁的功能,避免調用 .all
方法。
我們已經教過用 will_paginate gem 來做分頁功能,這裡示範用另一套也是非常熱門的分頁套件 kaminari,兩者大同小異。可依照個人公司偏好選擇。
編輯 Gemfile
+ gem 'kaminari'
執行 bundle
,重啟伺服器。
執行 rails g kaminari:views bootstrap3
,這會產生搭配 Bootstrap 樣式的樣板
編輯 app/controllers/admin/event_registrations_controller.rb
def index
- @registrations = @event.registrations.includes(:ticket).order("id DESC")
+ @registrations = @event.registrations.includes(:ticket).order("id DESC").page(params[:page])
end
要指定一頁有多少筆的話,使用
per
方法,例如 ` @event.registrations.includes(:ticket).order(“id DESC”).page(params[:page]).per(10)`
編輯 app/views/admin/event_registrations/index.html.erb
,在最下方放上分頁的超連結。
</table>
+ <%= paginate @registrations %>
最後結果:
18-3 篩選資料(單選)
需求:可以點選狀態或票種(單選)來篩選出報名人資料
編輯 app/views/admin/event_registrations/index.html.erb
,加上兩組 Bootstrap 樣式的按鈕:
<h1><%= @event.name %> / Registrations</h1>
<p class="text-right">
<%= link_to "New Registration", new_admin_event_registration_path(@event), :class => "btn btn-primary" %>
</p>
+ <div class="submenu">
+ <div class="btn-group">
+ <%= link_to "全部 (#{@event.registrations.size})", admin_event_registrations_path(registration_filters(:status => nil)), :class => "btn btn-success btn-group #{(params[:status].blank?) ? "active" : ""}" %>
+ <% Registration::STATUS.each do |s| %>
+ <%= link_to t(s, :scope => "registration.status") + " (#{@event.registrations.by_status(s).size})", admin_event_registrations_path(registration_filters(:status => s)), :class => "btn btn-success btn-group #{( params[:status] == s) ? "active" : ""}" %>
+ <% end %>
+ </div>
+
+ <div class="btn-group">
+ <%= link_to "全部 (#{@event.registrations.size})", admin_event_registrations_path(registration_filters(:ticket_id => nil)), :class => "btn btn-default btn-group #{(params[:ticket_id].blank?) ? "active" : ""}" %>
+ <% @event.tickets.each do |t| %>
+ <%= link_to t.name + " (#{@event.registrations.by_ticket(t).size})", admin_event_registrations_path( registration_filters(:ticket_id => t.id)), :class => "btn btn-default btn-group #{(params[:ticket_id].to_i == t.id) ? "active" : ""}" %>
+ <% end %>
+ </div>
+ </div>
修改 app/helpers/admin/event_registrations_helper.rb
,新增 registration_filters
方法
module Admin::EventRegistrationsHelper
+
+ def registration_filters(options)
+ params.permit(:status, :ticket_id).merge(options)
+ end
+
end
這個 registration_filters
方法的目的,在於建構按鈕超連結的參數。當點了狀態再點票種,或是點了票種再點狀態時,要同時套用兩個參數。
修改 app/models/registration.rb
加上兩個 scope
+ scope :by_status, ->(s){ where( :status => s ) }
+ scope :by_ticket, ->(t){ where( :ticket_id => t ) }
修改 app/controllers/admin/event_registrations_controller.rb
,如果有傳參數進來,則進行篩選:
def index
@registrations = @event.registrations.includes(:ticket).order("id DESC").page(params[:page])
+ if params[:status].present? && Registration::STATUS.include?(params[:status])
+ @registrations = @registrations.by_status(params[:status])
+ end
+ if params[:ticket_id].present?
+ @registrations = @registrations.by_ticket(params[:ticket_id])
+ end
scope 的作用是將常用的查詢條件宣告起來,方便重復使用
修改 app/assets/stylesheets/admin.scss
,調整一下間距。
+ .submenu {
+ margin-bottom: 10px;
+ }
這樣就完成了。
18-4 篩選資料(多選)
需求:可以用核選方塊(多選)狀態和票種,來篩選出報名人資料
實務上,單選和多選的作法不太會混用,所以這裡會註解掉上一節的單選接口讓畫面清楚一點。
再次編輯 app/views/admin/event_registrations/index.html.erb
,加上核選方塊的表單,以及一個送出篩選的按鈕:
+ <% if false %>
<div class="submenu">
# 略
</div>
+ <% end %>
+ <%= form_tag admin_event_registrations_path(@event), :method => :get do %>
+
+ <p><% Registration::STATUS.each do |s| %>
+ <label><%= check_box_tag "statuses[]", s, Array(params[:statuses]).include?(s) %> <%= t(s, :scope => + "registration.status") %> (<%= @event.registrations.by_status(s).size %>)</label>
+ <% end %></p>
+
+ <p><% @event.tickets.each do |t| %>
+ <label><%= check_box_tag "ticket_ids[]", t.id, Array(params[:ticket_ids]).include?(t.id.to_s) %> <%= t.name %> (<%= + @event.registrations.where( :ticket_id => t.id ).size %>)</label>
+ <% end %></p>
+
+ <p class="text-right">
+ <%= submit_tag "送出篩選", :class => "btn btn-primary" %>
+ </p>
+ <% end %>
Protip: 樣板中如果有大範圍暫時需要註解,可以用
<% if false %>
….<% end %>
包起來。
修改 app/controllers/admin/event_registrations_controller.rb
,如果有傳參數進來,則進行篩選:
def index
@registrations = @event.registrations.includes(:ticket).order("id DESC").page(params[:page])
+ if Array(params[:statuses]).any?
+ @registrations = @registrations.by_status(params[:statuses])
+ end
+ if Array(params[:ticket_ids]).any?
+ @registrations = @registrations.by_ticket(params[:ticket_ids])
+ end
這樣就完成了。
18-5 時間區間篩選
需求:可以輸入報名日期區間來篩選出報名人資料
編輯 app/views/admin/event_registrations/index.html.erb
,加上日期輸入框:
+ <p>
+ 報名日期:<%= date_field_tag :start_on, params[:start_on] %>~<%= date_field_tag :end_on, params[:end_on] %>
+ </p>
<p class="text-right">
<%= submit_tag "送出篩選", :class => "btn btn-primary" %>
</p>
修改 app/controllers/admin/event_registrations_controller.rb
,如果有傳參數進來,則進行篩選:
def index
@registrations = @event.registrations.includes(:ticket).order("id DESC").page(params[:page])
+ if params[:start_on].present?
+ @registrations = @registrations.where( "created_at >= ?", Date.parse(params[:start_on]).beginning_of_day )
+ end
+ if params[:end_on].present?
+ @registrations = @registrations.where( "created_at <= ?", Date.parse(params[:end_on]).end_of_day )
+ end
輸入的是日期,但是資料庫中存的是 UTC 時間,因此這裡需要調用
beginning_of_day
和end_of_day
才會轉換成正確的時間。例如台北時區的 2017/4/30 這一天,對資料庫中存 UTC 時間的欄位來說,正確的區間是 2017-04-30 16:00:00 UTC 到 2017-05-01 15:59:59 UTC。
這樣就完成了。
18-6 資料比對篩選
需求:可以輸入報名編號搜尋報名人資料
編輯 app/views/admin/event_registrations/index.html.erb
,加上文字輸入框:
<%= form_tag admin_event_registrations_path(@event), :method => :get do %>
+ <p><%= text_field_tag :registration_id, params[:registration_id], :placeholder => "報名編號,可用,號區隔", :class => "form-control" %></p>
修改 app/controllers/admin/event_registrations_controller.rb
,如果有傳參數進來,則進行篩選:
def index
@registrations = @event.registrations.includes(:ticket).order("id DESC").page(params[:page])
+ if params[:registration_id].present?
+ @registrations = @registrations.where( :id => params[:registration_id].split(",") )
+ end
這樣又完成了。
18-7 關鍵字搜尋,使用 Ransack
需求:可以輸入姓名或 Email,模糊搜尋報名人資料
上一節的資料比對,會要完全一模一樣(exact match)才會篩選出來,如果只要符合部分關鍵字就好,則需要用資料庫的 LIKE 搜尋 語法,這裡我們使用 ransack gem 來幫我們達成這個功能。
編輯 Gemfile
+ gem 'ransack'
執行 bundle
,重啟伺服器
修改 app/controllers/admin/event_registrations_controller.rb
,改成需要先調用 ransack 方法
def index
- @registrations = @event.registrations.includes(:ticket).order("id DESC").page(params[:page])
+ @q = @event.registrations.ransack(params[:q])
+
+ @registrations = @q.result.includes(:ticket).order("id DESC").page(params[:page])
編輯 app/views/admin/event_registrations/index.html.erb
,改成 ransack 提供的 search_form_for
- <%= form_tag admin_event_registrations_path(@event), :method => :get do %>
+ <%= search_form_for @q, :url => admin_event_registrations_path(@event) do |f| %>
+
+ <p><%= f.search_field :name_cont, :placeholder => "姓名", :class => "form-control" %></p>
+ <p><%= f.search_field :email_cont, :placeholder => "E-mail", :class => "form-control" %></p>
其中 search_field
是 ransack 獨到的方法,後面的 :name_cont
代表 name 這個欄位要包含(contains)的關鍵字。詳見 ransack 的文檔說明,有各種用法。
ransack 會用資料庫的 LIKE 語法來做搜尋,雖然用起來方便,但它會逐筆檢查資料是否符合,而不會使用資料庫的索引。如果數據量非常多有上萬筆以上,搜尋效能就會不滿足我們的需要。這時候會改安裝專門的全文搜尋引擎,例如 Elasticsearch,這是大數據等級的。
18-8 前臺的活動狀態篩選
需求:活動的狀態有分公開(public)、私密(private)和草稿(draft):前臺的活動列表頁應該只顯示 public 的活動、前臺的活動頁面不能顯示狀態是 draft 的活動
私密(private)狀態的意思是,在活動列表上看不到,但是如果直接分享網址的話,透過連結還是可以打開。像我們預設的活動網址是隨機的 uuid,如果不是自己分享的話,是不可能猜到的。
編輯 app/models/event.rb
,加上兩個 scope
+ scope :only_public, -> { where( :status => "public" ) }
+ scope :only_available, -> { where( :status => ["public", "private"] ) }
編輯 app/controllers/events_controller.rb
,分別套用這兩個 scope
class EventsController < ApplicationController
def index
- @events = Event.rank(:row_order).all
+ @events = Event.only_public.rank(:row_order).all
end
def show
- @event = Event.find_by_friendly_id!(params[:id])
+ @event = Event.only_available.find_by_friendly_id!(params[:id]) # 如果活動是 draft 的話,經過 only_available scope 就會找不到,這就是我們的目的
end
end
回到前臺瀏覽看看活動列表,只剩下有公開的活動了。