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.
- checksums.yaml +7 -0
- data/README.md +76 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/zen_admin/application.css +15 -0
- data/app/controllers/zen_admin/application_controller.rb +27 -0
- data/app/controllers/zen_admin/dashboard_controller.rb +9 -0
- data/app/controllers/zen_admin/sessions_controller.rb +40 -0
- data/app/controllers/zen_admin/ui/resources_controller.rb +331 -0
- data/app/helpers/zen_admin/application_helper.rb +80 -0
- data/app/javascript/controllers/application.js +9 -0
- data/app/javascript/controllers/bulk_actions_controller.js +55 -0
- data/app/javascript/controllers/clipboard_controller.js +27 -0
- data/app/javascript/controllers/index.js +5 -0
- data/app/javascript/controllers/layout_controller.js +48 -0
- data/app/javascript/controllers/menu_group_controller.js +40 -0
- data/app/javascript/controllers/modal_controller.js +31 -0
- data/app/javascript/controllers/toast_controller.js +14 -0
- data/app/javascript/controllers/tom_select_controller.js +35 -0
- data/app/javascript/zen_admin/application.js +12 -0
- data/app/jobs/zen_admin/application_job.rb +4 -0
- data/app/mailers/zen_admin/application_mailer.rb +6 -0
- data/app/models/zen_admin/application_record.rb +5 -0
- data/app/models/zen_admin/asset.rb +43 -0
- data/app/models/zen_admin/audit_log.rb +116 -0
- data/app/models/zen_admin/permission.rb +73 -0
- data/app/models/zen_admin/role.rb +45 -0
- data/app/models/zen_admin/trash_item.rb +98 -0
- data/app/models/zen_admin/user.rb +37 -0
- data/app/policies/zen_admin/application_policy.rb +53 -0
- data/app/policies/zen_admin/resource_policy.rb +48 -0
- data/app/views/layouts/zen_admin/_flash.html.erb +9 -0
- data/app/views/layouts/zen_admin/_sidebar.html.erb +115 -0
- data/app/views/layouts/zen_admin/application.html.erb +98 -0
- data/app/views/zen_admin/builtin/_copy_button.html.erb +9 -0
- data/app/views/zen_admin/builtin/_file_link.html.erb +22 -0
- data/app/views/zen_admin/dashboard/index.html.erb +27 -0
- data/app/views/zen_admin/sessions/new.html.erb +47 -0
- data/app/views/zen_admin/ui/resources/_form.html.erb +226 -0
- data/app/views/zen_admin/ui/resources/_row.html.erb +170 -0
- data/app/views/zen_admin/ui/resources/create.turbo_stream.erb +2 -0
- data/app/views/zen_admin/ui/resources/destroy.turbo_stream.erb +2 -0
- data/app/views/zen_admin/ui/resources/edit.html.erb +11 -0
- data/app/views/zen_admin/ui/resources/index.html.erb +285 -0
- data/app/views/zen_admin/ui/resources/new.html.erb +11 -0
- data/app/views/zen_admin/ui/resources/show.html.erb +133 -0
- data/app/views/zen_admin/ui/resources/update.turbo_stream.erb +2 -0
- data/config/importmap.rb +10 -0
- data/config/locales/en.yml +107 -0
- data/config/locales/zh-CN.yml +110 -0
- data/config/routes.rb +24 -0
- data/lib/generators/zen_admin/admin_user/admin_user_generator.rb +55 -0
- data/lib/generators/zen_admin/install/install_generator.rb +50 -0
- data/lib/generators/zen_admin/install/templates/asset.rb +46 -0
- data/lib/generators/zen_admin/install/templates/create_zen_admin_assets.rb.erb +9 -0
- data/lib/generators/zen_admin/install/templates/create_zen_admin_audit_logs.rb.erb +18 -0
- data/lib/generators/zen_admin/install/templates/create_zen_admin_rbac.rb.erb +57 -0
- data/lib/generators/zen_admin/install/templates/create_zen_admin_trash_items.rb.erb +13 -0
- data/lib/generators/zen_admin/install/templates/zen_admin.rb +17 -0
- data/lib/generators/zen_admin/install/templates/zh-CN.yml +15 -0
- data/lib/generators/zen_admin/model/model_generator.rb +65 -0
- data/lib/generators/zen_admin/model/templates/zen_admin_config.rb.erb +80 -0
- data/lib/generators/zen_admin/rbac_install/rbac_install_generator.rb +52 -0
- data/lib/generators/zen_admin/rbac_install/templates/create_zen_admin_rbac.rb.erb +42 -0
- data/lib/generators/zen_admin/rbac_install/templates/seeds.rb.erb +13 -0
- data/lib/tasks/zen_admin_tasks.rake +4 -0
- data/lib/zen_admin/authenticatable.rb +44 -0
- data/lib/zen_admin/builtin.rb +77 -0
- data/lib/zen_admin/configuration.rb +17 -0
- data/lib/zen_admin/core/field.rb +34 -0
- data/lib/zen_admin/core/filter.rb +16 -0
- data/lib/zen_admin/core/resource.rb +175 -0
- data/lib/zen_admin/core.rb +3 -0
- data/lib/zen_admin/engine.rb +67 -0
- data/lib/zen_admin/link_registerable.rb +11 -0
- data/lib/zen_admin/registerable.rb +17 -0
- data/lib/zen_admin/registry.rb +83 -0
- data/lib/zen_admin/schema/serializer.rb +15 -0
- data/lib/zen_admin/schema.rb +1 -0
- data/lib/zen_admin/version.rb +3 -0
- data/lib/zen_admin.rb +51 -0
- 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,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,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,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
|
+
}
|