Merge remote-tracking branch 'origin/dev_osredm_server' into dev_osredm_server

This commit is contained in:
xxq250 2025-10-24 14:59:24 +08:00
commit 220e65772d
21 changed files with 672 additions and 6 deletions

View File

@ -1,5 +1,5 @@
class Admins::PlatformCommunicatesController < Admins::BaseController
before_action :get_communicate, only: [:edit, :update, :destroy, :show, :online_switch]
before_action :get_communicate, only: [:edit, :update, :destroy, :show, :online_switch, :new_vote, :create_vote, :edit_vote, :update_vote]
def index
params[:location] = "pc"
@ -85,6 +85,42 @@ class Admins::PlatformCommunicatesController < Admins::BaseController
# render_ok
end
def new_vote
@vote = Vote.new(platform_communicate_id: @communicate.id)
2.times { @vote.vote_options.build }
end
def create_vote
@vote = Vote.new(platform_communicate_id: @communicate.id)
@vote.attributes = vote_params
if @vote.save
redirect_to applet_admins_platform_communicates_path, notice: '投票创建成功'
else
# 确保至少有2个选项显示在表单中
@vote.vote_options.build if @vote.vote_options.empty?
@vote.vote_options.build if @vote.vote_options.size == 1
render :new_vote
end
end
def edit_vote
@vote = @communicate.vote
# 确保至少有2个选项显示在表单中
@vote.vote_options.build if @vote.vote_options.empty?
@vote.vote_options.build if @vote.vote_options.size == 1
end
def update_vote
@vote = @communicate.vote
if @vote.update(vote_params)
redirect_to applet_admins_platform_communicates_path, notice: '投票更新成功'
else
render :edit_vote
end
end
private
def get_communicate
@ -94,4 +130,19 @@ class Admins::PlatformCommunicatesController < Admins::BaseController
def communicate_params
params.require(:platform_communicate).permit!
end
def vote_params
params.require(:vote).permit(
:description,
:is_multiple_choice,
:max_choice_vote_options_count,
:due_at,
vote_options_attributes: [
:id,
:title,
:order_index,
:_destroy
]
)
end
end

View File

@ -1,8 +1,110 @@
class Home::PlatformCommunicatesController < ApplicationController
before_action :require_login, only: [:submit_vote, :vote_results]
before_action :load_vote, only: [:submit_vote, :vote_results]
def index
location = params[:location] || "pc"
scope = PlatformCommunicate.where(status:true).where(location: location).order(order_index: :desc)
@communicates = kaminari_paginate(scope)
@communicates = kaminari_paginate(scope.includes(:vote))
end
def submit_vote
return render_error("投票已结束!") if @vote.expired?
# 处理投票选项
selected_option_ids = vote_params[:vote_option_ids] || []
# 验证选择数量
validation_result = validate_vote_selections(selected_option_ids)
return render_error(validation_result[:message]) unless validation_result[:valid]
# 保存投票
result = save_user_votes(selected_option_ids)
if result[:success]
render_ok
else
render_error(result[:errors].join(', '))
end
end
def vote_results
@vote_results = @vote.results_with_cache
@selected_option_ids = current_user.user_vote_options.where(vote_option: @vote.vote_options).pluck(:vote_option_id)
end
private
def load_vote
@communicate = PlatformCommunicate.find_by_id(params[:id])
@vote = @communicate.vote
return render_error("投票不存在!") unless @vote.present?
end
def vote_params
params.require(:vote).permit(vote_option_ids: [])
end
def validate_vote_selections(selected_option_ids)
selected_option_ids = Array(selected_option_ids).map(&:to_i).uniq
# 检查是否选择了选项
if selected_option_ids.empty?
return { valid: false, message: '请至少选择一个投票选项' }
end
# 检查选项是否属于该投票
valid_option_ids = @vote.vote_options.pluck(:id)
invalid_options = selected_option_ids - valid_option_ids
unless invalid_options.empty?
return { valid: false, message: '包含无效的投票选项' }
end
# 检查选择数量限制
if @vote.is_multiple_choice
max_choices = @vote.max_choice_vote_options_count || selected_option_ids.length
if selected_option_ids.length > max_choices
return {
valid: false,
message: "最多只能选择 #{max_choices} 个选项"
}
end
else
if selected_option_ids.length > 1
return { valid: false, message: '只能选择一个选项' }
end
end
{ valid: true }
end
def save_user_votes(selected_option_ids)
selected_option_ids = Array(selected_option_ids).map(&:to_i)
saved_selections = []
errors = []
ActiveRecord::Base.transaction do
# 如果是多选,先删除用户之前的投票记录(重新投票)
if @vote.is_multiple_choice
@vote.user_vote_options.where(user_id: current_user.id).destroy_all
end
selected_option_ids.each do |option_id|
vote_option = @vote.vote_options.find(option_id)
user_vote_option = current_user.user_vote_options.new(
vote_option: vote_option
)
if user_vote_option.save
saved_selections << user_vote_option
else
errors << user_vote_option.errors.full_messages
raise ActiveRecord::Rollback
end
end
end
if errors.empty?
{ success: true, selections: saved_selections }
else
{ success: false, errors: errors.flatten }
end
end
end

