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
|
@@ -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,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
|