Add joint sorting of links and files

This commit is contained in:
junyi 2026-08-25 15:55:40 +08:00
parent 225066bbea
commit 42a6fe06d2
6 changed files with 323 additions and 11 deletions

View File

@ -85,6 +85,10 @@ class ArchiveFile
field :uid, type: String
field :sort_number, type: Integer
field :tmp_sort_number, type: Integer
field :enable_file_link_sort, type: Boolean, default: false
field :sort_order_list, type: Hash, default: {}
field :files_links_migrated, type: Boolean, default: false
attr_accessor :order_source
field :rss2_sn
field :feed_uid
field :site_feed_id
@ -96,15 +100,15 @@ class ArchiveFile
# belongs_to :archive_file_category
has_many :archive_file_multiples, :autosave => true, :dependent => :destroy
has_many :archive_file_links, :autosave => true, :dependent => :destroy
accepts_nested_attributes_for :archive_file_multiples, :allow_destroy => true
accepts_nested_attributes_for :archive_file_links, :allow_destroy => true
# validates :title, :at_least_one => true
after_save :save_archive_file_multiples, :update_tmp_sort_number
after_save :save_archive_file_multiples, :save_archive_file_links, :sync_links_back_to_urls_array, :sync_file_order_between_ui, :update_tmp_sort_number
before_save :add_http
before_save :migrate_urls_to_links_if_needed
after_initialize do
unless self.new_record?
if self.urls.nil? && self.url.present?
@ -139,6 +143,67 @@ class ArchiveFile
self.class.recalc_sort_number
end
end
def migrate_urls_to_links_if_needed
return unless self.enable_file_link_sort
return if self.files_links_migrated
in_use_locales = (Site.first.in_use_locales rescue I18n.available_locales).map(&:to_s)
in_use_locales.each do |locale|
urls = self.urls_translations[locale] || []
url_texts = self.url_texts_translations[locale] || []
urls.each_with_index do |url, i|
next if url.blank?
self.archive_file_links.build(locale: locale, url: url, url_text: url_texts[i])
end
end
new_sort_order_list = {}
in_use_locales.each do |locale|
file_tokens = self.archive_file_multiples.select { |f| f.choose_lang.include?(locale) }
.sort_by { |f| -(f.sort_number.to_i) }
.map { |f| "file:#{f.id}" }
link_tokens = self.archive_file_links.select { |l| l.locale == locale }
.map { |l| "link:#{l.id}" }
new_sort_order_list[locale] = file_tokens + link_tokens
end
self.sort_order_list = new_sort_order_list
self.files_links_migrated = true
end
def ordered_tokens_for_locale(locale)
locale = locale.to_s
order_tokens = (self.sort_order_list[locale] || []).dup
existing_file_tokens = self.archive_file_multiples.select { |f| f.choose_lang.include?(locale) }.map { |f| "file:#{f.id}" }
existing_link_tokens = self.archive_file_links.select { |l| l.locale == locale }.map { |l| "link:#{l.id}" }
valid_tokens = existing_file_tokens + existing_link_tokens
tokens = order_tokens & valid_tokens
missing = valid_tokens - tokens
tokens + missing
end
def merged_items_for_locale(locale)
locale = locale.to_s
file_map = self.archive_file_multiples.each_with_object({}) { |f, h| h[f.id.to_s] = f }
link_map = self.archive_file_links.select { |l| l.locale == locale }.each_with_object({}) { |l, h| h[l.id.to_s] = l }
self.ordered_tokens_for_locale(locale).map do |token|
type, id = token.split(":", 2)
if type == "file"
f = file_map[id]
next nil if f.nil?
{ type: "file", token: token, label: (f.file_title.presence || (File.basename(f.file.path) rescue "file")) }
else
l = link_map[id]
next nil if l.nil?
{ type: "link", token: token, label: (l.url_text.presence || l.url) }
end
end.compact
end
def save_archive_file_links
return if @skip_callback
self.archive_file_links.each do |t|
t.destroy if t.should_destroy
end
end
def get_url_text(idx=0,org=false)
url_text = self.url_texts[idx] rescue nil
if org
@ -272,6 +337,7 @@ class ArchiveFile
end
ArchiveCache.destroy_all
end
def get_files(locale=nil, serial_number=0)
if locale.nil?
locale = I18n.locale.to_s
@ -286,6 +352,45 @@ class ArchiveFile
h["serial_number"] = serial_number
h
end
elsif self.enable_file_link_sort
file_map = self.archive_file_multiples.each_with_object({}) { |f, h| h[f.id.to_s] = f }
link_map = self.archive_file_links.each_with_object({}) { |l, h| h[l.id.to_s] = l }
self.ordered_tokens_for_locale(locale).each do |token|
type, id = token.split(":", 2)
if type == "file"
file = file_map[id]
next if file.nil?
title = (file.file_title.blank? ? File.basename(file.file.path) : file.file_title) rescue ""
extension = file.file.file.extension.downcase rescue ""
serial_number += 1
real_file_time = ""
if file.file.present? && file.file.path && File.exist?(file.file.path)
real_file_time = File.mtime(file.file.path).localtime.strftime("%Y-%m-%d %H:%M")
else
real_file_time = file.created_at.localtime.strftime("%Y-%m-%d %H:%M") rescue ""
end
files << {
"file-name" => title,
"file-type" => extension,
"file-url" => (file.file.present? ? "/xhr/archive/download?file=#{file.id}" : 'javascript:void(0)'),
"target" => "_blank",
"serial_number" => serial_number,
"file-date" => real_file_time
}
elsif type == "link"
link = link_map[id]
next if link.nil?
serial_number += 1
target = (link.url.to_s.match(/\/[^\/]/) ? '_self' : '_blank')
files << {
"file-name" => (link.url_text.presence || link.url),
"file-type" => 'link',
"file-url" => link.url.to_s + "\" data-target=\"#{target}",
"target" => target,
"serial_number" => serial_number
}
end
end
else
self.archive_file_multiples.order_by(:sort_number=>'desc').each do |file|
if file.choose_lang.include?(locale)
@ -296,7 +401,6 @@ class ArchiveFile
if file.file.present? && file.file.path && File.exist?(file.file.path)
real_file_time = File.mtime(file.file.path).localtime.strftime("%Y-%m-%d %H:%M")
else
# 如果找不到實體檔案,才回退到資料庫時間(或留空)
real_file_time = file.created_at.localtime.strftime("%Y-%m-%d %H:%M") rescue ""
end
files << {
@ -325,6 +429,7 @@ class ArchiveFile
end
[files, serial_number]
end
def get_widget_data(locale=nil, serial_number=0, idx=0, show_tags=false, more_url=nil)
created_at_int = self.created_at.strftime('%Y%m%d').to_i
statuses = self.statuses_with_classname.collect do |status|
@ -546,6 +651,70 @@ class ArchiveFile
end
end
end
def sync_links_back_to_urls_array
return unless self.enable_file_link_sort
in_use_locales = (Site.first.in_use_locales rescue I18n.available_locales).map(&:to_s)
new_urls = self.urls_translations.dup
new_url_texts = self.url_texts_translations.dup
in_use_locales.each do |locale|
ordered_links = self.ordered_tokens_for_locale(locale)
.select { |token| token.start_with?("link:") }
.map { |token| token.split(":", 2)[1] }
link_map = self.archive_file_links.select { |l| l.locale == locale }.each_with_object({}) { |l, h| h[l.id.to_s] = l }
urls_for_locale = []
url_texts_for_locale = []
ordered_links.each do |id|
link = link_map[id]
next if link.nil?
urls_for_locale << link.url
url_texts_for_locale << link.url_text
end
new_urls[locale] = urls_for_locale
new_url_texts[locale] = url_texts_for_locale
end
@skip_callback = true
in_use_locales.each do |locale|
I18n.with_locale(locale) do
self.class.where(:id => self.id).update_all(:urls => new_urls[locale], :url_texts => new_url_texts[locale])
end
end
@skip_callback = false
end
def sync_file_order_between_ui
return unless self.enable_file_link_sort
case self.order_source
when "merged_list"
reference_locale = (Site.first.in_use_locales rescue I18n.available_locales).first.to_s
file_ids_in_order = self.ordered_tokens_for_locale(reference_locale)
.select { |token| token.start_with?("file:") }
.map { |token| token.split(":", 2)[1] }
total = file_ids_in_order.count
file_ids_in_order.each_with_index do |id, idx|
ArchiveFileMultiple.where(:id => id).update_all(:sort_number => (total - idx))
end
self.class.recalc_sort_number
when "file_sort"
in_use_locales = (Site.first.in_use_locales rescue I18n.available_locales).map(&:to_s)
new_sort_order_list = self.sort_order_list.dup
in_use_locales.each do |locale|
tokens = self.ordered_tokens_for_locale(locale)
file_slot_indexes = tokens.each_index.select { |i| tokens[i].start_with?("file:") }
visible_files_sorted = self.archive_file_multiples.select { |f| f.choose_lang.include?(locale) }
.sort_by { |f| -(f.sort_number.to_i) }
new_tokens = tokens.dup
file_slot_indexes.each_with_index do |slot_index, i|
file = visible_files_sorted[i]
new_tokens[slot_index] = "file:#{file.id}" if file
end
new_sort_order_list[locale] = new_tokens
end
self.class.where(:id => self.id).update_all(:sort_order_list => new_sort_order_list)
else
end
end
def self.smart_convertor(text,url)
doc = Nokogiri.HTML(text)
doc.search('a[href]').each do |link|

View File

@ -0,0 +1,14 @@
class ArchiveFileLink
include Mongoid::Document
include Mongoid::Timestamps
field :locale, type: String
field :url, type: String
field :url_text, type: String
field :sort_number, type: Integer
field :should_destroy, type: Boolean
belongs_to :archive_file, index: true
index({archive_file_id: 1, locale: 1})
end

View File

@ -103,7 +103,6 @@
<p class="muted"><%= t('archive.min') %>: <%=min_sort_number%>, <%= t('archive.max') %>: <%=max_sort_number%></p>
</div>
</div>
</div>
<!-- Status Module -->
@ -150,6 +149,20 @@
<% end %>
</ul>
<div class="control-group" id="file_link_sort_control" data-migrated="<%= @archive_file.files_links_migrated ? 'true' : 'false' %>">
<label class="control-label muted"><%= t("archive.enable_file_link_sort") %></label>
<div class="controls">
<label class="checkbox">
<%= f.check_box :enable_file_link_sort, id: "enable_file_link_sort_checkbox" %>
<%= t("archive.enable_file_link_sort_hint") %>
</label>
<p class="muted" style="color:#c0392b;"><%= t("archive.disable_file_link_sort_warning") %></p>
<% unless @archive_file.files_links_migrated %>
<p class="muted" style="color:#c0392b;"><%= t("archive.not_migrated_yet_hint") %></p>
<% end %>
</div>
</div>
<!-- Language -->
<div class="tab-content language-area">
@ -175,25 +188,52 @@
<% end %>
</div>
</div>
<!-- urls -->
<div class="control-group input-title">
<label class="control-label muted"><%= t(:link) %></label>
<div class="controls">
<div class="link_append_target">
<!-- 開關「關閉」時使用:原本的陣列式連結輸入,完全不變 -->
<div class="link_append_target old-style-links" style="<%= @archive_file.enable_file_link_sort ? 'display:none;' : '' %>">
<% I18n.with_locale(locale) do %>
<% f.object.urls.to_a.each_with_index do |url,i| %>
<%= render :partial => "form_link", :locals=>{:f=>f,:locale=>locale,:url=>url,:url_text=>f.object.get_url_text(i,true)} %>
<% end %>
<% end %>
</div>
<a class="trigger btn btn-small btn-primary add_link" data-locale="<%=locale%>"><i class="icons-plus"></i> <%= t(:add) %></a>
<a class="trigger btn btn-small btn-primary add_link old-style-links" data-locale="<%=locale%>" style="<%= @archive_file.enable_file_link_sort ? 'display:none;' : '' %>"><i class="icons-plus"></i> <%= t(:add) %></a>
<div class="new-style-links" style="<%= @archive_file.enable_file_link_sort ? '' : 'display:none;' %>">
<% f.object.archive_file_links.select{|l| l.locale == locale.to_s}.each do |archive_file_link| %>
<%= f.fields_for :archive_file_links, archive_file_link do |lf| %>
<%= render :partial => "form_file_link", :locals => {:f=>lf, :locale=>locale, :form_link=>archive_file_link} %>
<% end %>
<% end %>
</div>
<a class="trigger btn btn-small btn-primary add_file_link new-style-links" data-locale="<%=locale%>" style="<%= @archive_file.enable_file_link_sort ? '' : 'display:none;' %>"><i class="icons-plus"></i> <%= t(:add) %></a>
</div>
</div>
<div class="control-group file-link-order-block" style="<%= @archive_file.enable_file_link_sort ? '' : 'display:none;' %>">
<label class="control-label muted"><%= t("archive.file_link_order") %></label>
<div class="controls">
<ul class="file-link-sortable" data-locale="<%= locale %>">
<% f.object.merged_items_for_locale(locale).each do |item| %>
<li class="file-link-sortable-item" data-token="<%= item[:token] %>">
<i class="icons-list-2 sort-order-icon"></i>
<span class="label label-info"><%= item[:type] == 'file' ? t(:file_) : t(:link) %></span>
<%= item[:label] %>
</li>
<% end %>
</ul>
<div class="file-link-sortable-hidden-fields"></div>
<p class="muted"><%= t("archive.file_link_order_hint") %></p>
</div>
</div>
</div>
<% end %>
<!-- File -->
<div class="control-group">
<label class="control-label muted"><%= t(:file_) %></label>
@ -232,6 +272,7 @@
<!-- Form Actions -->
<div class="form-actions">
<%= hidden_field_tag 'archive_file[order_source]', '', id: 'archive_file_order_source' %>
<%= hidden_field_tag 'page', params[:page] if !params[:page].blank? %>
<%= f.submit t('submit'), class: 'btn btn-primary' %>
<a href="<%= admin_archive_files_path %>" class="btn"><%= t("archive.back_to_archives") %></a>
@ -424,6 +465,7 @@
existingfiles.each(function(i, file){
$(file).find("input.file-sort-number-field").val(existingfiles.length - i);
})
$('#archive_file_order_source').val('file_sort');
}
});
$('.main-forms .add-on').tooltip();
@ -452,6 +494,63 @@
}
});
function toggle_file_link_sort_ui(){
var migrated = $('#file_link_sort_control').data('migrated') === true || $('#file_link_sort_control').data('migrated') === 'true';
if (!migrated){
$('.old-style-links').show();
$('.new-style-links').hide();
$('.file-link-order-block').hide();
return;
}
var checked = $('#enable_file_link_sort_checkbox').is(':checked');
$('.old-style-links').toggle(!checked);
$('.new-style-links').toggle(checked);
$('.file-link-order-block').toggle(checked);
}
toggle_file_link_sort_ui();
$(document).on('change', '#enable_file_link_sort_checkbox', toggle_file_link_sort_ui);
// 合併排序清單:拖曳後,把目前順序寫回 hidden fields 一起送出
$(".file-link-sortable").each(function(){
var $list = $(this);
var locale = $list.data("locale");
function write_hidden_fields(){
var $hidden_container = $list.siblings(".file-link-sortable-hidden-fields");
$hidden_container.empty();
$list.children("li").each(function(){
var token = $(this).data("token");
$hidden_container.append(
$("<input>", {type:"hidden", name:"archive_file[sort_order_list]["+locale+"][]", value: token})
);
});
}
$list.sortable({ update: function(){
write_hidden_fields();
$('#archive_file_order_source').val('merged_list');
}});
write_hidden_fields();
});
// 新式連結:新增一筆
$(document).on('click', '.add_file_link', function(){
var target_locale = $(this).data('locale');
var new_index = 'new_' + new Date().getTime();
var html = "<%= escape_javascript(f.fields_for(:archive_file_links, ArchiveFileLink.new, child_index: 'NEW_INDEX') { |lf| render(partial: 'form_file_link', locals: {f: lf, locale: 'new_locale', form_link: ArchiveFileLink.new}) }) %>";
html = html.replace(/NEW_INDEX/g, new_index).replace(/new_locale/g, target_locale);
$(this).siblings('.new-style-links').append(html);
});
// 新式連結:刪除「這次新增、還沒存檔」的那筆
$(document).on('click', '.delete_file_link', function(){
$(this).parents('.input-prepend').remove();
});
// 新式連結:刪除「已存在」的那筆(標記 should_destroy交給後端在儲存時真正刪除
$(document).on('click', '.remove_existing_file_link', function(){
if(confirm("<%= I18n.t(:sure?)%>")){
$(this).find('.should_destroy').val('1');
$(this).hide();
}
});
});
</script>
<% end %>