View File

@ -21,6 +21,8 @@ class PlatformCommunicate < ApplicationRecord
before_save :add_fake_id, on: [:create, :update]
has_many :watchers, as: :watchable, dependent: :destroy
has_one :vote, dependent: :destroy
has_many :vote_options, through: :vote
def watched_ext_info(user_id)
watcher = self.watchers.find_by(user_id: user_id)

View File

@ -25,8 +25,8 @@ class ProjectActions::PullRequestEvent < ProjectAction
pull_id: pull_request.id,
pull_number: pull_request.gitea_number,
title: pull_request.title,
body: pull_request.body,
git_diff: gitea_pull_request,
# body: pull_request.body,
# git_diff: gitea_pull_request,
additions: gitea_pull_request['TotalAddition'],
deletions: gitea_pull_request['TotalDeletion'],
changed_files: gitea_pull_request['NumFiles'],

View File

@ -192,6 +192,9 @@ class User < Owner
has_one :page, :dependent => :destroy
has_many :home_top_settings, dependent: :destroy
has_many :user_vote_options, dependent: :destroy
has_many :vote_options, through: :user_vote_options
# Groups and active users
scope :active, lambda { where(status: [STATUS_ACTIVE, STATUS_EDIT_INFO]) }

View File

@ -0,0 +1,17 @@
class UserVoteOption < ApplicationRecord
belongs_to :user
belongs_to :vote_option, counter_cache: true
validates :user_id, uniqueness: { scope: :vote_option_id, message: "您已经投过票了" }
private
def user_can_vote_only_once_per_vote
return unless user && vote_option
existing_vote = UserVoteOption.joins(:vote_option)
.where(user_id: user_id, vote_options: { vote_id: vote_option.vote_id })
.where.not(id: id) # 排除自身(更新时)
.exists?
errors.add(:base, '您已经投过票了') if existing_vote
end
end

75
app/models/vote.rb Normal file
View File

