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,27 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ copy(event) {
5
+ event.preventDefault()
6
+ const button = event.currentTarget
7
+ const text = button.dataset.clipboardText
8
+
9
+ if (!text) return
10
+
11
+ navigator.clipboard.writeText(text).then(() => {
12
+ const originalContent = button.innerHTML
13
+ button.innerHTML = '<i class="fas fa-check"></i>'
14
+ button.classList.replace('btn-outline-primary', 'btn-success')
15
+ button.disabled = true
16
+
17
+ setTimeout(() => {
18
+ button.innerHTML = originalContent
19
+ button.classList.replace('btn-success', 'btn-outline-primary')
20
+ button.disabled = false
21
+ }, 2000)
22
+ }).catch(err => {
23
+ console.error('Failed to copy: ', err)
24
+ alert('复制失败,请手动复制')
25
+ })
26
+ }
27
+ }
@@ -0,0 +1,5 @@
1
+ import { application } from "controllers/application"
2
+
3
+ // Eager load all controllers defined in the import map under controllers/**/*_controller
4
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
5
+ eagerLoadControllersFrom("controllers", application)
@@ -0,0 +1,48 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ toggleSidebar(event) {
5
+ event.preventDefault()
6
+ document.body.classList.toggle('sidebar-collapse')
7
+ document.body.classList.toggle('sidebar-open')
8
+ }
9
+
10
+ toggleFullscreen(event) {
11
+ event.preventDefault()
12
+ if (!document.fullscreenElement) {
13
+ document.documentElement.requestFullscreen()
14
+ } else {
15
+ if (document.exitFullscreen) {
16
+ document.exitFullscreen()
17
+ }
18
+ }
19
+ }
20
+
21
+ previewImage(event) {
22
+ const url = event.currentTarget.dataset.previewUrl;
23
+ if (!url) return;
24
+
25
+ let modalEl = document.getElementById('zenImagePreviewModal');
26
+ if (!modalEl) {
27
+ const html = `
28
+ <div class="modal fade" id="zenImagePreviewModal" tabindex="-1" aria-hidden="true">
29
+ <div class="modal-dialog modal-dialog-centered modal-xl">
30
+ <div class="modal-content bg-transparent border-0 shadow-none">
31
+ <div class="modal-body p-0 text-center position-relative">
32
+ <button type="button" class="btn-close btn-close-white position-absolute" data-bs-dismiss="modal" style="top: -30px; right: 0;"></button>
33
+ <img id="zenPreviewImg" src="" class="img-fluid rounded shadow-lg" style="max-height: 85vh; border: 4px solid white;">
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </div>`;
38
+ document.body.insertAdjacentHTML('beforeend', html);
39
+ modalEl = document.getElementById('zenImagePreviewModal');
40
+ }
41
+
42
+ const img = modalEl.querySelector('#zenPreviewImg');
43
+ img.src = url;
44
+
45
+ const modal = new bootstrap.Modal(modalEl);
46
+ modal.show();
47
+ }
48
+ }
@@ -0,0 +1,40 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["treeview"]
5
+
6
+ get storageKey() {
7
+ return `zen_admin_menu_group_${this.element.textContent.trim().split('\n')[0].trim()}`
8
+ }
9
+
10
+ connect() {
11
+ // Check if any child link is active, if so, expand the group
12
+ const hasActiveChild = !!this.element.querySelector('.nav-link.active')
13
+ const isSavedOpen = localStorage.getItem(this.storageKey) === 'open'
14
+
15
+ if (hasActiveChild || isSavedOpen) {
16
+ this.open()
17
+ }
18
+ }
19
+
20
+ toggle(event) {
21
+ event.preventDefault()
22
+ if (this.element.classList.contains("menu-open")) {
23
+ this.close()
24
+ } else {
25
+ this.open()
26
+ }
27
+ }
28
+
29
+ open() {
30
+ this.element.classList.add("menu-is-opening", "menu-open")
31
+ this.treeviewTarget.style.display = "block"
32
+ localStorage.setItem(this.storageKey, 'open')
33
+ }
34
+
35
+ close() {
36
+ this.element.classList.remove("menu-is-opening", "menu-open")
37
+ this.treeviewTarget.style.display = "none"
38
+ localStorage.removeItem(this.storageKey)
39
+ }
40
+ }
@@ -0,0 +1,31 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.modal = new bootstrap.Modal(this.element, {
6
+ keyboard: false,
7
+ backdrop: 'static'
8
+ })
9
+ this.modal.show()
10
+ }
11
+
12
+ disconnect() {
13
+ if (this.modal) {
14
+ this.modal.hide()
15
+ }
16
+ }
17
+
18
+ close(event) {
19
+ if (event) event.preventDefault()
20
+ this.modal.hide()
21
+ // Optional: Remove the modal content or frame src
22
+ // this.element.remove()
23
+ }
24
+
25
+ // Called when form submission is successful
26
+ submitEnd(event) {
27
+ if (event.detail.success) {
28
+ this.modal.hide()
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,14 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["element"]
5
+
6
+ connect() {
7
+ // Auto-hide after 3 seconds
8
+ setTimeout(() => {
9
+ if (this.hasElementTarget) {
10
+ $(this.elementTarget).alert('close') // Use Bootstrap's alert close if jQuery available, or custom
11
+ }
12
+ }, 3000)
13
+ }
14
+ }
@@ -0,0 +1,35 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.init();
6
+ }
7
+
8
+ init() {
9
+ // 关键:检查全局变量是否存在
10
+ if (typeof TomSelect === 'undefined') {
11
+ console.warn("TomSelect not ready, retrying...");
12
+ setTimeout(() => this.init(), 50);
13
+ return;
14
+ }
15
+
16
+ // 初始化 TomSelect
17
+ this.select = new TomSelect(this.element, {
18
+ plugins: ['remove_button'],
19
+ placeholder: '请选择...',
20
+ allowEmptyOption: true,
21
+ maxItems: null,
22
+ hidePlaceholder: true,
23
+ onInitialize: function() {
24
+ // 样式微调
25
+ this.wrapper.classList.add('zen-ts-wrapper');
26
+ }
27
+ });
28
+ }
29
+
30
+ disconnect() {
31
+ if (this.select) {
32
+ this.select.destroy();
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,12 @@
1
+ // 核心:引入 Rails 全局环境
2
+ import "@hotwired/turbo-rails"
3
+ import "controllers"
4
+ import "trix"
5
+ import "@rails/actiontext"
6
+
7
+ // 补全:必须引入并启动 ActiveStorage,它负责处理直传逻辑
8
+ import * as ActiveStorage from "@rails/activestorage"
9
+ ActiveStorage.start()
10
+
11
+ // 关键:虽然 Rails 7+ 倾向于不使用 UJS,但 Trix 的上传依然依赖它来自动处理 CSRF
12
+ // 或者我们需要手动配置。为了最稳妥,我们直接在全局环境启动
@@ -0,0 +1,4 @@
1
+ module ZenAdmin
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module ZenAdmin
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module ZenAdmin
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,43 @@
1
+ module ZenAdmin
2
+ class Asset < ApplicationRecord
3
+ self.table_name = "zen_admin_assets"
4
+ has_one_attached :file
5
+
6
+ zen_admin do |resource|
7
+ resource.label :'zen_admin.models.asset.fields.file', :'zen_admin.models.asset.fields.file'
8
+ resource.menu label: :'zen_admin.builtin.assets', icon: "fas fa-images", position: 99, group: "系统管理"
9
+ resource.soft_delete = false
10
+ resource.enable_batch_actions
11
+ resource.batch_action :delete, label: :'zen_admin.actions.delete', type: :danger, confirm: I18n.t('zen_admin.ui.confirm_delete') do |records|
12
+ records.destroy_all
13
+ end
14
+
15
+ resource.list do
16
+ field :file, label: :'zen_admin.models.asset.fields.file'
17
+ field :name, label: :'zen_admin.models.asset.fields.name'
18
+ field :id, label: :'zen_admin.models.asset.fields.id' do |asset|
19
+ if asset.file.attached?
20
+ url = Rails.application.routes.url_helpers.rails_blob_url(asset.file, host: "localhost:3000") rescue "#"
21
+ render "zen_admin/builtin/copy_button", url: url
22
+ end
23
+ end
24
+ field :created_at, label: :'zen_admin.models.asset.fields.created_at'
25
+ end
26
+
27
+ resource.form do
28
+ field :name, label: :'zen_admin.models.asset.fields.name', required: true
29
+ field :file, label: :'zen_admin.models.asset.fields.file', type: :file
30
+ end
31
+
32
+ resource.show do
33
+ field :name, label: :'zen_admin.models.asset.fields.name'
34
+ field :file, label: :'zen_admin.models.asset.fields.file' do |asset|
35
+ if asset.file.attached?
36
+ url = Rails.application.routes.url_helpers.rails_blob_url(asset.file, host: "localhost:3000") rescue ""
37
+ render "zen_admin/builtin/file_link", url: url
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,116 @@
1
+ module ZenAdmin
2
+ class AuditLog < ApplicationRecord
3
+ self.table_name = "zen_admin_audit_logs"
4
+
5
+ def self.record(user, resource, action, changes: nil, note: nil, request: nil)
6
+ return unless ZenAdmin.configuration.audit_log_enable
7
+
8
+ # 获取资源类型
9
+ res_type = resource.is_a?(Class) ? resource.name : resource.class.name
10
+
11
+ # 过滤掉回收站自身的审计
12
+ return if res_type == "ZenAdmin::TrashItem"
13
+
14
+ create(
15
+ admin_user_id: user.try(:id),
16
+ admin_username: user.try(:username),
17
+ resource_type: res_type,
18
+ resource_id: resource.respond_to?(:id) ? resource.id : nil,
19
+ action: action,
20
+ note: note,
21
+ changes_data: changes,
22
+ ip_address: request.try(:remote_ip),
23
+ user_agent: request.try(:user_agent),
24
+ created_at: Time.current
25
+ )
26
+ end
27
+
28
+ def zen_admin_editable?; false; end
29
+ def zen_admin_deletable?; false; end
30
+
31
+ zen_admin do |resource|
32
+ resource.label :'zen_admin.models.audit_log.one', :'zen_admin.models.audit_log.other'
33
+ resource.menu label: :'zen_admin.models.audit_log.other', icon: "fas fa-history", position: 105
34
+ resource.actions = [:index, :show]
35
+ resource.soft_delete = false
36
+
37
+ resource.list do
38
+ field :admin_username, label: :'zen_admin.models.audit_log.fields.admin_username'
39
+ field :resource_type, label: :'zen_admin.models.audit_log.fields.resource_type' do |log|
40
+ res = ZenAdmin::Registry.instance[log.resource_type]
41
+ res ? res.label : log.resource_type
42
+ end
43
+ field :action, label: :'zen_admin.models.audit_log.fields.action' do |log|
44
+ color = case log.action
45
+ when 'create' then 'success'
46
+ when 'update' then 'info'
47
+ when 'destroy', 'move_to_trash', 'soft_delete' then 'danger'
48
+ when 'restore' then 'success'
49
+ else 'secondary'
50
+ end
51
+
52
+ action_key = log.action.to_s
53
+ clean_key = action_key.gsub(/^(bulk_|member_)/, '')
54
+ label = I18n.t("zen_admin.actions.#{clean_key}", default: clean_key)
55
+ label = "批量#{label}" if action_key.start_with?('bulk_')
56
+
57
+ content_tag :span, label, class: "badge bg-#{color}"
58
+ end
59
+ field :note, label: "业务备注"
60
+ field :created_at, label: :'zen_admin.models.audit_log.fields.created_at' do |log|
61
+ tz = ZenAdmin.configuration.try(:time_zone) || "UTC"
62
+ log.created_at.in_time_zone(tz).strftime("%Y-%m-%d %H:%M:%S")
63
+ end
64
+ end
65
+
66
+ resource.show do
67
+ field :admin_username, label: "操作员"
68
+ field :action, label: "操作标识"
69
+ field :note, label: "业务说明"
70
+ field :resource_type, label: "资源类型" do |log|
71
+ res = ZenAdmin::Registry.instance[log.resource_type]
72
+ res ? "#{res.label} (#{log.resource_type})" : log.resource_type
73
+ end
74
+ field :resource_id, label: "主资源 ID"
75
+
76
+ field :changes_data, label: "详细数据" do |log|
77
+ if log.changes_data.present?
78
+ content_tag :div do
79
+ if log.changes_data["ids"].is_a?(Array)
80
+ ids = log.changes_data["ids"]
81
+ model_class = log.resource_type.safe_constantize
82
+ header = content_tag(:div, "涉及资源 (共 #{ids.count} 个):", class: "small fw-bold text-muted mb-2")
83
+
84
+ # 批量加载,避免 N+1
85
+ records_map = model_class ? model_class.where(id: ids).index_by { |r| r.id.to_s } : {}
86
+
87
+ tags = content_tag(:div, class: "d-flex flex-wrap gap-2 mb-3") do
88
+ ids.map do |id|
89
+ record = records_map[id.to_s]
90
+ res_name = log.resource_type.underscore.pluralize.gsub('/', '_') rescue nil
91
+ if record && res_name
92
+ display_label = record.try(:title) || record.try(:name) || record.try(:username) || "##{id}"
93
+ link_to display_label, ui_resource_path(res_name, id), data: { turbo_frame: "modal" }, class: "badge bg-info text-white text-decoration-none"
94
+ else
95
+ content_tag :span, "##{id} (已删除)", class: "badge bg-light text-muted border"
96
+ end
97
+ end.join.html_safe
98
+ end
99
+ header + tags + content_tag(:hr) + content_tag(:pre, JSON.pretty_generate(log.changes_data), class: "p-2 bg-light small rounded")
100
+ else
101
+ content_tag :pre, JSON.pretty_generate(log.changes_data), class: "p-3 bg-light rounded"
102
+ end
103
+ end
104
+ else
105
+ "无数据变动"
106
+ end
107
+ end
108
+ field :ip_address, label: "IP"
109
+ field :created_at, label: "时间" do |log|
110
+ tz = ZenAdmin.configuration.try(:time_zone) || "UTC"
111
+ log.created_at.in_time_zone(tz).strftime("%Y-%m-%d %H:%M:%S")
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,73 @@
1
+ module ZenAdmin
2
+ class Permission < ApplicationRecord
3
+ self.table_name = "zen_admin_permissions"
4
+
5
+ has_and_belongs_to_many :roles, join_table: "zen_admin_roles_permissions"
6
+
7
+ def self.sync_from_registry!
8
+ return unless table_exists?
9
+
10
+ ZenAdmin::Registry.instance.all.each do |res|
11
+ # 1. 基础动作
12
+ base_actions = ["index", "show", "create", "update", "destroy"]
13
+
14
+ # 2. 自定义动作 (Batch & Member)
15
+ custom_actions = (res.batch_actions.keys + res.member_actions.keys).map(&:to_s)
16
+
17
+ all_actions = (base_actions + custom_actions).uniq
18
+
19
+ raw_label = res.instance_variable_get(:@resource_label_singular)
20
+ res_label = if raw_label.is_a?(Symbol)
21
+ I18n.t(raw_label, locale: :'zh-CN', default: res.model.model_name.human)
22
+ else
23
+ raw_label || res.model.model_name.human
24
+ end
25
+
26
+ all_actions.each do |action|
27
+ code = "#{res.name}:#{action}"
28
+ action_label = case action
29
+ when "index" then "列表"
30
+ when "show" then "详情"
31
+ when "create" then "创建"
32
+ when "update" then "编辑"
33
+ when "destroy" then "删除"
34
+ else action.humanize # 自定义动作直接显示名
35
+ end
36
+
37
+ name = "#{res_label} - #{action_label}"
38
+ p = find_or_initialize_by(code: code)
39
+ p.name = name
40
+ p.save! if p.changed?
41
+ end
42
+ end
43
+ end
44
+
45
+ zen_admin do |resource|
46
+ resource.label :'zen_admin.models.permission.one', :'zen_admin.models.permission.other'
47
+ resource.menu label: :'zen_admin.models.permission.other', icon: "fas fa-key", position: 92, visible: ZenAdmin.configuration.rbac_enable, group: "系统管理"
48
+ resource.list do
49
+ field :name, label: :'zen_admin.models.permission.fields.name'
50
+ field :code, label: :'zen_admin.models.permission.fields.code'
51
+ end
52
+ resource.form do
53
+ field :name, label: :'zen_admin.models.permission.fields.name', required: true
54
+
55
+ # 动态计算可用权限建议
56
+ help_text = lambda do
57
+ suggestions = []
58
+ ZenAdmin::Registry.instance.all.each do |res|
59
+ suggestions << "#{res.name}:index"
60
+ suggestions << "#{res.name}:show"
61
+ suggestions << "#{res.name}:create"
62
+ suggestions << "#{res.name}:update"
63
+ suggestions << "#{res.name}:destroy"
64
+ end
65
+
66
+ "<div class='mt-2 p-2 bg-dark text-light rounded'><div class='small mb-1 font-weight-bold'>建议的标识符格式:</div><pre class='mb-0 text-success' style='font-size: 11px;'>#{suggestions.join("\n")}</pre></div>"
67
+ end
68
+
69
+ field :code, label: :'zen_admin.models.permission.fields.code', required: true, help: help_text.call
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,45 @@
1
+ module ZenAdmin
2
+ class Role < ApplicationRecord
3
+ self.table_name = "zen_admin_roles"
4
+
5
+ has_and_belongs_to_many :users, join_table: "zen_admin_users_roles"
6
+ has_and_belongs_to_many :permissions, join_table: "zen_admin_roles_permissions"
7
+
8
+ validates :name, presence: true, uniqueness: true
9
+ validates :code, presence: true, uniqueness: true
10
+
11
+ zen_admin do |resource|
12
+ resource.label :'zen_admin.models.role.one', :'zen_admin.models.role.other'
13
+ resource.menu label: :'zen_admin.models.role.other', icon: "fas fa-user-shield", position: 91, visible: ZenAdmin.configuration.rbac_enable, group: "系统管理"
14
+ resource.list do
15
+ field :name, label: :'zen_admin.models.role.fields.name'
16
+ field :code, label: :'zen_admin.models.role.fields.code'
17
+ field :permissions, label: :'zen_admin.models.role.fields.permissions' do |role, label|
18
+ if role.code == "superadmin"
19
+ content_tag :span, "所有权限 (ALL)", class: "badge badge-danger p-2"
20
+ else
21
+ # label 此时已经是逗号分隔的权限名称字符串了
22
+ content_tag :div, class: "d-flex flex-wrap", style: "gap: 4px;" do
23
+ role.permissions.first(5).map do |p|
24
+ content_tag :span, p.name, class: "badge badge-info"
25
+ end.join.html_safe + (content_tag(:span, "+#{role.permissions.count - 5}", class: "text-muted small") if role.permissions.count > 5).to_s.html_safe
26
+ end
27
+ end
28
+ end
29
+ end
30
+ resource.form do
31
+ field :name, label: :'zen_admin.models.role.fields.name', required: true
32
+ field :code, label: :'zen_admin.models.role.fields.code', required: true, help: "建议使用英文,如 'editor', 'superadmin'"
33
+ field :permission_ids, label: :'zen_admin.models.role.fields.permission_ids', type: :multi_select, collection: -> { ZenAdmin::Permission.all.map { |p| [p.name, p.id] } }
34
+ end
35
+ end
36
+
37
+ def zen_admin_deletable?
38
+ code != "superadmin"
39
+ end
40
+
41
+ def zen_admin_editable?
42
+ code != "superadmin"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,98 @@
1
+ module ZenAdmin
2
+ class TrashItem < ApplicationRecord
3
+ self.table_name = "zen_admin_trash_items"
4
+
5
+ def self.move_to_trash(record, admin_user)
6
+ snapshot = record.attributes
7
+ associations = {}
8
+ record.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |assoc|
9
+ associations[assoc.name.to_s] = record.send("#{assoc.name.to_s.singularize}_ids")
10
+ end
11
+ snapshot["_zen_associations"] = associations
12
+
13
+ create!(
14
+ resource_type: record.class.name,
15
+ resource_id: record.id,
16
+ resource_label: record.try(:title) || record.try(:name) || record.try(:username) || "##{record.id}",
17
+ data: snapshot,
18
+ admin_username: admin_user.try(:username),
19
+ created_at: Time.current
20
+ )
21
+ record.destroy!
22
+ end
23
+
24
+ def restore!
25
+ klass = resource_type.constantize
26
+
27
+ # 1. 检查 ID 占用冲突
28
+ if klass.exists?(resource_id)
29
+ return { success: false, message: "还原失败:原始 ID (##{resource_id}) 已被新数据占用。" }
30
+ end
31
+
32
+ snapshot = data.dup
33
+ associations = snapshot.delete("_zen_associations") || {}
34
+
35
+ # 2. 还原基础记录
36
+ record = klass.new(snapshot)
37
+ record.id = resource_id
38
+
39
+ if record.save
40
+ # 3. 尝试还原多对多关联
41
+ associations.each do |name, ids|
42
+ method_name = "#{name.to_s.singularize}_ids="
43
+ record.send(method_name, ids) if record.respond_to?(method_name)
44
+ rescue => e
45
+ Rails.logger.error "[ZenAdmin] Failed to restore association #{name}: #{e.message}"
46
+ end
47
+
48
+ # 4. 关键:同步数据库自增序列 (针对 PostgreSQL)
49
+ sync_db_sequence(klass)
50
+
51
+ destroy
52
+ return { success: true }
53
+ else
54
+ return { success: false, message: record.errors.full_messages.join(", ") }
55
+ end
56
+ rescue => e
57
+ Rails.logger.error "[ZenAdmin] Restore failed: #{e.message}"
58
+ return { success: false, message: e.message }
59
+ end
60
+
61
+ private
62
+
63
+ def sync_db_sequence(klass)
64
+ # 仅在 PostgreSQL 下需要手动重置序列,MySQL 会自动处理
65
+ if ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!)
66
+ ActiveRecord::Base.connection.reset_pk_sequence!(klass.table_name)
67
+ end
68
+ end
69
+
70
+ # 保持原本的 method_missing 逻辑...
71
+ def method_missing(method, *args, &block)
72
+ if data.has_key?(method.to_s)
73
+ data[method.to_s]
74
+ else
75
+ super
76
+ end
77
+ end
78
+
79
+ def respond_to_missing?(method, include_private = false)
80
+ data.has_key?(method.to_s) || super
81
+ end
82
+
83
+ zen_admin do |resource|
84
+ resource.label :'zen_admin.models.trash_item.one', :'zen_admin.models.trash_item.other'
85
+ resource.menu visible: false
86
+ resource.actions = [:index, :show, :destroy]
87
+ resource.list do
88
+ field :resource_label, label: "资源名称"
89
+ field :resource_type, label: "类型" do |item|
90
+ res = ZenAdmin::Registry.instance[item.resource_type]
91
+ res ? res.label : item.resource_type
92
+ end
93
+ field :admin_username, label: "删除者"
94
+ field :created_at, label: "删除时间"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,37 @@
1
+ module ZenAdmin
2
+ class User < ApplicationRecord
3
+ self.table_name = "zen_admin_users"
4
+ has_secure_password
5
+
6
+ has_and_belongs_to_many :roles, join_table: "zen_admin_users_roles"
7
+
8
+ validates :username, presence: true, uniqueness: true
9
+
10
+ zen_admin do |resource|
11
+ resource.label :'zen_admin.models.user.one', :'zen_admin.models.user.other'
12
+ resource.menu label: :'zen_admin.models.user.other', icon: "fas fa-users-cog", position: 90, visible: ZenAdmin.configuration.rbac_enable, group: "系统管理"
13
+ resource.list do
14
+ field :username, label: :'zen_admin.models.user.fields.username'
15
+ field :roles, label: :'zen_admin.models.user.fields.roles'
16
+ field :created_at, label: :'zen_admin.models.user.fields.created_at'
17
+ end
18
+ resource.form do
19
+ field :username, label: :'zen_admin.models.user.fields.username', required: true
20
+ field :password, label: :'zen_admin.models.user.fields.password', type: :password
21
+ field :role_ids, label: :'zen_admin.models.user.fields.roles', type: :multi_select, collection: -> { ZenAdmin::Role.all.map { |r| [r.name, r.id] } }
22
+ end
23
+ end
24
+
25
+ def can?(permission_name)
26
+ super_admin? || permissions.include?(permission_name.to_s)
27
+ end
28
+
29
+ def super_admin?
30
+ roles.any? { |r| r.code == "superadmin" }
31
+ end
32
+
33
+ def permissions
34
+ @permissions ||= roles.includes(:permissions).flat_map { |r| r.permissions.map(&:code) }.uniq
35
+ end
36
+ end
37
+ end