zen_admin 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +76 -0
  3. data/Rakefile +6 -0
  4. data/app/assets/stylesheets/zen_admin/application.css +15 -0
  5. data/app/controllers/zen_admin/application_controller.rb +27 -0
  6. data/app/controllers/zen_admin/dashboard_controller.rb +9 -0
  7. data/app/controllers/zen_admin/sessions_controller.rb +40 -0
  8. data/app/controllers/zen_admin/ui/resources_controller.rb +331 -0
  9. data/app/helpers/zen_admin/application_helper.rb +80 -0
  10. data/app/javascript/controllers/application.js +9 -0
  11. data/app/javascript/controllers/bulk_actions_controller.js +55 -0
  12. data/app/javascript/controllers/clipboard_controller.js +27 -0
  13. data/app/javascript/controllers/index.js +5 -0
  14. data/app/javascript/controllers/layout_controller.js +48 -0
  15. data/app/javascript/controllers/menu_group_controller.js +40 -0
  16. data/app/javascript/controllers/modal_controller.js +31 -0
  17. data/app/javascript/controllers/toast_controller.js +14 -0
  18. data/app/javascript/controllers/tom_select_controller.js +35 -0
  19. data/app/javascript/zen_admin/application.js +12 -0
  20. data/app/jobs/zen_admin/application_job.rb +4 -0
  21. data/app/mailers/zen_admin/application_mailer.rb +6 -0
  22. data/app/models/zen_admin/application_record.rb +5 -0
  23. data/app/models/zen_admin/asset.rb +43 -0
  24. data/app/models/zen_admin/audit_log.rb +116 -0
  25. data/app/models/zen_admin/permission.rb +73 -0
  26. data/app/models/zen_admin/role.rb +45 -0
  27. data/app/models/zen_admin/trash_item.rb +98 -0
  28. data/app/models/zen_admin/user.rb +37 -0
  29. data/app/policies/zen_admin/application_policy.rb +53 -0
  30. data/app/policies/zen_admin/resource_policy.rb +48 -0
  31. data/app/views/layouts/zen_admin/_flash.html.erb +9 -0
  32. data/app/views/layouts/zen_admin/_sidebar.html.erb +115 -0
  33. data/app/views/layouts/zen_admin/application.html.erb +98 -0
  34. data/app/views/zen_admin/builtin/_copy_button.html.erb +9 -0
  35. data/app/views/zen_admin/builtin/_file_link.html.erb +22 -0
  36. data/app/views/zen_admin/dashboard/index.html.erb +27 -0
  37. data/app/views/zen_admin/sessions/new.html.erb +47 -0
  38. data/app/views/zen_admin/ui/resources/_form.html.erb +226 -0
  39. data/app/views/zen_admin/ui/resources/_row.html.erb +170 -0
  40. data/app/views/zen_admin/ui/resources/create.turbo_stream.erb +2 -0
  41. data/app/views/zen_admin/ui/resources/destroy.turbo_stream.erb +2 -0
  42. data/app/views/zen_admin/ui/resources/edit.html.erb +11 -0
  43. data/app/views/zen_admin/ui/resources/index.html.erb +285 -0
  44. data/app/views/zen_admin/ui/resources/new.html.erb +11 -0
  45. data/app/views/zen_admin/ui/resources/show.html.erb +133 -0
  46. data/app/views/zen_admin/ui/resources/update.turbo_stream.erb +2 -0
  47. data/config/importmap.rb +10 -0
  48. data/config/locales/en.yml +107 -0
  49. data/config/locales/zh-CN.yml +110 -0
  50. data/config/routes.rb +24 -0
  51. data/lib/generators/zen_admin/admin_user/admin_user_generator.rb +55 -0
  52. data/lib/generators/zen_admin/install/install_generator.rb +50 -0
  53. data/lib/generators/zen_admin/install/templates/asset.rb +46 -0
  54. data/lib/generators/zen_admin/install/templates/create_zen_admin_assets.rb.erb +9 -0
  55. data/lib/generators/zen_admin/install/templates/create_zen_admin_audit_logs.rb.erb +18 -0
  56. data/lib/generators/zen_admin/install/templates/create_zen_admin_rbac.rb.erb +57 -0
  57. data/lib/generators/zen_admin/install/templates/create_zen_admin_trash_items.rb.erb +13 -0
  58. data/lib/generators/zen_admin/install/templates/zen_admin.rb +17 -0
  59. data/lib/generators/zen_admin/install/templates/zh-CN.yml +15 -0
  60. data/lib/generators/zen_admin/model/model_generator.rb +65 -0
  61. data/lib/generators/zen_admin/model/templates/zen_admin_config.rb.erb +80 -0
  62. data/lib/generators/zen_admin/rbac_install/rbac_install_generator.rb +52 -0
  63. data/lib/generators/zen_admin/rbac_install/templates/create_zen_admin_rbac.rb.erb +42 -0
  64. data/lib/generators/zen_admin/rbac_install/templates/seeds.rb.erb +13 -0
  65. data/lib/tasks/zen_admin_tasks.rake +4 -0
  66. data/lib/zen_admin/authenticatable.rb +44 -0
  67. data/lib/zen_admin/builtin.rb +77 -0
  68. data/lib/zen_admin/configuration.rb +17 -0
  69. data/lib/zen_admin/core/field.rb +34 -0
  70. data/lib/zen_admin/core/filter.rb +16 -0
  71. data/lib/zen_admin/core/resource.rb +175 -0
  72. data/lib/zen_admin/core.rb +3 -0
  73. data/lib/zen_admin/engine.rb +67 -0
  74. data/lib/zen_admin/link_registerable.rb +11 -0
  75. data/lib/zen_admin/registerable.rb +17 -0
  76. data/lib/zen_admin/registry.rb +83 -0
  77. data/lib/zen_admin/schema/serializer.rb +15 -0
  78. data/lib/zen_admin/schema.rb +1 -0
  79. data/lib/zen_admin/version.rb +3 -0
  80. data/lib/zen_admin.rb +51 -0
  81. metadata +233 -0