@ -0,0 +1,75 @@
class Vote < ApplicationRecord
self.table_name = 'new_votes'
belongs_to :platform_communicate
has_many :vote_options, dependent: :destroy
has_many :user_vote_options, through: :vote_options
accepts_nested_attributes_for :vote_options, allow_destroy: true
validates :max_choice_vote_options_count,
numericality: {
greater_than: 1,
less_than_or_equal_to: 20,
allow_nil: true
},
if: :is_multiple_choice?
validate :validate_multiple_choice_settings
def expired?
due_at.present? && due_at < Time.current
end
def user_voted?(user)
return false unless user
user_vote_options.where(user_id: user.id).exists?
end
def user_selections(user)
return UserVoteOption.none unless user
user_vote_options.where(user_id: user.id)
end
def total_votes_count
# 计算参与投票的总人数(去重)
user_vote_options.select(:user_id).distinct.count
end
def total_votes_options_count
vote_options.sum(:user_vote_options_count)
end
# 获取投票结果(使用缓存字段,性能更好)
def results_with_cache
vote_options.select(:id, :title, :user_vote_options_count).map do |option|
{
id: option.id,
title: option.title,
vote_count: option.user_vote_options_count,
percentage: calculate_percentage_for_option(option.user_vote_options_count)
}
end
end
private
def calculate_percentage_for_option(option_votes)
total = total_votes_options_count
return 0 if total.zero?
(option_votes.to_f / total * 100).round(2)
end
def validate_multiple_choice_settings
if is_multiple_choice? && max_choice_vote_options_count.nil?
errors.add(:max_choice_vote_options_count, '多选投票必须设置最大选择数')
end
if !is_multiple_choice? && max_choice_vote_options_count.present?
errors.add(:max_choice_vote_options_count, '单选投票不需要设置最大选择数')
end
end
end

11
app/models/vote_option.rb Normal file
View File

@ -0,0 +1,11 @@
class VoteOption < ApplicationRecord
belongs_to :vote
has_many :user_vote_options, dependent: :destroy
has_many :users, through: :user_vote_options
validates :title, presence: true
def self.selected?(option, user)
option.user_vote_options.exists?(user_id: user.id)
end
end

View File

@ -21,6 +21,11 @@
<td><%= c.order_index %></td>
<td><%= c.tag_field.to_s.include?("活动") ? link_to(c.watchers_count, "/admins/platform_communicates/#{c.id}") : "--"%></td>
<td class="action-container">
<% if c.vote.present? %>
<%= link_to "编辑投票", edit_vote_admins_platform_communicate_path(c), class: "action" %>
<% else %>
<%= link_to "新增投票", new_vote_admins_platform_communicate_path(c), class: "action" %>
<% end %>
<%= link_to c.status ? "下架" : "上架", "/admins/platform_communicates/#{c.id}/online_switch.js", style: "#{c.status? ? 'color:#FF6800;' : ''}", method: :post, remote: true%>
<%= link_to "编辑", edit_admins_platform_communicate_path(c), remote: true, class: "action" %>
<%= link_to "删除", admins_platform_communicate_path(c), method: :delete, data:{confirm: "确认删除的吗?"}, class: "action" %>

View File

