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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f2dd3dea01b5b2f2f61dbe4b710f782f03350e8f8a5798ac3afa463d21732570
4
+ data.tar.gz: 89088d988f3a3b225253e8b429f41a9a786eb551779b54a54ab06b4860f08f02
5
+ SHA512:
6
+ metadata.gz: a6842819ce0af246eef49871f612995b018029c96f33a93409cbb33722f6ed8ca4f886113b34ba1bf6eb6c1576c0689383993a30355771c7a97d26e4db055fca
7
+ data.tar.gz: 392fcfa165fa9b24ebeac3adae34af1dab06ebd1b6e8bd6b9ca1bc9ba0f0db933d843cdfcac49fb7310dfe120393da16c21eeb86364e65bbf6099def56ee1a28
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # ZenAdmin
2
+ Rails engine for admin interface with Headless API and Bootstrap UI.
3
+
4
+ ## Installation
5
+ Add this line to your application's Gemfile:
6
+
7
+ ```ruby
8
+ gem "zen_admin"
9
+ ```
10
+
11
+ And then execute:
12
+ ```bash
13
+ $ bundle
14
+ ```
15
+
16
+ Or install it yourself as:
17
+ ```bash
18
+ $ gem install zen_admin
19
+ ```
20
+
21
+ ## Usage
22
+ 1. Mount the engine in your routes:
23
+ ```ruby
24
+ # config/routes.rb
25
+ mount ZenAdmin::Engine => "/admin"
26
+ ```
27
+
28
+ 2. Configure ZenAdmin:
29
+ ```ruby
30
+ # config/initializers/zen_admin.rb
31
+ ZenAdmin.configure do |config|
32
+ config.enable_ui = true
33
+ config.admin_path = "/admin"
34
+ end
35
+ ```
36
+
37
+ 3. Register your models:
38
+ ```ruby
39
+ # config/initializers/zen_admin.rb
40
+ ZenAdmin.register(User) do |resource|
41
+ resource.list do
42
+ field :email
43
+ field :name
44
+ end
45
+
46
+ resource.form do
47
+ field :email, type: :string, required: true
48
+ field :name, type: :string
49
+ end
50
+
51
+ resource.filters do
52
+ filter :email
53
+ end
54
+ end
55
+ ```
56
+
57
+ 4. Access the admin interface at `/admin`
58
+
59
+ ## API Endpoints
60
+ - `GET /admin/api/schema/:resource` - Get resource schema
61
+ - `GET /admin/api/resources/:resource_name` - List resources
62
+ - `POST /admin/api/resources/:resource_name` - Create resource
63
+ - `GET /admin/api/resources/:resource_name/:id` - Show resource
64
+ - `PUT /admin/api/resources/:resource_name/:id` - Update resource
65
+ - `DELETE /admin/api/resources/:resource_name/:id` - Delete resource
66
+
67
+ ## UI Pages
68
+ - `/admin/ui/:resource_name` - List resources
69
+ - `/admin/ui/:resource_name/new` - New resource form
70
+ - `/admin/ui/:resource_name/:id/edit` - Edit resource form
71
+
72
+ ## Contributing
73
+ Contribution directions go here.
74
+
75
+ ## License
76
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,27 @@
1
+ module ZenAdmin
2
+ class ApplicationController < ActionController::Base
3
+ include ZenAdmin::ApplicationHelper
4
+ include ZenAdmin::Authenticatable
5
+ include Pundit::Authorization
6
+
7
+ def self.zen_admin_dashboard(template_path)
8
+ ZenAdmin.configuration.custom_dashboard = template_path
9
+ end
10
+
11
+ rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
12
+
13
+ before_action :authenticate_admin!
14
+ helper_method :current_admin_user
15
+
16
+ def pundit_user
17
+ current_admin_user
18
+ end
19
+
20
+ private
21
+
22
+ def user_not_authorized
23
+ flash[:alert] = "您没有执行此操作的权限。"
24
+ redirect_back fallback_location: root_path
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,9 @@
1
+ module ZenAdmin
2
+ class DashboardController < ApplicationController
3
+ def index
4
+ if ZenAdmin.configuration.custom_dashboard
5
+ render ZenAdmin.configuration.custom_dashboard
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,40 @@
1
+ module ZenAdmin
2
+ class SessionsController < ApplicationController
3
+ skip_before_action :authenticate_admin!, only: [ :new, :create ], raise: false
4
+ layout false
5
+
6
+ def new
7
+ end
8
+
9
+ def create
10
+ # 1. 如果启用了 RBAC 且数据库表存在,优先走数据库验证
11
+ if ZenAdmin.configuration.rbac_enable && ActiveRecord::Base.connection.table_exists?("zen_admin_users")
12
+ user = ZenAdmin::User.find_by(username: params[:username])
13
+ if user&.authenticate(params[:password])
14
+ session[:zen_admin_user_id] = user.id
15
+ return redirect_to root_path, notice: "欢迎回来,#{user.username}!"
16
+ end
17
+
18
+ # 如果开启了 RBAC 但数据库验证失败,且未找到用户,则不再尝试硬编码登录 (防止后门)
19
+ # 除非配置显式允许混合模式 (当前未实现,故严格安全优先)
20
+ flash.now[:alert] = "用户名或密码错误"
21
+ return render :new, status: :unprocessable_entity
22
+ end
23
+
24
+ # 2. 仅在未开启 RBAC 时,使用硬编码验证
25
+ if params[:username] == ZenAdmin.configuration.username &&
26
+ params[:password] == ZenAdmin.configuration.password
27
+ session[:zen_admin_user_id] = "config_admin"
28
+ redirect_to root_path, notice: "登录成功(基础模式)"
29
+ else
30
+ flash.now[:alert] = "用户名或密码错误"
31
+ render :new, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ session[:zen_admin_user_id] = nil
37
+ redirect_to login_path, notice: "已安全退出"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,331 @@
1
+ module ZenAdmin
2
+ module Ui
3
+ class ResourcesController < ApplicationController
4
+ layout -> { turbo_frame_request? ? false : "zen_admin/application" }
5
+ before_action :set_time_zone
6
+ before_action :find_resource
7
+
8
+ def index
9
+ authorize @resource.model, policy_class: ZenAdmin::ResourcePolicy
10
+
11
+ # 1. 强制为当前模型注入 Ransack 权限
12
+ if @resource.model.respond_to?(:ransack)
13
+ @resource.model.instance_eval do
14
+ def ransackable_attributes(auth_object = nil); column_names; end
15
+ def ransackable_associations(auth_object = nil); reflect_on_all_associations.map { |a| a.name.to_s }; end
16
+ end
17
+ end
18
+
19
+ if params[:scope] == 'trash'
20
+ # Accessing trash requires destroy permission
21
+ unless current_admin_user.can?("#{@resource.name}:destroy") || (current_admin_user.respond_to?(:super_admin?) && current_admin_user.super_admin?)
22
+ raise Pundit::NotAuthorizedError, "not allowed to access trash for #{@resource.name}"
23
+ end
24
+
25
+ @records = ZenAdmin::TrashItem.where(resource_type: @resource.model_name)
26
+ .order(created_at: :desc)
27
+ .page(params[:page]).per(params[:limit] || 10)
28
+ else
29
+ # 2. 正常列表:构建 Ransack 搜索
30
+ q_params = params[:q].to_unsafe_h rescue {}
31
+
32
+ # 2.1 处理快捷页签 (Scopes)
33
+ @available_scopes = @resource.computed_scopes(self)
34
+ if @available_scopes.any?
35
+ active_scope = if params[:scope_name].present?
36
+ @available_scopes.find { |s| s[:name].to_s == params[:scope_name].to_s }
37
+ else
38
+ @available_scopes.find { |s| s[:default] } || @available_scopes.first
39
+ end
40
+
41
+ if active_scope && active_scope[:query].present?
42
+ q_params.merge!(active_scope[:query])
43
+ @active_scope_name = active_scope[:name]
44
+ end
45
+ end
46
+
47
+ # 3. 处理顶栏关键字
48
+ if params[:keyword].present?
49
+ @search_term = params[:keyword]
50
+ searchable_columns = @resource.model.columns.select { |c| [:string, :text].include?(c.type) }.map(&:name)
51
+ if searchable_columns.any?
52
+ search_key = searchable_columns.map { |name| "#{name}_cont" }.join("_or_")
53
+ q_params[search_key] = @search_term
54
+ end
55
+ end
56
+
57
+ # 4. 增强日期筛选逻辑:自动补全结束时间为 23:59:59
58
+ q_params.each do |key, value|
59
+ if key.to_s.end_with?("_lteq", "_lt") && value.present? && value.is_a?(String)
60
+ # 如果值看起来像纯日期 (YYYY-MM-DD)
61
+ if value =~ /^\d{4}-\d{2}-\d{2}$/
62
+ begin
63
+ q_params[key] = Time.zone.parse(value).end_of_day
64
+ rescue
65
+ # 解析失败保持原样
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ @q = @resource.model.ransack(q_params)
72
+ @q.sorts = 'created_at desc' if @q.sorts.empty? && @resource.model.column_names.include?("created_at")
73
+
74
+ scope = @q.result(distinct: true)
75
+
76
+ # 预加载
77
+ includes_to_load = @resource.includes_list.dup
78
+ @resource.list_fields.each do |field|
79
+ field_name = field.name.to_sym
80
+ includes_to_load << field_name if @resource.model.reflect_on_association(field_name)
81
+ if @resource.model.reflect_on_association("#{field_name}_attachment".to_sym)
82
+ includes_to_load << { "#{field_name}_attachment": :blob }
83
+ end
84
+ end
85
+ scope = scope.includes(*includes_to_load.uniq) if includes_to_load.any?
86
+
87
+ respond_to do |format|
88
+ format.html do
89
+ if turbo_frame_request? && turbo_frame_request_id == 'modal'
90
+ render html: "<script>window.location.reload();</script>".html_safe, layout: false and return
91
+ end
92
+ @records = scope.page(params[:page]).per(params[:limit] || 10)
93
+ end
94
+ format.csv do
95
+ export_count = scope.respond_to?(:count) ? scope.count : scope.size
96
+ ZenAdmin::AuditLog.record(current_admin_user, @resource.model, 'export', note: "导出了 #{export_count} 条数据", request: request)
97
+ send_data generate_csv(scope),
98
+ filename: "#{@resource.model_name.underscore}_#{Time.current.strftime('%Y%m%d%H%M')}.csv",
99
+ type: 'text/csv; charset=utf-8'
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ def show
106
+ @record = @resource.model.find(params[:id])
107
+ authorize @record, policy_class: ZenAdmin::ResourcePolicy
108
+ render layout: false if turbo_frame_request?
109
+ end
110
+
111
+ def new
112
+ @record = @resource.model.new
113
+
114
+ # DEBUG LOGGING
115
+ permission_key = "#{@resource.name}:create"
116
+ has_permission = current_admin_user.can?(permission_key)
117
+ is_super = current_admin_user.respond_to?(:super_admin?) && current_admin_user.super_admin?
118
+ Rails.logger.error "🛑 SECURITY CHECK: User=#{current_admin_user.username} | Permission Key=[#{permission_key}] | Has Permission?=#{has_permission} | Is Super?=#{is_super}"
119
+
120
+ # 显式双重校验,防止 Pundit 策略推断异常
121
+ unless has_permission || is_super
122
+ raise Pundit::NotAuthorizedError, "not allowed to create? this #{@resource.name}"
123
+ end
124
+
125
+ authorize @record, policy_class: ZenAdmin::ResourcePolicy
126
+ render layout: false if turbo_frame_request?
127
+ end
128
+ def create
129
+ @record = @resource.model.new(permitted_params)
130
+ authorize @record, policy_class: ZenAdmin::ResourcePolicy
131
+ respond_to do |format|
132
+ if @record.save
133
+ ZenAdmin::AuditLog.record(current_admin_user, @record, 'create', changes: @record.attributes, request: request)
134
+ format.html { redirect_to ui_resource_index_path(@resource.name), notice: t('zen_admin.messages.created', resource: @resource.label) }
135
+ format.turbo_stream { flash.now[:notice] = t('zen_admin.messages.created', resource: @resource.label) }
136
+ else
137
+ format.html { render :new, status: :unprocessable_entity, layout: (false if turbo_frame_request?) }
138
+ end
139
+ end
140
+ end
141
+
142
+ def edit
143
+ @record = @resource.model.find(params[:id])
144
+
145
+ # 显式双重校验
146
+ unless current_admin_user.can?("#{@resource.name}:update") || (current_admin_user.respond_to?(:super_admin?) && current_admin_user.super_admin?)
147
+ raise Pundit::NotAuthorizedError, "not allowed to update? this #{@resource.name}"
148
+ end
149
+
150
+ authorize @record, policy_class: ZenAdmin::ResourcePolicy
151
+ render layout: false if turbo_frame_request?
152
+ end
153
+ def update
154
+ @record = @resource.model.find(params[:id])
155
+ authorize @record, policy_class: ZenAdmin::ResourcePolicy
156
+ respond_to do |format|
157
+ if @record.update(permitted_params)
158
+ ZenAdmin::AuditLog.record(current_admin_user, @record, 'update', changes: @record.saved_changes, request: request)
159
+ format.html { redirect_to ui_resource_index_path(@resource.name), notice: t('zen_admin.messages.updated', resource: @resource.label) }
160
+ format.turbo_stream { flash.now[:notice] = t('zen_admin.messages.updated', resource: @resource.label) }
161
+ else
162
+ format.html { render :edit, status: :unprocessable_entity, layout: (false if turbo_frame_request?) }
163
+ end
164
+ end
165
+ end
166
+
167
+ def destroy
168
+ @record = @resource.model.find_by(id: params[:id])
169
+ if @record
170
+ authorize @record, policy_class: ZenAdmin::ResourcePolicy
171
+ if params[:permanent] == 'true'
172
+ ZenAdmin::AuditLog.record(current_admin_user, @record, 'destroy', changes: @record.attributes, request: request)
173
+ @record.destroy
174
+ notice_msg = "已永久删除该数据。"
175
+ else
176
+ ZenAdmin::TrashItem.move_to_trash(@record, current_admin_user)
177
+ ZenAdmin::AuditLog.record(current_admin_user, @record, 'move_to_trash', note: "移至回收站", request: request)
178
+ notice_msg = t('zen_admin.messages.moved_to_trash', resource: @resource.label)
179
+ end
180
+ else
181
+ @trash_item = ZenAdmin::TrashItem.find(params[:id])
182
+ authorize @resource.model, :destroy?, policy_class: ZenAdmin::ResourcePolicy
183
+ @trash_item.destroy
184
+ notice_msg = "已从回收站彻底清除。"
185
+ end
186
+ redirect_to ui_resource_index_path(@resource.name, scope: params[:scope]), notice: notice_msg
187
+ end
188
+
189
+ def restore
190
+ @trash_item = ZenAdmin::TrashItem.find(params[:id])
191
+ authorize @resource.model, :create?, policy_class: ZenAdmin::ResourcePolicy
192
+ result = @trash_item.restore!
193
+ if result[:success]
194
+ ZenAdmin::AuditLog.record(current_admin_user, @resource.model, 'restore', note: "从回收站恢复了: #{@trash_item.resource_label}", request: request)
195
+ redirect_to ui_resource_index_path(@resource.name), notice: t('zen_admin.messages.restored', resource: @resource.label)
196
+ else
197
+ redirect_to ui_resource_index_path(@resource.name, scope: 'trash'), alert: result[:message]
198
+ end
199
+ end
200
+
201
+ def empty_trash
202
+ authorize @resource.model, :destroy?, policy_class: ZenAdmin::ResourcePolicy
203
+ items = ZenAdmin::TrashItem.where(resource_type: @resource.model_name)
204
+ count = items.count
205
+ items.destroy_all
206
+ ZenAdmin::AuditLog.record(current_admin_user, @resource.model, 'empty_trash', note: "清空了 #{@resource.label} 的回收站", request: request)
207
+ redirect_to ui_resource_index_path(@resource.name), notice: "已彻底清空回收站。"
208
+ end
209
+
210
+ def bulk_action
211
+ ids = params[:ids] || []
212
+ action_name = params[:bulk_operation].to_sym
213
+ definition = @resource.batch_actions[action_name]
214
+
215
+ if ids.any? && definition
216
+ # 权限校验:删除操作查 destroy,自定义操作查对应权限点
217
+ if action_name == :delete
218
+ authorize @resource.model, :destroy?, policy_class: ZenAdmin::ResourcePolicy
219
+ else
220
+ # 检查自定义权限 (如 posts:publish)
221
+ unless current_admin_user.can?("#{@resource.name}:#{action_name}") || (current_admin_user.respond_to?(:super_admin?) && current_admin_user.super_admin?)
222
+ raise Pundit::NotAuthorizedError, "not allowed to perform #{action_name} on #{@resource.name}"
223
+ end
224
+ end
225
+
226
+ records = @resource.model.where(id: ids)
227
+ if definition[:method] && @resource.model.respond_to?(definition[:method])
228
+ @resource.model.send(definition[:method], records)
229
+ elsif definition[:handler]
230
+ instance_exec(records, &definition[:handler])
231
+ end
232
+ ZenAdmin::AuditLog.record(current_admin_user, @resource.model, "bulk_#{action_name}", changes: { count: records.count, ids: ids }, request: request) unless @zen_admin_audited
233
+ redirect_to ui_resource_index_path(@resource.name), notice: "操作成功。"
234
+ else
235
+ redirect_to ui_resource_index_path(@resource.name), alert: "操作失败。"
236
+ end
237
+ end
238
+
239
+ def member_action
240
+ @record = @resource.model.find(params[:id])
241
+ action_name = params[:operation].to_sym
242
+ definition = @resource.member_actions[action_name]
243
+
244
+ if definition
245
+ # 权限校验
246
+ unless current_admin_user.can?("#{@resource.name}:#{action_name}") || (current_admin_user.respond_to?(:super_admin?) && current_admin_user.super_admin?)
247
+ raise Pundit::NotAuthorizedError, "not allowed to perform #{action_name} on #{@resource.name}"
248
+ end
249
+
250
+ instance_exec(@record, &definition[:handler]) if definition[:handler]
251
+ @record.send(definition[:method]) if definition[:method] && @record.respond_to?(definition[:method])
252
+ ZenAdmin::AuditLog.record(current_admin_user, @record, "member_#{action_name}", request: request) unless @zen_admin_audited
253
+ redirect_back fallback_location: ui_resource_index_path(@resource.name), notice: "操作成功。"
254
+ else
255
+ redirect_back fallback_location: ui_resource_index_path(@resource.name), alert: "操作未定义。"
256
+ end
257
+ end
258
+
259
+ private
260
+
261
+ def set_time_zone
262
+ tz = ZenAdmin.configuration.try(:time_zone) || Time.zone.name
263
+ Time.zone = tz
264
+ end
265
+
266
+ def generate_csv(scope)
267
+ require 'csv'
268
+ model_class = @resource.model
269
+ physical_columns = model_class.column_names
270
+ associations = model_class.reflect_on_all_associations
271
+ multi_assoc_names = associations.select { |a| [:has_many, :has_and_belongs_to_many].include?(a.macro) }.reject { |a| a.name.to_s.start_with?('rich_text') || a.name.to_s.include?('attachment') }.map(&:name)
272
+ rich_text_names = associations.select { |a| a.options[:class_name] == "ActionText::RichText" || a.name.to_s.start_with?("rich_text_") }.map { |a| a.name.to_s.gsub(/^rich_text_/, "").to_sym }
273
+ attachment_names = associations.select { |a| a.options[:class_name] == "ActiveStorage::Attachment" || a.name.to_s.end_with?("_attachment") }.map { |a| a.name.to_s.gsub(/_attachment$/, "").to_sym }
274
+ all_export_fields = (physical_columns + multi_assoc_names + rich_text_names + attachment_names).uniq
275
+
276
+ CSV.generate(headers: true) do |csv|
277
+ csv << all_export_fields.map { |f| model_class.human_attribute_name(f) }
278
+ scope.find_each do |record|
279
+ csv << all_export_fields.map do |field|
280
+ value = record.send(field) rescue nil
281
+ if value.is_a?(Time) || value.is_a?(DateTime) || value.is_a?(ActiveSupport::TimeWithZone)
282
+ tz = ZenAdmin.configuration.try(:time_zone) || Time.zone.name
283
+ value.in_time_zone(tz).strftime("%Y-%m-%d %H:%M")
284
+ elsif value.respond_to?(:attached?)
285
+ value.attached? ? Rails.application.routes.url_helpers.rails_blob_url(value, host: request.base_url) : "无"
286
+ elsif value.class.name.include?("ActionText")
287
+ value.to_plain_text
288
+ elsif value.is_a?(ActiveRecord::Associations::CollectionProxy) || value.is_a?(Array)
289
+ value.map { |i| i.try(:name) || i.try(:title) || i.try(:username) || i.to_s }.join(", ")
290
+ elsif value.is_a?(ActiveRecord::Base)
291
+ value.try(:name) || value.try(:title) || value.try(:username) || "##{value.id}"
292
+ elsif field.to_s.end_with?("_id")
293
+ assoc_name = field.to_s.gsub(/_id$/, "")
294
+ if record.respond_to?(assoc_name) && (assoc = record.send(assoc_name))
295
+ assoc.try(:name) || assoc.try(:title) || value
296
+ else
297
+ value
298
+ end
299
+ else
300
+ value.to_s
301
+ end
302
+ end
303
+ end
304
+ end.sub(/^\n/, "\xEF\xBB\xBF")
305
+ end
306
+
307
+ def find_resource
308
+ resource_name = params[:resource_name]
309
+ begin
310
+ model = resource_name.classify.constantize
311
+ @resource = ZenAdmin::Registry.instance[model]
312
+ rescue NameError
313
+ @resource = nil
314
+ end
315
+ @resource = ZenAdmin::Registry.instance.all.find { |r| r.name == resource_name } unless @resource
316
+ if @resource.nil?
317
+ available = ZenAdmin::Registry.instance.all.map(&:name).join(", ")
318
+ raise ActiveRecord::RecordNotFound, "资源 '#{resource_name}' 未注册。可用资源: [#{available}]"
319
+ end
320
+ end
321
+
322
+ def permitted_params
323
+ param_key = @resource.model.model_name.param_key
324
+ permit_list = @resource.form_fields.map do |field|
325
+ field.type == :multi_select ? { field.name => [] } : field.name
326
+ end
327
+ params.require(param_key).permit(permit_list)
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,80 @@
1
+ module ZenAdmin
2
+ module ApplicationHelper
3
+ def sort_url(field_name)
4
+ current_sorts = params[:sort].to_s.split(',').map { |s| s.split(':') }
5
+ found = false
6
+ new_sorts = []
7
+
8
+ current_sorts.each do |col, dir|
9
+ if col == field_name.to_s
10
+ found = true
11
+ if dir == 'asc'
12
+ new_sorts << [col, 'desc']
13
+ end
14
+ else
15
+ new_sorts << [col, dir]
16
+ end
17
+ end
18
+
19
+ unless found
20
+ new_sorts.unshift([field_name.to_s, 'asc'])
21
+ end
22
+
23
+ sort_param = new_sorts.map { |cd| cd.join(':') }.join(',')
24
+ query_params = request.query_parameters.merge(sort: sort_param)
25
+ query_params.delete(:sort) if sort_param.blank?
26
+
27
+ "#{request.path}?#{query_params.to_query}"
28
+ end
29
+
30
+ def current_sort_direction(field_name)
31
+ current_sorts = params[:sort].to_s.split(',').map { |s| s.split(':') }
32
+ pair = current_sorts.find { |col, dir| col == field_name.to_s }
33
+ pair ? pair[1] : nil
34
+ end
35
+
36
+ # Delegate Active Storage helpers to main_app to fix Action Text rendering in engine
37
+ def rails_blob_path(*args, **options)
38
+ main_app.rails_blob_path(*args, **options)
39
+ end
40
+
41
+ def rails_blob_url(*args, **options)
42
+ main_app.rails_blob_url(*args, **options)
43
+ end
44
+
45
+ def rails_representation_path(*args, **options)
46
+ main_app.rails_representation_path(*args, **options)
47
+ end
48
+
49
+ def rails_representation_url(*args, **options)
50
+ main_app.rails_representation_url(*args, **options)
51
+ end
52
+
53
+ def rails_storage_proxy_path(*args, **options)
54
+ main_app.rails_storage_proxy_path(*args, **options)
55
+ end
56
+
57
+ def rails_storage_proxy_url(*args, **options)
58
+ main_app.rails_storage_proxy_url(*args, **options)
59
+ end
60
+
61
+ def zen_highlight(text, phrases)
62
+ return text if phrases.blank? || text.blank? || !text.is_a?(String)
63
+ highlight(text, phrases, highlighter: '<mark class="p-0 bg-warning">\\1</mark>')
64
+ end
65
+
66
+ def menu_item_visible?(item, user)
67
+ case item[:type]
68
+ when :resource
69
+ user.can?("#{item[:object].name}:index")
70
+ when :link
71
+ item[:object][:permission].blank? || user.can?(item[:object][:permission])
72
+ when :group
73
+ # A group is visible if ANY of its children are visible
74
+ item[:items].any? { |sub_item| menu_item_visible?(sub_item, user) }
75
+ else
76
+ false
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,9 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+
5
+ // Configure Stimulus development experience
6
+ application.debug = false
7
+ window.Stimulus = application
8
+
9
+ export { application }
@@ -0,0 +1,55 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["checkbox", "selectAll", "actionButton"]
5
+
6
+ connect() {
7
+ this.updateButtonState()
8
+ }
9
+
10
+ toggleAll() {
11
+ const isChecked = this.selectAllTarget.checked
12
+ this.checkboxTargets.forEach(checkbox => {
13
+ checkbox.checked = isChecked
14
+ })
15
+ this.updateButtonState()
16
+ }
17
+
18
+ toggle() {
19
+ const allChecked = this.checkboxTargets.every(checkbox => checkbox.checked)
20
+ this.selectAllTarget.checked = allChecked
21
+ this.updateButtonState()
22
+ }
23
+
24
+ updateButtonState() {
25
+ const anyChecked = this.checkboxTargets.some(checkbox => checkbox.checked)
26
+ this.actionButtonTargets.forEach(button => {
27
+ button.disabled = !anyChecked
28
+ })
29
+ }
30
+
31
+ submit(event) {
32
+ const button = event.currentTarget
33
+ const actionName = button.dataset.actionName
34
+ const confirmMessage = button.dataset.confirm
35
+ const customUrl = button.dataset.url
36
+ const count = this.checkboxTargets.filter(c => c.checked).length
37
+
38
+ if (confirmMessage && !confirm(confirmMessage.replace("{count}", count))) {
39
+ return
40
+ }
41
+
42
+ const form = document.getElementById('bulk_action_form')
43
+ const input = document.getElementById('bulk_operation_input')
44
+
45
+ if (form && input) {
46
+ // 如果有自定义 URL,则修改表单提交地址
47
+ if (customUrl) {
48
+ form.action = customUrl
49
+ }
50
+
51
+ input.value = actionName
52
+ form.requestSubmit()
53
+ }
54
+ }
55
+ }