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,285 @@
1
+ <%
2
+ resource_label = @resource.try(:label) || @resource.model.model_name.human
3
+ resource_label_plural = @resource.try(:label_plural) || @resource.model.model_name.human.pluralize
4
+ %>
5
+
6
+ <% content_for :title, params[:scope].to_s == 'trash' ? "回收站: #{resource_label_plural}" : "#{resource_label_plural} #{t('zen_admin.ui.list_suffix')}" %>
7
+
8
+ <% content_for :page_actions do %>
9
+ <div class="d-flex align-items-center gap-3">
10
+ <% if params[:scope].to_s == 'trash' %>
11
+ <%= link_to ui_resource_index_path(@resource.name), class: 'btn btn-outline-secondary btn-sm shadow-sm' do %>
12
+ <i class="fas fa-arrow-left me-1"></i> 返回列表
13
+ <% end %>
14
+ <% else %>
15
+ <% if @resource.actions.include?(:new) && current_admin_user.can?("#{@resource.name}:create") %>
16
+ <%= link_to new_ui_resource_path(@resource.name), class: 'btn btn-primary btn-sm shadow-sm', data: { turbo_frame: "modal" } do %>
17
+ <i class="fas fa-plus me-1"></i> <%= t('zen_admin.actions.new', resource: resource_label) %>
18
+ <% end %>
19
+ <% end %>
20
+
21
+ <span class="mx-1"></span>
22
+
23
+ <% if @resource.try(:soft_delete) && current_admin_user.can?("#{@resource.name}:destroy") %>
24
+ <%= link_to ui_resource_index_path(@resource.name, scope: 'trash'), class: 'btn btn-outline-secondary btn-sm shadow-sm' do %>
25
+ <i class="fas fa-trash-alt me-1"></i> 回收站
26
+ <% end %>
27
+ <% end %>
28
+ <% end %>
29
+ </div>
30
+ <% end %>
31
+
32
+ <% # 高级筛选面板 (折叠抽屉) %>
33
+ <% if @resource.try(:filter_fields).present? && params[:scope].to_s != 'trash' %>
34
+ <div class="collapse <%= 'show' if params[:q].present? %>" id="advancedFilter">
35
+ <div class="card card-body bg-white border-0 shadow-sm mb-4 py-4 rounded-3">
36
+ <h6 class="fw-bold text-dark mb-3 d-flex align-items-center">
37
+ <i class="fas fa-filter text-primary me-2"></i> 高级筛选条件
38
+ </h6>
39
+ <%= form_with url: ui_resource_index_path(@resource.name), method: :get, data: { turbo_frame: "_top" } do |f| %>
40
+ <%= hidden_field_tag :keyword, params[:keyword] %>
41
+ <%= hidden_field_tag :sort, params[:sort] %>
42
+ <div class="row g-3">
43
+ <% @resource.filter_fields.each do |filter| %>
44
+ <div class="col-md-3">
45
+ <label class="form-label small fw-bold text-muted text-uppercase mb-1"><%= filter.label %></label>
46
+ <% case filter.type.to_sym %>
47
+ <% when :string, :text %>
48
+ <input type="text" name="q[<%= filter.name %>_cont]" value="<%= params.dig(:q, "#{filter.name}_cont") %>" class="form-control form-control-sm" placeholder="搜索关键字...">
49
+ <% when :select %>
50
+ <% coll = filter.options[:collection] %>
51
+ <% coll = coll.call if coll.respond_to?(:call) %>
52
+ <select name="q[<%= filter.name %>_eq]" class="form-select form-select-sm">
53
+ <option value="">全部</option>
54
+ <% Array(coll).each do |lbl, val| %>
55
+ <option value="<%= val %>" <%= 'selected' if params.dig(:q, "#{filter.name}_eq").to_s == val.to_s %>><%= lbl %></option>
56
+ <% end %>
57
+ </select>
58
+ <% when :date_range %>
59
+ <div class="input-group input-group-sm">
60
+ <input type="date" name="q[<%= filter.name %>_gteq]" value="<%= params.dig(:q, "#{filter.name}_gteq") %>" class="form-control">
61
+ <span class="input-group-text bg-light border-0">至</span>
62
+ <input type="date" name="q[<%= filter.name %>_lteq]" value="<%= params.dig(:q, "#{filter.name}_lteq") %>" class="form-control">
63
+ </div>
64
+ <% end %>
65
+ </div>
66
+ <% end %>
67
+ <div class="col-12 mt-4 pt-2 border-top d-flex justify-content-end gap-2">
68
+ <a href="<%= ui_resource_index_path(@resource.name) %>" class="btn btn-link btn-sm text-secondary text-decoration-none">重置</a>
69
+ <button type="submit" class="btn btn-primary btn-sm px-4 shadow-sm fw-bold">立即筛选</button>
70
+ </div>
71
+ </div>
72
+ <% end %>
73
+ </div>
74
+ </div>
75
+ <% end %>
76
+
77
+ <% # 快捷页签 (仅在正常列表且定义了 scopes 时显示) %>
78
+ <% if @available_scopes.present? && @available_scopes.any? && params[:scope].to_s != 'trash' %>
79
+ <div class="mb-3">
80
+ <ul class="nav nav-pills zen-scope-pills p-1 bg-light rounded-pill d-inline-flex border">
81
+ <% @available_scopes.each do |s| %>
82
+ <%
83
+ is_active = (@active_scope_name == s[:name])
84
+ # 生成链接,保留除 scope_name 以外的所有搜索和排序参数
85
+ scope_path = ui_resource_index_path(@resource.name, params.to_unsafe_h.merge(scope_name: s[:name]).except(:page))
86
+ %>
87
+ <li class="nav-item">
88
+ <%= link_to scope_path, class: "nav-link rounded-pill py-1 px-4 #{'active shadow-sm' if is_active}", data: { turbo_frame: "_top" } do %>
89
+ <%= s[:label].is_a?(Symbol) ? t(s[:label]) : s[:label] %>
90
+ <% end %>
91
+ </li>
92
+ <% end %>
93
+ </ul>
94
+ </div>
95
+ <% end %>
96
+
97
+ <div class="card card-outline <%= params[:scope].to_s == 'trash' ? 'card-danger' : 'card-primary' %> shadow-sm" data-controller="bulk-actions">
98
+ <div class="card-header border-transparent d-flex align-items-center">
99
+ <div class="flex-grow-1">
100
+ <h3 class="card-title fw-bold mb-0" style="float: none !important;">
101
+ <%= params[:scope].to_s == 'trash' ? "回收站: #{resource_label_plural}" : "#{resource_label_plural} #{t('zen_admin.ui.list_suffix')}" %>
102
+ </h3>
103
+ </div>
104
+
105
+ <div class="card-tools ms-auto">
106
+ <div class="d-flex align-items-center gap-2">
107
+ <% if params[:scope].to_s == 'trash' %>
108
+ <%= link_to ui_resource_empty_trash_path(@resource.name), class: 'btn btn-danger btn-xs px-2 shadow-xs', data: { turbo_method: :delete, turbo_confirm: '确定要彻底删除回收站内的所有数据吗?此操作不可撤销。' } do %>
109
+ <i class="fas fa-eraser me-1"></i> 清空回收站
110
+ <% end %>
111
+ <% end %>
112
+
113
+ <% is_super = current_admin_user.respond_to?(:super_admin?) && current_admin_user.super_admin? %>
114
+ <% can_destroy = is_super || ZenAdmin::ResourcePolicy.new(current_admin_user, @resource.model).destroy? %>
115
+
116
+ <div class="btn-group shadow-xs">
117
+ <% unless params[:scope].to_s == 'trash' %>
118
+ <% ( @resource.batch_actions.is_a?(Hash) ? @resource.batch_actions : {} ).each do |name, config| %>
119
+ <% # 核心权限判定:检查 superadmin 或 具体的 resource:action 权限 %>
120
+ <% next unless is_super || current_admin_user.can?("#{@resource.name}:#{name}") %>
121
+ <% next if name == :delete && !can_destroy %>
122
+
123
+ <button type="button"
124
+ class="btn btn-outline-<%= config[:type] == :danger ? 'danger' : (config[:type] == :success ? 'success' : 'secondary') %> btn-xs px-2"
125
+ data-bulk-actions-target="actionButton"
126
+ data-action="click->bulk-actions#submit"
127
+ data-action-name="<%= name %>"
128
+ data-confirm="<%= config[:confirm] %>"
129
+ data-url="<%= if config[:url].is_a?(Symbol); main_app.respond_to?(config[:url]) ? main_app.send(config[:url]) : config[:url]; elsif config[:url].is_a?(Hash); main_app.url_for(config[:url]); else; config[:url]; end %>"
130
+ disabled>
131
+ <i class="fas fa-<%= name == :delete ? 'trash-alt' : 'bolt' %> me-1"></i>
132
+ <%= config[:label].is_a?(Symbol) ? t(config[:label]) : config[:label] %>
133
+ </button>
134
+ <% end %>
135
+ <% end %>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </div>
140
+
141
+ <div class="card-body p-0">
142
+ <div class="px-3 py-2 border-bottom bg-light">
143
+ <%= form_with url: ui_resource_index_path(@resource.name), method: :get, id: "search_form", data: { turbo_frame: "_top" } do |f| %>
144
+ <%= hidden_field_tag :sort, params[:sort] %>
145
+ <%= hidden_field_tag :scope, params[:scope] %>
146
+ <%= hidden_field_tag :limit, params[:limit] %>
147
+
148
+ <div class="row g-2 align-items-center">
149
+ <div class="col">
150
+ <% if @resource.model.respond_to?(:ransack) %>
151
+ <div class="zen-search-container">
152
+ <i class="fas fa-search zen-search-icon"></i>
153
+ <input type="text" name="keyword" value="<%= params[:keyword] %>" class="form-control zen-search-input" placeholder="<%= t('zen_admin.ui.search') %>..." autocomplete="off">
154
+ <input type="submit" style="display: none;">
155
+ </div>
156
+ <% end %>
157
+ </div>
158
+
159
+ <div class="col-auto">
160
+ <% if @resource.try(:filter_fields).present? && params[:scope].to_s != 'trash' %>
161
+ <button class="btn btn-default btn-sm border shadow-xs" type="button"
162
+ data-bs-toggle="collapse" data-bs-target="#advancedFilter"
163
+ data-toggle="collapse" data-target="#advancedFilter">
164
+ <i class="fas fa-filter text-muted me-1"></i> 筛选
165
+ </button>
166
+ <% end %>
167
+
168
+ <% if @resource.try(:exportable) %>
169
+ <%= link_to ui_resource_index_path(@resource.name, params.to_unsafe_h.merge(format: :csv)),
170
+ class: 'btn btn-default btn-sm border shadow-xs',
171
+ data: { turbo: false } do %>
172
+ <i class="fas fa-file-export text-muted me-1"></i> 导出
173
+ <% end %>
174
+ <% end %> </div>
175
+ </div>
176
+ <% end %>
177
+ </div>
178
+
179
+ <%= form_with url: ui_resource_bulk_action_path(@resource.name), method: :post, id: "bulk_action_form" do |f| %>
180
+ <input type="hidden" name="bulk_operation" id="bulk_operation_input" value="">
181
+ <div class="table-responsive">
182
+ <table class="table table-hover table-striped mb-0 align-middle">
183
+ <thead>
184
+ <tr class="bg-light align-middle">
185
+ <% if @resource.batch_actions.is_a?(Hash) && @resource.batch_actions.any? %>
186
+ <th style="width: 50px; min-width: 50px;" class="text-center py-3">
187
+ <% if @records.any? %>
188
+ <div class="d-inline-flex align-items-center justify-content-center" style="vertical-align: top;">
189
+ <input type="checkbox" class="form-check-input m-0" id="checkAll" data-bulk-actions-target="selectAll" data-action="change->bulk-actions#toggleAll">
190
+ </div>
191
+ <% end %>
192
+ </th>
193
+ <% end %>
194
+ <% @resource.list_fields.each do |field| %>
195
+ <th class="fw-bold py-3 text-nowrap text-center">
196
+ <div class="d-inline-flex align-items-center justify-content-center w-100" style="vertical-align: top;">
197
+ <% if field.options[:sortable] %>
198
+ <% current_dir = current_sort_direction(field.name) %>
199
+ <a href="<%= sort_url(field.name) %>" class="d-inline-flex align-items-center justify-content-center text-decoration-none text-dark small" data-turbo-frame="_top">
200
+ <span><%= field.label %></span>
201
+ <span class="ms-2"><i class="fas fa-sort-<%= current_dir == 'asc' ? 'amount-down-alt' : (current_dir == 'desc' ? 'amount-down' : 'sort') %> <%= current_dir ? 'text-primary' : 'text-muted opacity-50' %>" style="font-size: 0.8rem;"></i></span>
202
+ </a>
203
+ <% else %>
204
+ <span class="small"><%= field.label %></span>
205
+ <% end %>
206
+ </div>
207
+ </th>
208
+ <% end %>
209
+ <th class="fw-bold py-3 text-center">
210
+ <div class="d-inline-flex align-items-center justify-content-center w-100" style="vertical-align: top;">
211
+ <span class="small"><%= t('zen_admin.ui.actions') %></span>
212
+ </div>
213
+ </th>
214
+ </tr>
215
+ </thead>
216
+ <tbody id="resources_table_body">
217
+ <%= render partial: "row", collection: @records, as: :record %>
218
+ <% if @records.empty? %>
219
+ <tr>
220
+ <td colspan="<%= @resource.list_fields.count + (@resource.batch_actions.is_a?(Hash) && @resource.batch_actions.any? ? 2 : 1) %>" class="text-center py-5 text-muted">
221
+ <i class="fas fa-inbox fa-2x mb-2 d-block opacity-25"></i>
222
+ <%= t('zen_admin.ui.no_data') %>
223
+ </td>
224
+ </tr>
225
+ <% end %>
226
+ </tbody>
227
+ </table>
228
+ </div>
229
+ <% end %>
230
+ </div>
231
+
232
+ <div class="card-footer bg-white py-3">
233
+ <div class="d-flex flex-column flex-md-row justify-content-between align-items-center">
234
+ <div class="text-muted small mb-2 mb-md-0">
235
+ <!-- Removed page_entries_info as requested -->
236
+ </div>
237
+
238
+ <div class="d-flex align-items-center">
239
+ <div class="zen-pagination pagination-sm me-4">
240
+ <%= paginate @records %>
241
+ </div>
242
+
243
+ <% # 智能显示页容量选择 (仅当总数 > 10 时) %>
244
+ <% if @records.total_count > 10 %>
245
+ <div class="d-flex align-items-center ms-2 border-start ps-4">
246
+ <span class="text-muted small me-2 text-nowrap"><%= t('zen_admin.ui.show_limit') %></span>
247
+ <form action="<%= request.path %>" method="get" class="d-inline" data-turbo-frame="_top">
248
+ <% # 复制当前所有搜索参数 %>
249
+ <% (params.to_unsafe_h.except(:controller, :action, :limit, :page)).each do |k, v| %>
250
+ <% if v.is_a?(Hash) %>
251
+ <% v.each { |sk, sv| %> <input type="hidden" name="<%= k %>[<%= sk %>]" value="<%= sv %>"> <% } %>
252
+ <% else %>
253
+ <input type="hidden" name="<%= k %>" value="<%= v %>">
254
+ <% end %>
255
+ <% end %>
256
+ <select name="limit" class="form-select form-select-sm" style="width: auto; height: 31px;" onchange="this.form.submit()">
257
+ <%= options_for_select([10, 25, 50, 100], selected: params[:limit] || 10) %>
258
+ </select>
259
+ </form>
260
+ </div>
261
+ <% end %>
262
+ </div>
263
+ </div>
264
+ </div>
265
+ </div>
266
+
267
+ <style>
268
+ .btn-xs { padding: 0.125rem 0.25rem; font-size: 0.75rem; border-radius: 0.15rem; }
269
+ .card-outline.card-primary { border-top: 3px solid var(--primary-color); }
270
+ .table thead th { border-bottom: 2px solid #dee2e6; }
271
+
272
+ /* 搜索框美化 */
273
+ .zen-search-container { position: relative; width: 200px; transition: all 0.3s ease; }
274
+ .zen-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: #86909c; z-index: 5; font-size: 0.85rem; pointer-events: none; }
275
+ .zen-search-input { padding-left: 34px !important; padding-right: 12px !important; border-radius: 20px !important; background-color: #f2f3f5 !important; border: 1px solid transparent !important; height: 34px !important; font-size: 0.85rem !important; transition: all 0.2s ease-in-out !important; width: 100% !important; }
276
+ .zen-search-input:focus { background-color: #fff !important; border-color: var(--primary-color) !important; box-shadow: 0 0 0 3px rgba(22, 93, 255, 0.1) !important; }
277
+ .zen-search-container:focus-within { width: 240px; }
278
+
279
+ .form-check-input { cursor: pointer; width: 1.1rem; height: 1.1rem; margin: 0 !important; }
280
+ .table th .d-inline-flex, .table td .d-inline-flex { line-height: 1; height: 1.25rem; }
281
+ .shadow-xs { box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
282
+
283
+ /* 列表底部隐约阴影 */
284
+ .card-footer { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); border-bottom-left-radius: 0.25rem; border-bottom-right-radius: 0.25rem; z-index: 1; position: relative; }
285
+ </style>
@@ -0,0 +1,11 @@
1
+ <% content_for :title, t('zen_admin.actions.new', 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">New</li>
5
+ <% end %>
6
+
7
+ <div class="row">
8
+ <div class="col-md-12">
9
+ <%= render 'form', record: @record %>
10
+ </div>
11
+ </div>
@@ -0,0 +1,133 @@
1
+ <% content_for :title, "#{@resource.label} #{t('zen_admin.actions.view')}" %>
2
+ <%= turbo_frame_tag "modal" do %>
3
+ <div class="modal fade" tabindex="-1" data-controller="modal">
4
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
5
+ <div class="modal-content">
6
+ <div class="modal-header">
7
+ <h4 class="modal-title"><%= @resource.label %> <%= t('zen_admin.actions.view') %></h4>
8
+ <button type="button" class="close" data-action="modal#close" aria-label="Close">
9
+ <span aria-hidden="true">&times;</span>
10
+ </button>
11
+ </div>
12
+ <div class="modal-body">
13
+ <table class="table table-bordered table-striped">
14
+ <%
15
+ # --- Advanced Auto-Discovery Logic ---
16
+ fields = if @resource.show_fields.any?
17
+ @resource.show_fields
18
+ else
19
+ discovered = []
20
+ reflections = @resource.model.reflect_on_all_associations
21
+
22
+ @resource.model.columns.each do |col|
23
+ next if col.name == "id"
24
+ assoc_name = col.name.gsub(/_id$/, "")
25
+ reflection = reflections.find { |r| r.name.to_s == assoc_name && r.macro == :belongs_to }
26
+
27
+ if reflection
28
+ discovered << ZenAdmin::Core::Field.new(reflection.name.to_sym, type: :belongs_to, label: reflection.name.to_s.humanize)
29
+ else
30
+ discovered << ZenAdmin::Core::Field.new(col.name.to_sym, type: col.type.to_sym)
31
+ end
32
+ end
33
+
34
+ reflections.each do |r|
35
+ next if r.macro == :belongs_to && discovered.any? { |f| f.name == r.name }
36
+ if r.class_name == "ActiveStorage::Attachment"
37
+ discovered << ZenAdmin::Core::Field.new(r.name.to_s.gsub(/_attachment$/, "").to_sym, type: :file)
38
+ elsif r.class_name == "ActionText::RichText"
39
+ discovered << ZenAdmin::Core::Field.new(r.name.to_s.gsub(/^rich_text_/, "").to_sym, type: :rich_text)
40
+ elsif [:has_many, :has_and_belongs_to_many].include?(r.macro)
41
+ discovered << ZenAdmin::Core::Field.new(r.name.to_sym, type: :association)
42
+ end
43
+ end
44
+ discovered.uniq(&:name)
45
+ end
46
+ %>
47
+
48
+ <% fields.each do |field| %>
49
+ <tr>
50
+ <th style="width: 30%; background-color: #f9f9f9;"><%= field.label %></th>
51
+ <td>
52
+ <% value = @record.send(field.name) rescue nil %>
53
+
54
+ <% # 2. 进行 Collection 映射 %>
55
+ <% mapped_label = if field.options[:collection] %>
56
+ <% collection = field.options[:collection] %>
57
+ <% collection = collection.call if collection.respond_to?(:call) %>
58
+ <% mapping_item = collection.find { |l, v| v.to_s == value.to_s } %>
59
+ <% mapping_item ? mapping_item[0] : value %>
60
+ <% else %>
61
+ <% value %>
62
+ <% end %>
63
+
64
+ <% if field.options[:formatter] %>
65
+ <%= instance_exec(@record, mapped_label, &field.options[:formatter]) %>
66
+ <% else %>
67
+ <% if mapped_label.nil? %>
68
+ <span class="text-muted italic">-</span>
69
+ <% elsif field.type == :belongs_to || (mapped_label.is_a?(ActiveRecord::Base) && !mapped_label.is_a?(ActionText::RichText)) %>
70
+ <% assoc_res = ZenAdmin::Registry.instance[mapped_label.class] %>
71
+ <% display = mapped_label.try(:name) || mapped_label.try(:title) || "##{mapped_label.id}" %>
72
+ <% if assoc_res %>
73
+ <%= link_to display, ui_resource_path(assoc_res.name, mapped_label), data: { turbo_frame: "modal" }, class: "text-primary font-weight-bold" %>
74
+ <% else %>
75
+ <%= display %>
76
+ <% end %>
77
+ <% elsif mapped_label.is_a?(ActiveRecord::Associations::CollectionProxy) || mapped_label.is_a?(Array) %>
78
+ <% mapped_label.each do |item| %>
79
+ <% item_res = ZenAdmin::Registry.instance[item.class] %>
80
+ <% item_display = item.try(:name) || item.try(:title) || "##{item.id}" %>
81
+ <% if item_res %>
82
+ <%= link_to ui_resource_path(item_res.name, item), data: { turbo_frame: "modal" }, class: "badge badge-info p-2 mb-1" do %>
83
+ <i class="fas fa-link mr-1"></i><%= item_display %>
84
+ <% end %>
85
+ <% else %>
86
+ <span class="badge badge-secondary p-2 mb-1"><%= item_display %></span>
87
+ <% end %>
88
+ <% end %>
89
+ <% elsif field.type == :file || (mapped_label.respond_to?(:attached?) && mapped_label.respond_to?(:variant)) %>
90
+ <% if mapped_label.respond_to?(:attached?) && mapped_label.attached? %>
91
+ <div class="attachment-preview border p-2 rounded d-inline-block bg-white shadow-sm">
92
+ <% if mapped_label.representable? %>
93
+ <%= image_tag main_app.url_for(mapped_label.representation(resize_to_limit: [1024, 1024])), style: "max-height: 250px; max-width: 100%; display: block;" %>
94
+ <div class="mt-2 text-center small text-muted"><%= mapped_label.filename %></div>
95
+ <% else %>
96
+ <i class="fas fa-file-download mr-1 text-primary"></i>
97
+ <%= link_to mapped_label.filename.to_s, main_app.rails_blob_path(mapped_label, disposition: "attachment"), target: "_blank" %>
98
+ <% end %>
99
+ </div>
100
+ <% else %>
101
+ <span class="text-muted small">-</span>
102
+ <% end %>
103
+ <% elsif field.type == :rich_text || (value.respond_to?(:body) && value.class.name.include?("ActionText")) %>
104
+ <div class="trix-content border p-3 rounded bg-white shadow-sm" style="max-height: 400px; overflow-y: auto;">
105
+ <%= mapped_label %>
106
+ </div>
107
+ <% elsif mapped_label == true || mapped_label == false %>
108
+ <span class="badge badge-<%= mapped_label ? 'success' : 'secondary' %>">
109
+ <i class="fas fa-<%= mapped_label ? 'check' : 'times' %> mr-1"></i><%= mapped_label ? '是' : '否' %>
110
+ </span>
111
+ <% elsif mapped_label.is_a?(Time) || mapped_label.is_a?(DateTime) || mapped_label.is_a?(ActiveSupport::TimeWithZone) %>
112
+ <%
113
+ tz = ZenAdmin.configuration.try(:time_zone) || Time.zone.name
114
+ display_time = mapped_label.in_time_zone(tz).strftime("%Y-%m-%d %H:%M:%S")
115
+ %>
116
+ <i class="far fa-clock text-muted me-1"></i><%= display_time %>
117
+ <% else %>
118
+ <%= mapped_label %>
119
+ <% end %>
120
+ <% end %>
121
+ </td>
122
+ </tr>
123
+ <% end %>
124
+ </table>
125
+ </div>
126
+ <div class="modal-footer justify-content-between">
127
+ <button type="button" class="btn btn-default" data-action="modal#close"><%= t('zen_admin.ui.close') rescue '关闭' %></button>
128
+ <%= link_to t('zen_admin.actions.edit'), edit_ui_resource_path(@resource.name, @record), class: "btn btn-primary", data: { turbo_frame: "modal" } %>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <%= turbo_stream.replace dom_id(@record), partial: "row", locals: { record: @record } %>
2
+ <%= turbo_stream.update "flash", partial: "layouts/zen_admin/flash" %>
@@ -0,0 +1,10 @@
1
+ pin "zen_admin/application", to: "zen_admin/application.js"
2
+ pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
3
+ pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
4
+ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
5
+ pin "trix"
6
+ pin "@rails/actiontext", to: "actiontext.esm.js"
7
+ pin "@rails/activestorage", to: "activestorage.esm.js"
8
+
9
+ # Use absolute path to pin controllers from the engine
10
+ pin_all_from ZenAdmin::Engine.root.join("app/javascript/controllers"), under: "controllers"
@@ -0,0 +1,107 @@
1
+ en:
2
+ zen_admin:
3
+ ui:
4
+ search: "Search"
5
+ show_limit: "Show"
6
+ items_per_page: "items"
7
+ no_data: "No records found."
8
+ list_suffix: "List"
9
+ actions: "Actions"
10
+ confirm_delete: "Are you sure you want to delete this?"
11
+ confirm_bulk_delete: "Are you sure you want to delete the selected {count} items?"
12
+ select_all: "Select All"
13
+ close: "Close"
14
+ save: "Save"
15
+ cancel: "Cancel"
16
+ errors: "Errors!"
17
+ related_records: "Related Records"
18
+ no_related_records: "No related records found."
19
+ click_to_upload: "Click to upload %{field}"
20
+ change_image: "Click to change image"
21
+ multi_select_help: "Hold Ctrl (or Command) to select multiple items"
22
+ actions:
23
+ new: "Add %{resource}"
24
+ view: "View"
25
+ edit: "Edit"
26
+ delete: "Delete"
27
+ bulk_delete: "Bulk Delete"
28
+ copy: "Copy"
29
+ restore: "Restore"
30
+ move_to_trash: "Move to Trash"
31
+ messages:
32
+ created: "Successfully created %{resource}."
33
+ updated: "Successfully updated %{resource}."
34
+ destroyed: "Successfully deleted %{resource}."
35
+ bulk_destroyed: "Successfully deleted %{count} records."
36
+ restored: "Successfully restored %{resource}."
37
+ moved_to_trash: "Moved %{resource} to trash."
38
+
39
+ builtin:
40
+ assets: "Asset Library"
41
+ attachments: "System Attachments"
42
+ attachment_info: "This list contains all attachments automatically associated via ActiveStorage (e.g., rich text illustrations)."
43
+
44
+ models:
45
+ "active_storage/attachment":
46
+ one: "Attachment"
47
+ other: "Attachments"
48
+ fields:
49
+ id: "Preview"
50
+ record_type: "Record Type"
51
+ name: "Field Name"
52
+ blob_filename: "Filename"
53
+ blob_id: "Copy Link"
54
+ record: "Record"
55
+ blob: "Blob"
56
+ asset:
57
+ one: "Asset"
58
+ other: "Assets"
59
+ fields:
60
+ file: "Preview"
61
+ name: "Name/Note"
62
+ id: "Copy Link"
63
+ created_at: "Created At"
64
+ user:
65
+ one: "User"
66
+ other: "Users"
67
+ fields:
68
+ username: "Username"
69
+ password: "Password"
70
+ roles: "Roles"
71
+ created_at: "Created At"
72
+ role:
73
+ one: "Role"
74
+ other: "Roles"
75
+ fields:
76
+ name: "Role Name"
77
+ code: "Code"
78
+ permissions: "Permissions"
79
+ permission_ids: "Permission Assignment"
80
+ permission:
81
+ one: "Permission"
82
+ other: "Permissions"
83
+ fields:
84
+ name: "Permission Name"
85
+ code: "Identifier"
86
+ audit_log:
87
+ one: "Audit Log"
88
+ other: "Audit Logs"
89
+ fields:
90
+ admin_username: "Operator"
91
+ resource_type: "Resource"
92
+ resource_id: "ID"
93
+ action: "Action"
94
+ created_at: "Time"
95
+ changes_data: "Changes"
96
+ ip_address: "IP Address"
97
+ trash_item:
98
+ one: "Trash Item"
99
+ other: "Trash Items"
100
+
101
+ views:
102
+ pagination:
103
+ first: "First"
104
+ last: "Last"
105
+ previous: "Prev"
106
+ next: "Next"
107
+ truncate: "..."