Compare commits

...

1 Commits

Author SHA1 Message Date
e5c1461e8a 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.
2025-02-07 13:10:53 +08:00
13 changed files with 218 additions and 1 deletions

View 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

View 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
}
}

View File

@ -71,6 +71,7 @@ class City < ApplicationRecord
end end
} }
has_many :votes, as: :votable, dependent: :destroy
def to_s def to_s
name 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 Ahoy::Event.where("properties::jsonb->>'event_type' = 'city_view' AND (properties::jsonb->>'city_id')::integer = ?", self.id).count
end end
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 end

12
app/models/vote.rb Normal file
View 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

View File

@ -8,6 +8,7 @@ class WeatherArt < ApplicationRecord
has_many :visits, class_name: "Ahoy::Visit", foreign_key: :weather_art_id 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 :events, class_name: "Ahoy::Event", foreign_key: :weather_art_id
has_many :votes, as: :votable, dependent: :destroy
validates :weather_date, presence: true validates :weather_date, presence: true
validates :city_id, presence: true validates :city_id, presence: true
@ -55,4 +56,16 @@ class WeatherArt < ApplicationRecord
def image_url def image_url
image.attached? ? image.blob : nil image.attached? ? image.blob : nil
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 end

View File

@ -143,6 +143,9 @@
</div> </div>
<% end %> <% end %>
</div> </div>
<%= render 'votes/vote_buttons', votable: @city %>
</div> </div>
</div> </div>
</div> </div>

View 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>

View File

@ -115,6 +115,8 @@
No more Weather Arts available No more Weather Arts available
</div> </div>
<% end %> <% end %>
<%= render 'votes/vote_buttons', votable: @weather_art %>
</div> </div>
</div> </div>

View File

@ -39,4 +39,6 @@ Rails.application.routes.draw do
# Defines the root path route ("/") # Defines the root path route ("/")
# root "posts#index" # root "posts#index"
resources :votes, only: [:create]
end end

View 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
View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "active_admin_comments", force: :cascade do |t|
t.string "namespace" t.string "namespace"
t.text "body" 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 t.index ["slug"], name: "index_regions_on_slug", unique: true
end 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| create_table "weather_arts", force: :cascade do |t|
t.integer "city_id", null: false t.integer "city_id", null: false
t.date "weather_date" t.date "weather_date"

11
test/fixtures/votes.yml vendored Normal file
View 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
View File

@ -0,0 +1,7 @@
require "test_helper"
class VoteTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end