@ -0,0 +1,312 @@
<%= form_for @vote, url: {controller: "platform_communicates", action: "#{type}"} do |form| %>
<% if vote.errors.any? %>
<div class="alert alert-danger">
<h5><%= pluralize(vote.errors.count, "error") %> 阻止了此投票的保存:</h5>
<ul>
<% vote.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<!-- 基本信息 -->
<div class="card mb-4">
<div class="card-header">
<h6 class="m-0 font-weight-bold text-primary">基本信息</h6>
</div>
<div class="card-body">
<div class="form-group">
<%= form.label :description, "投票描述", class: "font-weight-bold" %>
<%= form.text_area :description, class: "form-control", rows: 3, placeholder: "输入投票描述(可选)" %>
<small class="form-text text-muted">详细描述投票的目的和背景</small>
</div>
</div>
</div>
<!-- 投票设置 -->
<div class="card mb-4">
<div class="card-header">
<h6 class="m-0 font-weight-bold text-primary">投票设置</h6>
</div>
<div class="card-body">
<div class="form-group">
<div class="form-check">
<%= form.check_box :is_multiple_choice, class: "form-check-input", id: "is_multiple_choice" %>
<%= form.label :is_multiple_choice, "允许多选", class: "form-check-label font-weight-bold" %>
</div>
<small class="form-text text-muted">启用后用户可以选择多个选项</small>
</div>
<div class="form-group" id="max_choices_field" style="<%= 'display: none;' unless @vote.is_multiple_choice %>">
<%= form.label :max_choice_vote_options_count, "最多选择数", class: "font-weight-bold" %>
<%= form.number_field :max_choice_vote_options_count, class: "form-control", min: 2, max: 20, placeholder: "输入最多可选择的数量" %>
<small class="form-text text-muted">设置用户最多可以选择的选项数量2-20</small>
</div>
<div class="form-group">
<%= form.label :due_at, "结束时间", class: "font-weight-bold" %>
<div class="input-group">
<%= form.datetime_local_field :due_at, class: "form-control" %>
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" id="clear_ends_at">
清除
</button>
</div>
</div>
<small class="form-text text-muted">不设置则投票永久有效</small>
</div>
</div>
</div>
<!-- 投票选项 -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">投票选项</h6>
<button type="button" class="btn btn-sm btn-primary" id="add_vote_option">
添加选项
</button>
</div>
<div class="card-body">
<div id="vote_options">
<%= form.fields_for :vote_options do |vote_option_fields| %>
<div class="vote-option-item card mb-3">
<div class="card-body">
<div class="row">
<div class="col-md-8">
<div class="form-group">
<%= vote_option_fields.label :title, "选项名称", class: "font-weight-bold" %>
<%= vote_option_fields.text_field :title, class: "form-control", placeholder: "输入选项名称", required: true %>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<%= vote_option_fields.label :order_index, "排序", class: "font-weight-bold" %>
<%= vote_option_fields.number_field :order_index, class: "form-control", min: 1, value: vote_option_fields.object.order_index || 1 %>
</div>
</div>
<div class="col-md-1 d-flex align-items-end">
<% unless vote_option_fields.object.persisted? %>
<button type="button" class="btn btn-danger btn-sm remove-option" style="margin-bottom: 1rem;">
删除
</button>
<% end %>
</div>
</div>
<% if vote_option_fields.object.persisted? %>
<div class="form-group">
<div class="form-check">
<%= vote_option_fields.check_box :_destroy, class: "form-check-input" %>
<%= vote_option_fields.label :_destroy, "删除此选项", class: "form-check-label text-danger" %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<div class="alert alert-info">
<strong>提示:</strong> 至少需要添加2个投票选项最多支持50个选项。
</div>
</div>
</div>
<!-- 表单操作 -->
<div class="form-group text-center">
<%= form.submit vote.persisted? ? "更新投票" : "创建投票", class: "btn btn-primary btn-lg mr-2" %>
<%= link_to "取消", applet_admins_platform_communicates_path, class: "btn btn-secondary btn-lg" %>
</div>
<% end %>
<!-- 投票选项模板(用于动态添加) -->
<template id="vote_option_template">
<div class="vote-option-item card mb-3">
<div class="card-body">
<div class="row">
<div class="col-md-8">
<div class="form-group">
<label class="font-weight-bold">选项名称</label>
<input type="text" name="vote[vote_options_attributes][INDEX][title]" class="form-control" placeholder="输入选项名称" required>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label class="font-weight-bold">排序</label>
<input type="number" name="vote[vote_options_attributes][INDEX][order_index]" class="form-control" min="1" value="1">
</div>
</div>
<div class="col-md-1 d-flex align-items-end">
<button type="button" class="btn btn-danger btn-sm remove-option" style="margin-bottom: 1rem;">
删除
</button>
</div>
</div>
</div>
</div>
</template>
<%= javascript_tag nonce: true do %>
// 简化的投票表单交互功能
document.addEventListener('DOMContentLoaded', function() {
initVoteForm();
});
// 如果使用 Turbolinks/Turbo
document.addEventListener('turbolinks:load', initVoteForm);
document.addEventListener('turbo:load', initVoteForm);
function initVoteForm() {
console.log('初始化投票表单...');
// 防止重复初始化
if (window.voteFormInitialized) {
console.log('投票表单已经初始化过,跳过');
return;
}
window.voteFormInitialized = true;
// 多选切换
const multipleChoiceCheckbox = document.getElementById('is_multiple_choice');
const maxChoicesField = document.getElementById('max_choices_field');
if (multipleChoiceCheckbox && maxChoicesField) {
function toggleMaxChoices() {
maxChoicesField.style.display = multipleChoiceCheckbox.checked ? 'block' : 'none';
if (!multipleChoiceCheckbox.checked) {
const maxChoiceInput = document.getElementById('vote_max_choice_vote_options_count');
if (maxChoiceInput) maxChoiceInput.value = '';
}
}
multipleChoiceCheckbox.addEventListener('change', toggleMaxChoices);
toggleMaxChoices(); // 初始状态
}
// 清除结束时间
const clearEndsAtBtn = document.getElementById('clear_ends_at');
if (clearEndsAtBtn) {
clearEndsAtBtn.addEventListener('click', function() {
const endsAtInput = document.getElementById('vote_ends_at');
if (endsAtInput) endsAtInput.value = '';
});
}
// 添加选项 - 关键修复:确保只绑定一次
const addOptionButton = document.getElementById('add_vote_option');
if (addOptionButton && !addOptionButton.hasListener) {
addOptionButton.hasListener = true; // 标记已绑定
addOptionButton.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();
addNewOption();
});
}
// 删除选项 - 使用事件委托
const optionsContainer = document.getElementById('vote_options');
if (optionsContainer) {
optionsContainer.addEventListener('click', function(event) {
if (event.target.closest('.remove-option')) {
event.preventDefault();
event.target.closest('.vote-option-item').remove();
}
});
}
// 表单验证
const form = document.querySelector('.vote-form');
if (form) {
form.addEventListener('submit', validateForm);
}
}
// 添加新选项
function addNewOption() {
console.log('执行添加选项函数');
const optionsContainer = document.getElementById('vote_options');
const optionTemplate = document.getElementById('vote_option_template');
if (!optionsContainer || !optionTemplate) {
console.error('找不到选项容器或模板');
return;
}
// 计算新的索引和位置
const currentOptions = optionsContainer.querySelectorAll('.vote-option-item');
const newIndex = currentOptions.length;
const newPosition = getNextPosition();
const newOption = optionTemplate.content.cloneNode(true);
const html = newOption.querySelector('.vote-option-item').outerHTML;
let newHtml = html.replace(/INDEX/g, newIndex);
newHtml = newHtml.replace(/POSITION_VALUE/g, newPosition);
optionsContainer.insertAdjacentHTML('beforeend', newHtml);
console.log(`成功添加选项,索引: ${newIndex}, 位置: ${newPosition}`);
}
// 获取下一个位置值
function getNextPosition() {
const positionInputs = document.querySelectorAll('input[name*="position"]');
let maxPosition = 0;
positionInputs.forEach(input => {
const value = parseInt(input.value) || 0;
if (value > maxPosition) maxPosition = value;
});
return maxPosition + 1;
}
// 表单验证
function validateForm(event) {
const optionsContainer = document.getElementById('vote_options');
const optionItems = optionsContainer ? optionsContainer.querySelectorAll('.vote-option-item') : [];
let validOptionCount = 0;
// 计算有效的选项数量
optionItems.forEach(item => {
const nameInput = item.querySelector('input[type="text"][name*="name"]');
if (nameInput && nameInput.value.trim()) {
validOptionCount++;
}
});
if (validOptionCount < 2) {
event.preventDefault();
alert('请至少填写2个有效的投票选项');
return false;
}
// 验证选项名称是否重复
const optionNames = [];
let hasDuplicate = false;
optionItems.forEach(item => {
const nameInput = item.querySelector('input[type="text"][name*="name"]');
if (nameInput && nameInput.value.trim()) {
const name = nameInput.value.trim().toLowerCase();
if (optionNames.includes(name)) {
hasDuplicate = true;
nameInput.classList.add('is-invalid');
} else {
optionNames.push(name);
nameInput.classList.remove('is-invalid');
}
}
});
if (hasDuplicate) {
event.preventDefault();
alert('存在重复的选项名称,请修改后重新提交!');
return false;
}
return true;
}
<% end %>