@@ -0,0 +1,226 @@
1
+ <%= turbo_frame_tag "modal" do %>
2
+ <div class="modal fade" tabindex="-1" data-controller="modal" data-action="turbo:submit-end->modal#submitEnd">
3
+ <div class="modal-dialog modal-lg modal-dialog-centered">
4
+ <div class="modal-content border-0 shadow-lg overflow-hidden" style="border-radius: 12px; display: flex; flex-direction: column; max-height: 90vh;">
5
+ <% # Modal Header - 固定在顶部 %>
6
+ <div class="modal-header bg-light py-3 border-bottom-0 flex-shrink-0">
7
+ <div class="d-flex align-items-center">
8
+ <div class="bg-primary text-white rounded-3 d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
9
+ <i class="fas <%= @record.persisted? ? 'fa-edit' : 'fas fa-plus' %>"></i>
10
+ </div>
11
+ <div>
12
+ <h4 class="modal-title fw-bold text-dark mb-0">
13
+ <%= @record.persisted? ? t('zen_admin.actions.edit') : t('zen_admin.actions.new', resource: @resource.label) %>
14
+ </h4>
15
+ <p class="text-muted small mb-0"><%= @resource.model_name %></p>
16
+ </div>
17
+ </div>
18
+ <button type="button" class="btn btn-sm btn-light border-0 rounded-circle shadow-none" data-action="modal#close" aria-label="Close" style="width: 32px; height: 32px; padding: 0;">
19
+ <i class="fas fa-times text-muted"></i>
20
+ </button>
21
+ </div>
22
+
23
+ <%= form_with model: [@resource.name.to_sym, @record],
24
+ url: @record.persisted? ? ui_resource_path(@resource.name, @record) : ui_resource_index_path(@resource.name),
25
+ method: @record.persisted? ? :patch : :post,
26
+ class: "d-flex flex-column overflow-hidden" do |form| %>
27
+
28
+ <% # Modal Body - 开启滚动区域 %>
29
+ <div class="modal-body p-4 flex-grow-1" style="overflow-y: auto;">
30
+ <% # Error Messages %>
31
+ <% if @record.errors.any? %>
32
+ <div class="alert alert-danger border-0 shadow-sm mb-4" role="alert">
33
+ <div class="d-flex">
34
+ <i class="fas fa-exclamation-circle fs-4 me-3 mt-1"></i>
35
+ <div>
36
+ <h6 class="fw-bold mb-1"><%= t('zen_admin.ui.errors') %></h6>
37
+ <ul class="mb-0 small">
38
+ <% @record.errors.each do |error| %>
39
+ <li><%= error.full_message %></li>
40
+ <% end %>
41
+ </ul>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ <% end %>
46
+
47
+ <div class="row g-4">
48
+ <% @resource.form_fields.each do |field| %>
49
+ <div class="col-12">
50
+ <label class="form-label fw-bold text-dark mb-2 d-flex align-items-center">
51
+ <%= field.label %>
52
+ <% if field.required %>
53
+ <span class="text-danger ms-1">*</span>
54
+ <% end %>
55
+ </label>
56
+
57
+ <div class="field-container">
58
+ <% case field.type.to_sym %>
59
+ <% when :password %>
60
+ <%= form.password_field field.name, class: 'form-control form-control-lg fs-6', placeholder: "请输入 #{field.label}..." %>
61
+ <% when :text %>
62
+ <%= form.text_area field.name, class: 'form-control fs-6', rows: 4, placeholder: "请输入 #{field.label}内容..." %>
63
+ <% when :select %>
64
+ <% collection = field.options[:collection] %>
65
+ <% collection = collection.call if collection.respond_to?(:call) %>
66
+ <% selected_value = @record.send(field.name) || (collection.first[1] if !@record.persisted? && collection.any?) rescue nil %>
67
+ <%= form.select field.name, collection, { selected: selected_value, include_blank: "请选择..." }, { class: 'form-select form-select-lg fs-6' } %>
68
+ <% when :radio %>
69
+ <div class="zen-radio-group d-flex flex-wrap gap-2 mt-1">
70
+ <% collection = field.options[:collection] %>
71
+ <% collection = collection.call if collection.respond_to?(:call) %>
72
+ <%
73
+ current_val = @record.send(field.name)
74
+ # 如果当前值为空,则标记第一个为默认选中
75
+ should_default = current_val.nil? || (current_val.respond_to?(:empty?) && current_val.empty?)
76
+ %>
77
+ <% collection.each_with_index do |(label, value), idx| %>
78
+ <% unique_id = "#{field.name}_#{value}_#{@record.object_id}" %>
79
+ <% checked = should_default ? (idx == 0) : (current_val.to_s == value.to_s) %>
80
+ <div class="zen-radio-item">
81
+ <input type="radio"
82
+ name="<%= form.object_name %>[<%= field.name %>]"
83
+ value="<%= value %>"
84
+ id="<%= unique_id %>"
85
+ class="zen-radio-input"
86
+ <%= 'checked' if checked %>>
87
+ <label for="<%= unique_id %>" class="zen-radio-label">
88
+ <%= label %>
89
+ </label>
90
+ </div>
91
+ <% end %>
92
+ </div>
93
+ <% when :multi_select %>
94
+ <% collection = field.options[:collection] %>
95
+ <% collection = collection.call if collection.respond_to?(:call) %>
96
+ <% selected_values = @record.send(field.name).presence || ([] if @record.persisted?) rescue [] %>
97
+ <div class="zen-ts-container">
98
+ <%= form.select field.name, collection, { selected: selected_values }, { multiple: true, data: { controller: 'tom-select' } } %>
99
+ </div>
100
+ <% when :file %>
101
+ <div class="image-upload-wrapper">
102
+ <% attachment = @record.send(field.name) rescue nil %>
103
+ <% has_image = attachment.respond_to?(:attached?) && attachment.attached? && attachment.representable? %>
104
+ <div class="dropzone-area text-center p-4 border-2 border-dashed rounded-3 bg-light position-relative" style="cursor: pointer; transition: all 0.3s;">
105
+ <label for="<%= "#{field.name}_input" %>" class="w-100 m-0 cursor-pointer">
106
+ <% if has_image && @record.persisted? %>
107
+ <div class="preview-container mb-3 text-center">
108
+ <%= image_tag main_app.url_for(attachment), class: "img-fluid rounded shadow", style: "max-height: 180px;" %>
109
+ </div>
110
+ <div class="btn btn-sm btn-white border shadow-sm fw-bold">
111
+ <i class="fas fa-sync-alt me-1 text-primary"></i> <%= t('zen_admin.ui.change_image') %>
112
+ </div>
113
+ <% else %>
114
+ <div class="py-3 text-muted">
115
+ <div class="icon-circle bg-white shadow-sm mx-auto mb-3 d-flex align-items-center justify-content-center rounded-circle" style="width: 64px; height: 64px;">
116
+ <i class="fas fa-cloud-upload-alt fa-lg text-primary"></i>
117
+ </div>
118
+ <h6 class="fw-bold text-dark mb-1"><%= t('zen_admin.ui.click_to_upload', field: field.label) %></h6>
119
+ <p class="small mb-0">支持 JPG, PNG, GIF 等格式</p>
120
+ </div>
121
+ <% end %>
122
+ </label>
123
+ <%= form.file_field field.name, class: 'd-none', id: "#{field.name}_input",
124
+ onchange: "const label = this.closest('.dropzone-area').querySelector('label');
125
+ if (this.files && this.files[0]) {
126
+ const reader = new FileReader();
127
+ reader.onload = function(e) {
128
+ label.innerHTML = `<div class='preview-container mb-3'><img src='${e.target.result}' class='img-fluid rounded shadow' style='max-height: 180px;'></div><div class='text-success fw-bold'><i class='fas fa-check-circle me-1'></i>文件已就绪</div>`;
129
+ };
130
+ reader.readAsDataURL(this.files[0]);
131
+ }" %>
132
+ </div>
133
+ </div>
134
+ <% when :rich_text %>
135
+ <div class="rich-text-container shadow-sm rounded overflow-hidden border">
136
+ <%= form.rich_text_area field.name %>
137
+ </div>
138
+ <% else %>
139
+ <%= form.text_field field.name, class: 'form-control form-control-lg fs-6', placeholder: "请输入 #{field.label}..." %>
140
+ <% end %>
141
+ </div>
142
+
143
+ <% if field.options[:help] %>
144
+ <div class="form-text text-muted mt-2 ps-1 small"><%= field.options[:help].html_safe %></div>
145
+ <% end %>
146
+ </div>
147
+ <% end %>
148
+ </div>
149
+
150
+ <% # Related Records Section %>
151
+ <% if @record.persisted? && @resource.model.reflect_on_all_associations(:has_many).any? %>
152
+ <div class="mt-5 pt-4 border-top">
153
+ <h6 class="fw-bold text-dark mb-4 d-flex align-items-center small">
154
+ <i class="fas fa-link me-2 text-primary opacity-50"></i><%= t('zen_admin.ui.related_records') %>
155
+ </h6>
156
+ <div class="row g-3">
157
+ <% @resource.model.reflect_on_all_associations(:has_many).each do |assoc| %>
158
+ <% next if assoc.name.to_s.include?("rich_text") || assoc.name.to_s.include?("attachments") %>
159
+ <div class="col-md-6">
160
+ <div class="card border shadow-none h-100 bg-white">
161
+ <div class="card-header py-2 bg-light border-bottom-0">
162
+ <span class="small fw-bold text-secondary"><%= assoc.name.to_s.humanize %></span>
163
+ </div>
164
+ <div class="card-body p-3 pt-2">
165
+ <div class="list-group list-group-flush">
166
+ <% associated_records = @record.send(assoc.name).limit(5) %>
167
+ <% associated_records.each do |item| %>
168
+ <% associated_resource = ZenAdmin::Registry.instance[item.class] %>
169
+ <% display_name = item.try(:title) || item.try(:name) || "##{item.id}" %>
170
+ <% if associated_resource %>
171
+ <%= link_to display_name, ui_resource_path(associated_resource.name, item), data: { turbo_frame: "modal" }, class: "list-group-item list-group-item-action border-0 px-0 py-1 text-primary small" %>
172
+ <% else %>
173
+ <span class="list-group-item border-0 px-0 py-1 small text-dark"><%= display_name %></span>
174
+ <% end %>
175
+ <% end %>
176
+ <% if associated_records.empty? %>
177
+ <div class="py-2 text-muted fst-italic small"><%= t('zen_admin.ui.no_related_records') %></div>
178
+ <% end %>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ <% end %>
184
+ </div>
185
+ </div>
186
+ <% end %>
187
+ </div>
188
+
189
+ <% # Modal Footer - 固定在底部 %>
190
+ <div class="modal-footer bg-light p-4 border-top-0 flex-shrink-0">
191
+ <button type="button" class="btn btn-link text-secondary text-decoration-none fw-bold me-auto" data-action="modal#close"><%= t('zen_admin.ui.cancel') %></button>
192
+ <%= form.submit t('zen_admin.ui.save'), class: "btn btn-primary px-5 py-2 fw-bold shadow-sm rounded-pill" %>
193
+ </div>
194
+ <% end %>
195
+ </div>
196
+ </div>
197
+ </div>
198
+
199
+ <style>
200
+ .dropzone-area:hover { border-color: var(--primary-color) !important; background-color: #f0f7ff !important; }
201
+ .form-control:focus, .form-select:focus { border-color: var(--primary-color); box-shadow: 0 0 0 0.25rem rgba(22, 93, 255, 0.1); }
202
+ .btn-white { background: #fff; }
203
+ .btn-white:hover { background-color: #f8f9fa; }
204
+ .form-control-lg, .form-select-lg { border-radius: 0.6rem; }
205
+ .cursor-pointer { cursor: pointer; }
206
+
207
+ /* Modern Radio Group (Segmented Control) */
208
+ .zen-radio-group { background: #f2f3f5; padding: 4px; border-radius: 10px; display: inline-flex !important; border: 1px solid #e5e6eb; gap: 2px !important; }
209
+ .zen-radio-input { display: none; }
210
+ .zen-radio-label { display: block; padding: 8px 24px; cursor: pointer; border-radius: 8px; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); margin-bottom: 0; font-size: 0.9rem; font-weight: 600; color: #4e5969; user-select: none; }
211
+ .zen-radio-input:checked + .zen-radio-label { background: #ffffff; color: var(--primary-color); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
212
+
213
+ /* Tom Select Ant Design Overhaul */
214
+ .ts-wrapper.zen-ts-wrapper { min-height: 44px; border-radius: 8px; }
215
+ .ts-wrapper.zen-ts-wrapper.form-control { padding: 4px 12px !important; }
216
+ .ts-wrapper.zen-ts-wrapper .ts-control { border: none !important; padding: 0 !important; display: flex !important; align-items: center; flex-wrap: wrap; gap: 4px; }
217
+ .ts-wrapper.zen-ts-wrapper.focus .ts-control { box-shadow: none !important; }
218
+ .ts-wrapper.zen-ts-wrapper.focus { border-color: var(--primary-color) !important; box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.1) !important; }
219
+
220
+ .ts-wrapper.multi .ts-control > div { background: #f5f5f5 !important; border: 1px solid #f0f0f0 !important; color: #262626 !important; border-radius: 4px !important; padding: 2px 8px !important; margin: 0 !important; font-size: 14px !important; font-weight: 400 !important; display: inline-flex !important; align-items: center; }
221
+ .ts-wrapper.multi .ts-control > div .remove { border-left: none !important; margin-left: 4px !important; opacity: 0.45 !important; color: inherit !important; }
222
+ .ts-wrapper.multi .ts-control > div .remove:hover { opacity: 1 !important; color: #f5222d !important; }
223
+ .ts-dropdown { border-radius: 8px !important; box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08) !important; border: 1px solid #f0f0f0 !important; margin-top: 4px !important; }
224
+ .ts-dropdown .option.active { background-color: #f5f5f5 !important; color: var(--primary-color) !important; }
225
+ </style>
226
+ <% end %>
@@ -0,0 +1,170 @@
1
+ <tr id="<%= dom_id(record) %>" class="align-middle">
2
+ <% if @resource.batch_actions.is_a?(Hash) && @resource.batch_actions.any? %>
3
+ <td style="width: 50px;" class="text-center py-2">
4
+ <% unless params[:scope] == 'trash' %>
5
+ <div class="d-inline-flex align-items-center justify-content-center" style="vertical-align: top;">
6
+ <input type="checkbox" name="ids[]" value="<%= record.id %>" class="form-check-input m-0" id="check_<%= dom_id(record) %>" data-bulk-actions-target="checkbox" data-action="change->bulk-actions#toggle">
7
+ </div>
8
+ <% end %>
9
+ </td>
10
+ <% end %>
11
+
12
+ <% @resource.list_fields.each_with_index do |field, index| %>
13
+ <td class="py-2 text-center">
14
+ <div class="d-inline-flex align-items-center justify-content-center w-100" style="vertical-align: top; min-height: 24px;">
15
+ <%
16
+ # 1. 获取原始值
17
+ value = if params[:scope] == 'trash' && record.is_a?(ZenAdmin::TrashItem)
18
+ record.data[field.name.to_s]
19
+ else
20
+ record.send(field.name) rescue nil
21
+ end
22
+
23
+ # 2. 预处理显示文本 (Smart Mapping)
24
+ mapped_label = if field.options[:collection]
25
+ collection = field.options[:collection]
26
+ collection = collection.call if collection.respond_to?(:call)
27
+ mapping_item = collection.find { |l, v| v.to_s == value.to_s }
28
+ mapping_item ? mapping_item[0] : value
29
+ elsif (value.is_a?(ActiveRecord::Associations::CollectionProxy) || value.is_a?(Array)) && params[:scope] != 'trash'
30
+ # 正常模式下的多对多关联
31
+ value.first(5).map { |i| i.try(:name) || i.try(:title) || i.to_s }.join(", ")
32
+ elsif params[:scope] == 'trash' && value.is_a?(Array)
33
+ # 回收站模式下的 JSON 数组内容
34
+ value.first(5).map { |i| i.is_a?(Hash) ? (i["name"] || i["title"] || i["username"]) : i.to_s }.join(", ")
35
+ else
36
+ value
37
+ end
38
+ %>
39
+
40
+ <% # 3. 渲染引擎 %>
41
+ <% if field.options[:formatter] %>
42
+ <%= instance_exec(record, mapped_label, &field.options[:formatter]) %>
43
+ <% else %>
44
+ <% if value.is_a?(ActiveRecord::Associations::CollectionProxy) || (value.is_a?(Array) && !value.first.is_a?(Hash)) %>
45
+ <div class="d-flex flex-wrap gap-1" style="max-width: 300px;">
46
+ <% value.first(5).each do |item| %>
47
+ <span class="badge bg-info text-white" style="font-size: 0.7rem;"><%= item.try(:name) || item.try(:title) || item.to_s %></span>
48
+ <% end %>
49
+ <% if value.count > 5 %>
50
+ <span class="text-muted small">+<%= value.count - 5 %></span>
51
+ <% end %>
52
+ </div>
53
+
54
+ <% elsif value.respond_to?(:attached?) %>
55
+ <% if value.attached? %>
56
+ <% if value.representable? %>
57
+ <div class="thumbnail shadow-xs border rounded bg-light cursor-pointer"
58
+ style="width: 32px; height: 32px; overflow: hidden;"
59
+ data-action="click->layout#previewImage"
60
+ data-preview-url="<%= main_app.url_for(value) %>">
61
+ <%= image_tag main_app.rails_blob_path(value, only_path: true), style: "width: 100%; height: 100%; object-fit: cover;" %>
62
+ </div>
63
+ <% else %>
64
+ <% # 文件图标逻辑 %>
65
+ <div class="text-muted d-flex align-items-center">
66
+ <i class="fas <%= case value.content_type
67
+ when /pdf/ then 'fa-file-pdf text-danger'
68
+ when /word|officedocument/ then 'fa-file-word text-primary'
69
+ when /excel|sheet/ then 'fa-file-excel text-success'
70
+ when /zip|compressed/ then 'fa-file-archive text-warning'
71
+ else 'fa-file-alt'
72
+ end %> fa-lg"></i>
73
+ <span class="ms-2 small text-truncate d-inline-block" style="max-width: 80px;"><%= value.filename %></span>
74
+ </div>
75
+ <% end %>
76
+ <% else %>
77
+ <span class="text-muted small">-</span>
78
+ <% end %>
79
+
80
+ <% elsif value.is_a?(Time) || value.is_a?(DateTime) || value.is_a?(ActiveSupport::TimeWithZone) %>
81
+ <%
82
+ tz = ZenAdmin.configuration.try(:time_zone) || Time.zone.name
83
+ display_time = value.in_time_zone(tz).strftime("%Y-%m-%d %H:%M")
84
+ %>
85
+ <span class="text-muted small"><%= display_time %></span>
86
+
87
+ <% else %>
88
+ <% if field.options[:truncate] && mapped_label.is_a?(String) %>
89
+ <span class="small text-secondary text-wrap" style="max-width: 250px; display: inline-block;">
90
+ <%= zen_highlight(truncate(mapped_label, length: field.options[:truncate]), params[:keyword]) %>
91
+ </span>
92
+ <% else %>
93
+ <span class="small">
94
+ <%= mapped_label.is_a?(String) ? zen_highlight(mapped_label, params[:keyword]) : mapped_label %>
95
+ </span>
96
+ <% end %>
97
+ <% end %>
98
+ <% end %>
99
+ </div>
100
+ </td>
101
+ <% end %>
102
+
103
+ <td class="text-center py-2 text-nowrap">
104
+ <div class="d-flex align-items-center justify-content-center w-100">
105
+ <div class="btn-group btn-group-sm shadow-xs">
106
+ <%
107
+ is_super = current_admin_user.respond_to?(:super_admin?) && current_admin_user.super_admin?
108
+ %>
109
+ <% if params[:scope] == 'trash' %>
110
+ <% if is_super || current_admin_user.can?("#{@resource.name}:index") %>
111
+ <%= link_to ui_resource_restore_path(@resource.name, record), class: 'btn btn-outline-success py-1 px-2', title: '还原', data: { turbo_method: :post, turbo_confirm: '确定要还原这条数据吗?' } do %>
112
+ <i class="fas fa-undo"></i>
113
+ <% end %>
114
+ <% end %>
115
+ <% if is_super || current_admin_user.can?("#{@resource.name}:destroy") %>
116
+ <%= link_to ui_resource_path(@resource.name, record, scope: 'trash'), class: 'btn btn-outline-danger py-1 px-2', title: '彻底删除', data: { turbo_method: :delete, turbo_confirm: '确定要彻底删除吗?此操作无法恢复。' } do %>
117
+ <i class="fas fa-times-circle"></i>
118
+ <% end %>
119
+ <% end %>
120
+ <% else %>
121
+ <% # 自定义单条操作按钮 (仅在正常列表显示) %>
122
+ <% ( @resource.try(:member_actions).is_a?(Hash) ? @resource.member_actions : {} ).each do |name, config| %>
123
+ <% # 权限判定 %>
124
+ <% next unless is_super || current_admin_user.can?("#{@resource.name}:#{name}") %>
125
+
126
+ <%
127
+ action_url = if config[:url].is_a?(Symbol)
128
+ params_to_add = { "ids[]" => record.id }
129
+ if respond_to?(config[:url])
130
+ send(config[:url], params_to_add)
131
+ elsif main_app.respond_to?(config[:url])
132
+ main_app.send(config[:url], params_to_add)
133
+ else
134
+ config[:url].to_s
135
+ end
136
+ elsif config[:url].is_a?(Hash)
137
+ main_app.url_for(config[:url].merge(id: record.id, "ids[]" => record.id))
138
+ elsif config[:url].nil?
139
+ ui_resource_member_action_path(@resource.name, record, operation: name)
140
+ else
141
+ config[:url]
142
+ end
143
+ %>
144
+ <%= link_to action_url, class: "btn btn-outline-#{config[:type] == :default ? 'secondary' : (config[:type] == :success ? 'success' : config[:type])} py-1 px-2", title: config[:label], data: { turbo_method: :post, turbo_confirm: config[:confirm] } do %>
145
+ <% if config[:icon] %><i class="<%= config[:icon] %>"></i><% else %><%= config[:label] %><% end %>
146
+ <% end %>
147
+ <% end %>
148
+
149
+ <% if is_super || current_admin_user.can?("#{@resource.name}:show") %>
150
+ <%= link_to ui_resource_path(@resource.name, record), class: 'btn btn-outline-primary py-1 px-2', title: t('zen_admin.actions.view'), data: { turbo_frame: "modal" } do %>
151
+ <i class="fas fa-eye"></i>
152
+ <% end %>
153
+ <% end %>
154
+
155
+ <% if (is_super || current_admin_user.can?("#{@resource.name}:update")) && (!record.respond_to?(:zen_admin_editable?) || record.zen_admin_editable?) %>
156
+ <%= link_to edit_ui_resource_path(@resource.name, record), class: 'btn btn-outline-info py-1 px-2', title: t('zen_admin.actions.edit'), data: { turbo_frame: "modal" } do %>
157
+ <i class="fas fa-pencil-alt"></i>
158
+ <% end %>
159
+ <% end %>
160
+
161
+ <% if (is_super || current_admin_user.can?("#{@resource.name}:destroy")) && (!record.respond_to?(:zen_admin_deletable?) || record.zen_admin_deletable?) %>
162
+ <%= link_to ui_resource_path(@resource.name, record), data: { turbo_method: :delete, turbo_confirm: @resource.try(:soft_delete) ? '确定要移至回收站吗?' : t('zen_admin.ui.confirm_delete') }, class: 'btn btn-outline-danger py-1 px-2', title: t('zen_admin.actions.delete') do %>
163
+ <i class="fas fa-trash"></i>
164
+ <% end %>
165
+ <% end %>
166
+ <% end %>
167
+ </div>
168
+ </div>
169
+ </td>
170
+ </tr>
@@ -0,0 +1,2 @@
1
+ <%= turbo_stream.prepend "resources_table_body", partial: "row", locals: { record: @record } %>
2
+ <%= turbo_stream.update "flash", partial: "layouts/zen_admin/flash" %>
@@ -0,0 +1,2 @@
1
+ <%= turbo_stream.remove @record %>
2
+ <%= turbo_stream.update "flash", partial: "layouts/zen_admin/flash" %>
@@ -0,0 +1,11 @@
1
+ <% content_for :title, t('zen_admin.actions.edit', resource: @resource.label) %>
2
+ <% content_for :breadcrumb do %>
3
+ <li class="breadcrumb-item"><a href="<%= ui_resource_index_path(@resource.name) %>"><%= @resource.model.name.pluralize %></a></li>
4
+ <li class="breadcrumb-item active">Edit</li>
5
+ <% end %>
6
+
7
+ <div class="row">
8
+ <div class="col-md-12">
9
+ <%= render 'form', record: @record %>
10
+ </div>
11
+ </div>