feat: add voting functionality for votable entities
- Create VotesController to manage voting actions - Implement Vote model with polymorphic associations - Add vote buttons to the city and weather art views - Integrate vote counting and user vote tracking - Define routes for the votes resource This commit sets up a voting mechanism for cities and weather arts, allowing users to upvote or downvote. It includes seamless integration of user sessions to track individual votes, ensuring votes can be modified or deleted based on user interaction.
This commit is contained in:
parent
d418232e7b
commit
e5c1461e8a
49
app/controllers/votes_controller.rb
Normal file
49
app/controllers/votes_controller.rb
Normal file
@ -0,0 +1,49 @@
|
||||
class VotesController < ApplicationController
|
||||
def create
|
||||
@votable = find_votable
|
||||
user_session = session.id
|
||||
|
||||
# 检查是否已经存在投票
|
||||
existing_vote = @votable.votes.find_by(user_session: user_session)
|
||||
|
||||
if existing_vote
|
||||
# 如果点击相同类型的投票,则取消
|
||||
if existing_vote.vote_type == params[:vote_type]
|
||||
existing_vote.destroy
|
||||
else
|
||||
# 否则更新投票类型
|
||||
existing_vote.update(vote_type: params[:vote_type])
|
||||
end
|
||||
else
|
||||
# 创建新投票
|
||||
@votable.votes.create!(
|
||||
user_session: user_session,
|
||||
vote_type: params[:vote_type]
|
||||
)
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"#{@votable.class.name.downcase}_votes_#{@votable.id}",
|
||||
partial: 'votes/vote_buttons',
|
||||
locals: { votable: @votable }
|
||||
)
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_votable
|
||||
if params[:weather_art_id]
|
||||
WeatherArt.find(params[:weather_art_id])
|
||||
elsif params[:city_id]
|
||||
City.find(params[:city_id])
|
||||
else
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
end
|
50
app/javascript/controllers/vote_controller.js
Normal file
50
app/javascript/controllers/vote_controller.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.setupVoteButtons()
|
||||
}
|
||||
|
||||
setupVoteButtons() {
|
||||
document.querySelectorAll('.vote-btn').forEach(button => {
|
||||
button.addEventListener('click', this.handleVote.bind(this))
|
||||
})
|
||||
}
|
||||
|
||||
async handleVote(event) {
|
||||
const button = event.currentTarget
|
||||
const votableType = button.dataset.votableType
|
||||
const votableId = button.dataset.votableId
|
||||
const action = button.dataset.action
|
||||
|
||||
try {
|
||||
const response = await fetch('/votes', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
votable_type: votableType,
|
||||
votable_id: votableId,
|
||||
vote_type: action
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
this.updateVoteCounts(data)
|
||||
} else {
|
||||
alert(data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Vote error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
updateVoteCounts(data) {
|
||||
document.getElementById('upvotes-count').textContent = data.upvotes
|
||||
document.getElementById('downvotes-count').textContent = data.downvotes
|
||||
}
|
||||
}
|
@ -71,6 +71,7 @@ class City < ApplicationRecord
|
||||
end
|
||||
}
|
||||
|
||||
has_many :votes, as: :votable, dependent: :destroy
|
||||
|
||||
def to_s
|
||||
name
|
||||
@ -128,4 +129,16 @@ class City < ApplicationRecord
|
||||
Ahoy::Event.where("properties::jsonb->>'event_type' = 'city_view' AND (properties::jsonb->>'city_id')::integer = ?", self.id).count
|
||||
end
|
||||
end
|
||||
|
||||
def vote_counts
|
||||
{
|
||||
upvotes: votes.upvote.count,
|
||||
downvotes: votes.downvote.count
|
||||
}
|
||||
end
|
||||
|
||||
def user_vote(user_session)
|
||||
votes.find_by(user_session: user_session)&.vote_type
|
||||
end
|
||||
|
||||
end
|
||||
|
12
app/models/vote.rb
Normal file
12
app/models/vote.rb
Normal file
@ -0,0 +1,12 @@
|
||||
class Vote < ApplicationRecord
|
||||
belongs_to :votable, polymorphic: true
|
||||
|
||||
enum :vote_type, { upvote: 0, downvote: 1 }
|
||||
|
||||
validates :user_session, presence: true
|
||||
validates :votable_type, presence: true
|
||||
validates :votable_id, presence: true
|
||||
validates :vote_type, presence: true
|
||||
|
||||
scope :for_object, ->(obj) { where(votable: obj) }
|
||||
end
|
@ -8,6 +8,7 @@ class WeatherArt < ApplicationRecord
|
||||
|
||||
has_many :visits, class_name: "Ahoy::Visit", foreign_key: :weather_art_id
|
||||
has_many :events, class_name: "Ahoy::Event", foreign_key: :weather_art_id
|
||||
has_many :votes, as: :votable, dependent: :destroy
|
||||
|
||||
validates :weather_date, presence: true
|
||||
validates :city_id, presence: true
|
||||
@ -55,4 +56,16 @@ class WeatherArt < ApplicationRecord
|
||||
def image_url
|
||||
image.attached? ? image.blob : nil
|
||||
end
|
||||
|
||||
def vote_counts
|
||||
{
|
||||
upvotes: votes.upvote.count,
|
||||
downvotes: votes.downvote.count
|
||||
}
|
||||
end
|
||||
|
||||
def user_vote(user_session)
|
||||
votes.find_by(user_session: user_session)&.vote_type
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -143,6 +143,9 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render 'votes/vote_buttons', votable: @city %>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
30
app/views/votes/_vote_buttons.html.erb
Normal file
30
app/views/votes/_vote_buttons.html.erb
Normal file
@ -0,0 +1,30 @@
|
||||
# app/views/votes/_vote_buttons.html.erb
|
||||
<%
|
||||
Rails.logger.debug "Votable object: #{votable.inspect}"
|
||||
Rails.logger.debug "Votable class: #{votable.class}"
|
||||
Rails.logger.debug "Votable present?: #{votable.present?}"
|
||||
%>
|
||||
|
||||
<div id="<%= "#{votable.class.name.downcase}_votes_#{votable.id}" %>">
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= button_to votes_path,
|
||||
params: {
|
||||
"#{votable.class.name.downcase}_id": votable.id,
|
||||
vote_type: :upvote
|
||||
},
|
||||
method: :post,
|
||||
class: "btn btn-sm #{votable.user_vote(session.id) == 'upvote' ? 'btn-primary' : ''}" do %>
|
||||
👍 <%= votable.vote_counts[:upvotes] %>
|
||||
<% end %>
|
||||
|
||||
<%= button_to votes_path,
|
||||
params: {
|
||||
"#{votable.class.name.downcase}_id": votable.id,
|
||||
vote_type: :downvote
|
||||
},
|
||||
method: :post,
|
||||
class: "btn btn-sm #{votable.user_vote(session.id) == 'downvote' ? 'btn-error' : ''}" do %>
|
||||
👎 <%= votable.vote_counts[:downvotes] %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
@ -115,6 +115,8 @@
|
||||
No more Weather Arts available
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render 'votes/vote_buttons', votable: @weather_art %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@ -39,4 +39,6 @@ Rails.application.routes.draw do
|
||||
|
||||
# Defines the root path route ("/")
|
||||
# root "posts#index"
|
||||
|
||||
resources :votes, only: [:create]
|
||||
end
|
||||
|
14
db/migrate/20250205091907_create_votes.rb
Normal file
14
db/migrate/20250205091907_create_votes.rb
Normal file
@ -0,0 +1,14 @@
|
||||
class CreateVotes < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :votes do |t|
|
||||
t.references :votable, polymorphic: true, null: false
|
||||
t.string :user_session, null: false
|
||||
t.integer :vote_type, default: 0
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :votes, [:votable_id, :votable_type, :user_session], unique: true
|
||||
|
||||
end
|
||||
end
|
13
db/schema.rb
generated
13
db/schema.rb
generated
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_01_26_155239) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_02_05_091907) do
|
||||
create_table "active_admin_comments", force: :cascade do |t|
|
||||
t.string "namespace"
|
||||
t.text "body"
|
||||
@ -155,6 +155,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_26_155239) do
|
||||
t.index ["slug"], name: "index_regions_on_slug", unique: true
|
||||
end
|
||||
|
||||
create_table "votes", force: :cascade do |t|
|
||||
t.string "votable_type"
|
||||
t.integer "votable_id"
|
||||
t.string "session_id"
|
||||
t.integer "vote_type", default: 0
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["votable_id", "votable_type", "session_id", "vote_type"], name: "index_votes_on_votable_and_session_id_and_vote_type", unique: true
|
||||
t.index ["votable_type", "votable_id"], name: "index_votes_on_votable"
|
||||
end
|
||||
|
||||
create_table "weather_arts", force: :cascade do |t|
|
||||
t.integer "city_id", null: false
|
||||
t.date "weather_date"
|
||||
|
11
test/fixtures/votes.yml
vendored
Normal file
11
test/fixtures/votes.yml
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
# This model initially had no columns defined. If you add columns to the
|
||||
# model remove the "{}" from the fixture names and add the columns immediately
|
||||
# below each fixture, per the syntax in the comments below
|
||||
#
|
||||
# one: {}
|
||||
# column: value
|
||||
#
|
||||
# two: {}
|
||||
# column: value
|
7
test/models/vote_test.rb
Normal file
7
test/models/vote_test.rb
Normal file
@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class VoteTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
Loading…
Reference in New Issue
Block a user