View File

@ -0,0 +1,15 @@
<% content_for :page_title, "编辑投票" %>
<div class="row">
<div class="col-lg-12 mx-auto">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-default">编辑投票</h6>
</div>
<div class="card-body">
<%= render 'vote_form', vote: @vote, type: "update_vote" %>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
<% content_for :page_title, "创建新投票" %>
<div class="row">
<div class="col-lg-12 mx-auto">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-default">创建新投票</h6>
</div>
<div class="card-body">
<%= render 'vote_form', vote: @vote, type: "create_vote" %>
</div>
</div>
</div>
</div>

View File

@ -19,6 +19,7 @@ on:
<% end %>
<%if node.name.to_s.include?("on-pull_request") %>
pull_request:
types: [opened, reopened]
<% end %>
<%if node.name.to_s.include?("on-fork") %>
fork:

View File

@ -0,0 +1,4 @@
json.(vote, :id, :description, :is_multiple_choice, :max_choice_vote_options_count)
json.created_at vote.created_at.to_i
json.due_at vote.due_at.to_i
json.total_votes_count vote.total_votes_count # 计算参与投票的总人数(去重)

View File

@ -9,5 +9,6 @@ json.communicates do
json.enroll_field communicate.enroll_field.to_s.split(",")
json.enroll_info current_user.watched?(communicate) ? communicate.watched_ext_info(current_user.id) : []
json.url communicate.url_or_uuid
json.has_vote communicate.vote.present?
end
end