View File

@ -0,0 +1,18 @@
<div class="input-prepend input-append start-line" data-locale="<%=locale%>">
<span class="add-on icons-link" title="<%= t(:url) %>"></span>
<%= f.text_field :url, class: "input-large", placeholder: t(:url) %>
<span class="add-on icons-pencil" title="<%= t("archive.url_text") %>"></span>
<%= f.text_field :url_text, class: "input-large", placeholder: t("archive.url_text_hint") %>
<%= f.hidden_field :locale, value: locale %>
<% if form_link.new_record? %>
<span class="delete_file_link add-on btn" title="<%= t(:delete_) %>">
<a class="icon-trash"></a>
</span>
<% else %>
<%= f.hidden_field :id %>
<%= f.hidden_field :should_destroy, value: nil, class: 'should_destroy' %>
<span class="remove_existing_file_link add-on btn" title="<%= t(:remove) %>">
<a class="icon-remove"></a>
</span>
<% end %>
</div>

View File

@ -39,8 +39,14 @@ en:
Title: Title
Files: Files
Category: Category
enable_file_link_sort: "File & Link Sorting"
enable_file_link_sort_hint: "When enabled, files and links can be freely mixed and reordered together using the list below"
file_link_order: "Order (drag items to reorder)"
file_link_order_hint: "Only saved files/links can be dragged here; please save once first for newly added items, then reopen this page to reorder them."
not_migrated_yet_hint: "This record hasn't been migrated for sorting yet. The link display will stay as-is for now; please check the box and save once, then reopen this page to see and use the ordering list."
disable_file_link_sort_warning: "Note: After disabling this option, the displayed order of files and links may differ from your sorted result. Please save and re-open this page to review the order again."
back_to_archives: Back to Archives
frontend:
archive: Archive Frontend
widget:
index: Archive Widget
index: Archive Widget

View File

@ -36,6 +36,12 @@ zh_tw:
downloaded_times: 下載次數
Files: 檔案
Category: 類別
enable_file_link_sort: "檔案及連結排序"
enable_file_link_sort_hint: "開啟後,「檔案」與「連結」可以合併、自由交錯排序,顯示順序將依照下方拖曳的順序呈現"
file_link_order: "排序(拖曳項目調整順序)"
file_link_order_hint: "只有已經儲存過的檔案/連結才能在這裡拖曳排序;新增的項目請先按「儲存」,再重新進入編輯頁面排序。"
not_migrated_yet_hint: "此筆資料尚未套用過排序功能,畫面暫時維持原本的連結顯示方式;請先勾選並儲存一次,儲存後重新進入編輯頁面,才會看到排序清單並生效。"
disable_file_link_sort_warning: "提醒:關閉此開關後,檔案及連結的顯示順序可能會與您排序的結果有差異,請送出(儲存)後再次進入編輯頁面確認排序。"
back_to_archives: 返回檔案
frontend:
archive: 檔案室前台