View File

@ -0,0 +1,7 @@
json.vote do
json.partial! "home/platform_communicates/vote", locals: {vote: @vote}
end
json.selected_option_ids @selected_option_ids
json.vote_results @vote_results do |result|
json.(result, :id, :title, :vote_count, :percentage)
end

View File

@ -15,7 +15,8 @@ Rails.application.configure do
# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
config.cache_store = :file_store, "#{Rails.root }/files/cache_store/"
# config.cache_store = :file_store, "#{Rails.root }/files/cache_store/"
config.cache_store = :redis_store, { url: 'redis://127.0.0.1:6379/1', namespace: 'cache_store' }
# if Rails.root.join('tmp', 'caching-dev.txt').exist?
# config.action_controller.perform_caching = true

View File

@ -145,7 +145,12 @@ Rails.application.routes.draw do
namespace :home do
resources :platform_statistics, only: [:index]
resources :competitions, only:[:index]
resources :platform_communicates, only: [:index]
resources :platform_communicates, only: [:index] do
member do
post :submit_vote
get :vote_results
end
end
resources :platform_people, only: [:index]
end
get 'home/index'
@ -1221,6 +1226,10 @@ Rails.application.routes.draw do
get :applet, on: :collection
member do
post :online_switch
get :new_vote
post :create_vote
get :edit_vote
patch :update_vote
end
end
resources :platform_people

View File

@ -0,0 +1,13 @@
class CreateVotes < ActiveRecord::Migration[5.2]
def change
create_table :new_votes do |t|
t.references :platform_communicate
t.text :description
t.boolean :is_multiple_choice, default: false
t.integer :max_choice_vote_options_count, default: 1
t.datetime :due_at
t.timestamps
end
end
end

View File

@ -0,0 +1,12 @@
class CreateVoteOptions < ActiveRecord::Migration[5.2]
def change
create_table :vote_options do |t|
t.references :vote
t.string :title
t.integer :order_index, default: 0
t.integer :user_vote_options_count, default: 0
t.timestamps
end
end
end

View File

@ -0,0 +1,10 @@
class CreateUserVoteOptions < ActiveRecord::Migration[5.2]
def change
create_table :user_vote_options do |t|
t.references :user, index: true
t.references :vote_option, index: true
t.timestamps
end
end
end