django-simpleui-antd 1.3.0__py3-none-any.whl

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.

Potentially problematic release.


This version of django-simpleui-antd might be problematic. Click here for more details.

@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-simpleui-antd
3
+ Version: 1.3.0
4
+ Summary: simpleui(Django Admin 主题)的 React 19 + Ant Design 6 复刻版 —— 可安装的 Django 管理后台
5
+ Author: neekin
6
+ License-Expression: MIT
7
+ Classifier: Framework :: Django
8
+ Classifier: Framework :: Django :: 4.2
9
+ Classifier: Framework :: Django :: 5.0
10
+ Classifier: Framework :: Django :: 5.1
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: django>=4.2
15
+
16
+ # django-simpleui-antd
17
+
18
+ [simpleui](https://github.com/newpanjing/simpleui)(Django Admin 主题,Vue + Element-UI)的
19
+ **React 19 + Ant Design 6** 复刻版 —— 可安装的 Django 管理后台。
20
+
21
+ `pip install` 后把 `simpleui_antd` 加入 `INSTALLED_APPS` 并挂载 URL,即可在 `/simpleui/`
22
+ 得到一套 React 管理界面,数据直接来自你项目中**注册到 Django Admin 的模型**
23
+ (复用 ModelAdmin 的 queryset、权限与 ModelForm 校验)。
24
+
25
+ ## 安装
26
+
27
+ 从仓库 Release 安装(当前发布渠道):
28
+
29
+ ```bash
30
+ # 方式一:下载 Release 的 wheel 后安装
31
+ gh release download v1.1.0 -R neekin/simpleui-antd
32
+ pip install ./django_simpleui_antd-1.1.0-py3-none-any.whl
33
+
34
+ # 方式二:直接从 Git tag 安装(需要仓库访问权限)
35
+ pip install "git+https://github.com/neekin/simpleui-antd.git@v1.1.0#subdirectory=django"
36
+ ```
37
+
38
+ > 发布到 PyPI 后即可 `pip install django-simpleui-antd`。
39
+
40
+ ```python
41
+ # settings.py
42
+ INSTALLED_APPS = [
43
+ ...,
44
+ "simpleui_antd", # 放在你自己的 app 之后即可
45
+ ]
46
+
47
+ # urls.py
48
+ urlpatterns = [
49
+ ...,
50
+ path("simpleui/", include("simpleui_antd.urls")),
51
+ ]
52
+ ```
53
+
54
+ ```bash
55
+ python manage.py migrate # 无迁移也会创建表结构依赖(可选)
56
+ python manage.py runserver
57
+ ```
58
+
59
+ 访问 <http://127.0.0.1:8000/simpleui/>,使用**任意 is_staff 账号**登录。
60
+
61
+ ## 工作方式
62
+
63
+ - 前端为 React 19 + AntD 6 单页应用,构建产物已内置于包的 `static/` 目录
64
+ - 后端提供轻量管理 API(`/simpleui/api/*`),数据源是 `django.contrib.admin` 的模型注册表:
65
+ - `menu` 侧边栏(按 app 分组,仅含当前用户有查看权限的模型)
66
+ - `meta` 编辑表单元数据(复用 ModelForm,字段类型/必填/下拉选项)
67
+ - `list` 列表查询(复用 ModelAdmin.get_queryset,支持搜索 / 排序 / 分页)
68
+ - `save` / `delete` 增删改(复用 ModelForm 校验与 ModelAdmin 权限,写入 Admin LogEntry)
69
+ - 认证复用 Django session,登录接口使用 Django `authenticate()`,CSRF 走标准中间件
70
+
71
+ ## 已知边界(v1.1)
72
+
73
+ - 编辑表单支持常用字段类型(文本 / 数值 / 布尔 / 外键下拉 / 带选项字段);不支持内联(inline)、文件上传、多对多
74
+ - 列表搜索基于文本类字段的 icontains;未复刻 admin 的自定义 filter
75
+ - 需要更复杂行为时,可以同时保留原生 `/admin/` 两者并存
@@ -0,0 +1,26 @@
1
+ simpleui_antd/__init__.py,sha256=NNL2B0rwQTEVGkLX1Ovk5EB0CB_aHplw5MQqyH4SkXQ,117
2
+ simpleui_antd/apps.py,sha256=21bLoJjV2rB9CGRL2jwbomOvHC0C-ItmV-KlwAGAYA0,205
3
+ simpleui_antd/models.py,sha256=Wy5M0AX3d6hi4sZKrdx_zp3C3a7fN79MtNVCCp7JEKA,585
4
+ simpleui_antd/urls.py,sha256=O60irtVwXOHmMomvm0p9dQ_qNKE1DocGPfyU0HwK7TY,1384
5
+ simpleui_antd/views.py,sha256=Uk5evZZeq4itM6OhYKeTZfq8bvCQIfDIHUV3Pno5U04,279
6
+ simpleui_antd/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ simpleui_antd/api/auth.py,sha256=07lg1b8gjQIwdbeP460HpMz2i6kmc1IOF-81SDz1G_M,1280
8
+ simpleui_antd/api/base.py,sha256=VcPQuyHTqhfxHhpL1Qvn-0-AiDxBjn2DT0oFwbZ_lBM,1984
9
+ simpleui_antd/api/crud.py,sha256=2bPZ_rcBci3Wfbb6P2X21UzJYv8w5csRtVcyMBDl3d8,11263
10
+ simpleui_antd/api/dashboard.py,sha256=iC7MDocOyLoxNwmU87jI8ZMzIN8VR7zvELNNu0OYZ1w,2901
11
+ simpleui_antd/api/listing.py,sha256=AfcwqkMwaKH2uADYLxeTjYG58XqpVa_jw0wv_Y6HOi4,2298
12
+ simpleui_antd/api/registry.py,sha256=fy96O_h0oxaLsD_gK2iPqeyhLlmdGVnXhPEZN8cWjO4,12884
13
+ simpleui_antd/components/__init__.py,sha256=QdHSl-5z5XwifnpFwX5abUn9mQoPDVKwKQ6tp-HA4Fo,35
14
+ simpleui_antd/components/fields.py,sha256=3IJiABTopUsjMXDpW9JEZ77TIh4FLmkSQ61TRNf5B6E,5944
15
+ simpleui_antd/editor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ simpleui_antd/editor/fields.py,sha256=U0YMXBqngjpOG0_CRcnjpsbqBgsk_bEBpbhiI5b7SRU,817
17
+ simpleui_antd/migrations/0001_initial.py,sha256=Gt7GxUOm-gY5XGHtwsFJSDGnv2pD7T1kDNrw4zrRysc,1008
18
+ simpleui_antd/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
+ simpleui_antd/static/simpleui_antd/index.html,sha256=GFJiXa5nM6racfWE4Y8mYw27DDYbQbkHwAtde6F23M0,430
20
+ simpleui_antd/static/simpleui_antd/assets/index.css,sha256=BOKkqM-K-uEhQwcl0F4dcgXX1InMEQVckC7jUHAiBIg,22441
21
+ simpleui_antd/static/simpleui_antd/assets/index.js,sha256=xPwcrijr5HIvf8kVmf1RIG5HfqXm2AIxaH3o3t-7Cz8,2587193
22
+ simpleui_antd/templates/simpleui_antd/index.html,sha256=PN-qg9rjmfHnGWLeSSkFtsVofnBj0_PlblvYG2QYtzY,638
23
+ django_simpleui_antd-1.3.0.dist-info/METADATA,sha256=jn_DgGfODlmYL-W3EPzzGdktq7CrMnndRhRybN3kE28,2826
24
+ django_simpleui_antd-1.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
25
+ django_simpleui_antd-1.3.0.dist-info/top_level.txt,sha256=sszuygkyK5r3xIgSdpI52bRKDbE5Hl3LbRl_lfQuWaQ,14
26
+ django_simpleui_antd-1.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ simpleui_antd
@@ -0,0 +1,3 @@
1
+ """simpleui_antd —— simpleui(Django Admin 主题)的 React 19 + Ant Design 6 复刻版"""
2
+
3
+ __version__ = "1.3.0"
File without changes
@@ -0,0 +1,42 @@
1
+ """认证接口:session 登录 / 登出 / 当前用户"""
2
+
3
+ from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
4
+
5
+ from .base import json_err, json_ok, read_body
6
+
7
+
8
+ def me(request):
9
+ from .base import staff_required
10
+
11
+ @staff_required
12
+ def _inner(request):
13
+ user = request.user
14
+ return json_ok(
15
+ {
16
+ "username": user.username,
17
+ "name": user.get_full_name() or user.username,
18
+ "is_superuser": user.is_superuser,
19
+ }
20
+ )
21
+
22
+ return _inner(request)
23
+
24
+
25
+ def login(request):
26
+ if request.method != "POST":
27
+ return json_err("仅支持 POST", status=405)
28
+ body = read_body(request)
29
+ user = authenticate(request, username=body.get("username") or "", password=body.get("password") or "")
30
+ if user is None:
31
+ return json_err("用户名或密码错误")
32
+ if not (user.is_staff or user.is_superuser):
33
+ return json_err("需要管理员(Staff)账号", status=403)
34
+ auth_login(request, user)
35
+ return json_ok({"username": user.username, "is_superuser": user.is_superuser})
36
+
37
+
38
+ def logout(request):
39
+ if request.method != "POST":
40
+ return json_err("仅支持 POST", status=405)
41
+ auth_logout(request)
42
+ return json_ok({})
@@ -0,0 +1,66 @@
1
+ """API 基础设施:JSON 响应、权限装饰器、Admin 注册表查找"""
2
+
3
+ import json
4
+ from functools import wraps
5
+
6
+ from django.contrib import admin
7
+ from django.apps import apps
8
+ from django.http import Http404, JsonResponse
9
+ from django.test import RequestFactory
10
+
11
+ _perm_factory = RequestFactory()
12
+
13
+
14
+ def json_ok(data=None):
15
+ return JsonResponse({"ok": True, "data": data or {}})
16
+
17
+
18
+ def json_err(message, status=400, errors=None):
19
+ payload = {"ok": False, "error": message}
20
+ if errors:
21
+ payload["errors"] = errors
22
+ return JsonResponse(payload, status=status)
23
+
24
+
25
+ def read_body(request):
26
+ try:
27
+ return json.loads(request.body or b"{}")
28
+ except (json.JSONDecodeError, UnicodeDecodeError):
29
+ return {}
30
+
31
+
32
+ def staff_required(view):
33
+ """要求已登录且 is_staff(is_superuser 视为 staff)"""
34
+
35
+ @wraps(view)
36
+ def wrapper(request, *args, **kwargs):
37
+ if not request.user.is_authenticated:
38
+ return json_err("未登录", status=401)
39
+ if not (request.user.is_staff or request.user.is_superuser):
40
+ return json_err("需要管理员(Staff)账号", status=403)
41
+ return view(request, *args, **kwargs)
42
+
43
+ return wrapper
44
+
45
+
46
+ def perm_request(user):
47
+ """构造用于 ModelAdmin 权限检查的最小 request(部分自定义 ModelAdmin 需要完整 request,此处尽力兼容)"""
48
+ req = _perm_factory.get("/simpleui/")
49
+ req.user = user
50
+ return req
51
+
52
+
53
+ def get_admin(model_key):
54
+ """'app_label.model_name' → (model, ModelAdmin);未注册到 Admin 的模型返回 404"""
55
+ try:
56
+ app_label, model_name = model_key.split(".", 1)
57
+ except ValueError:
58
+ raise Http404("模型标识不合法")
59
+ try:
60
+ model = apps.get_model(app_label, model_name)
61
+ except (LookupError, ValueError):
62
+ raise Http404("模型不存在")
63
+ madmin = admin.site._registry.get(model)
64
+ if madmin is None:
65
+ raise Http404("该模型未注册到 Django Admin")
66
+ return model, madmin
@@ -0,0 +1,299 @@
1
+ """增删改接口:多对多、文件上传、内联(Inline)、批量 Action"""
2
+
3
+ from django.contrib.admin.models import ADDITION, CHANGE, DELETION, LogEntry
4
+ from django.contrib import messages
5
+ from django.contrib.contenttypes.models import ContentType
6
+ from django.core.files.storage import default_storage
7
+ from django.db import models as dbmodels
8
+ from django.forms.models import modelform_factory
9
+ from django.http import HttpResponse
10
+ from django.shortcuts import get_object_or_404
11
+
12
+ from .base import get_admin, json_err, json_ok, perm_request, read_body, staff_required
13
+ from ..components import fields as pro_fields
14
+ from .registry import build_form_fields_meta, form_field_names, m2m_field_names
15
+
16
+
17
+ def _log_action(user, obj, flag, message):
18
+ """直接写入 LogEntry,Django 4.2 ~ 6.x 均兼容(log_action 管理器方法已在 6.0 移除)"""
19
+ LogEntry.objects.create(
20
+ user_id=user.pk,
21
+ content_type_id=ContentType.objects.get_for_model(obj).pk,
22
+ object_id=str(obj.pk),
23
+ object_repr=str(obj)[:200],
24
+ action_flag=flag,
25
+ change_message=message,
26
+ )
27
+
28
+
29
+ def save_view(request, model_key):
30
+ """新增/修改:多对多走 ModelForm,文件/图片字段以存储后的路径字符串赋值"""
31
+ model, madmin = get_admin(model_key)
32
+ preq = perm_request(request.user)
33
+ body = read_body(request)
34
+ obj_id = body.get("id")
35
+ data = body.get("data") or {}
36
+
37
+ names = form_field_names(model) # 不含文件类字段
38
+ all_names = names + m2m_field_names(model)
39
+ instance = None
40
+ if obj_id:
41
+ instance = get_object_or_404(model, pk=obj_id)
42
+ if not madmin.has_change_permission(preq, instance):
43
+ return json_err("无修改权限", status=403)
44
+ else:
45
+ if not madmin.has_add_permission(preq):
46
+ return json_err("无新增权限", status=403)
47
+
48
+ Form = modelform_factory(model, fields=all_names)
49
+ payload = {k: v for k, v in data.items() if k in all_names}
50
+ # CheckboxField:前端以逗号分隔字符串提交,表单字段需要列表
51
+ for f in model._meta.concrete_fields:
52
+ if isinstance(f, pro_fields.CheckboxField) and isinstance(payload.get(f.name), str):
53
+ payload[f.name] = [v for v in payload[f.name].split(",") if v]
54
+ form = Form(data=payload, instance=instance)
55
+ if not form.is_valid():
56
+ return json_err(
57
+ "校验失败",
58
+ status=400,
59
+ errors={f: [str(m) for m in errs] for f, errs in form.errors.items()},
60
+ )
61
+
62
+ obj = form.save(commit=False)
63
+ # 文件/图片字段:前端先上传拿到 URL,保存时写回相对路径(兼容带 MEDIA_URL 前缀的值)
64
+ from django.conf import settings as dj_settings
65
+
66
+ media_url = dj_settings.MEDIA_URL or ""
67
+ for f in model._meta.concrete_fields:
68
+ if f.name in data and f.editable and isinstance(f, dbmodels.FileField):
69
+ v = data[f.name] or ""
70
+ if isinstance(v, str) and media_url:
71
+ if v.startswith(media_url):
72
+ v = v[len(media_url):]
73
+ elif v.startswith("/" + media_url.lstrip("/")):
74
+ v = v[len(media_url) + 1:]
75
+ v = v.lstrip("/")
76
+ setattr(obj, f.name, v)
77
+ obj.save()
78
+ form.save_m2m()
79
+ if instance:
80
+ _log_action(request.user, obj, CHANGE, "通过 SimpleUI AntD 修改")
81
+ else:
82
+ _log_action(request.user, obj, ADDITION, "通过 SimpleUI AntD 新增")
83
+ return json_ok({"id": obj.pk, "__str__": str(obj)})
84
+
85
+
86
+ def delete_view(request, model_key):
87
+ model, madmin = get_admin(model_key)
88
+ if not madmin.has_delete_permission(perm_request(request.user)):
89
+ return json_err("无删除权限", status=403)
90
+ ids = read_body(request).get("ids") or []
91
+ deleted = 0
92
+ for obj in model._default_manager.filter(pk__in=ids):
93
+ _log_action(request.user, obj, DELETION, "")
94
+ obj.delete()
95
+ deleted += 1
96
+ return json_ok({"deleted": deleted})
97
+
98
+
99
+ @staff_required
100
+ def upload(request):
101
+ """文件/图片上传:multipart,保存到默认存储,返回 URL"""
102
+ if request.method != "POST":
103
+ return json_err("仅支持 POST", status=405)
104
+ f = request.FILES.get("file")
105
+ if not f:
106
+ return json_err("缺少文件")
107
+ model_key = request.POST.get("model") or ""
108
+ field_name = request.POST.get("field") or ""
109
+ try:
110
+ model, madmin = get_admin(model_key)
111
+ except Exception:
112
+ return json_err("模型不存在", status=404)
113
+ if not madmin.has_change_permission(perm_request(request.user)):
114
+ return json_err("无修改权限,不能上传", status=403)
115
+ try:
116
+ mfield = model._meta.get_field(field_name)
117
+ except Exception:
118
+ return json_err("字段不存在", status=404)
119
+ if not isinstance(mfield, dbmodels.FileField):
120
+ return json_err("该字段不是文件/图片字段", status=400)
121
+
122
+ path = default_storage.save(
123
+ f"simpleui_antd/{model._meta.app_label}/{model._meta.model_name}/{field_name}/{f.name}", f
124
+ )
125
+ try:
126
+ url = default_storage.url(path)
127
+ except Exception:
128
+ url = path
129
+ return json_ok({"url": url, "path": path, "name": f.name, "size": f.size})
130
+
131
+
132
+ # ---------------- 内联(InlineModelAdmin) ----------------
133
+
134
+
135
+ def _resolve_inline(madmin, inline_key):
136
+ """inline_key 形如 '0:app.model',取该 ModelAdmin 的第 index 个 inline"""
137
+ try:
138
+ idx = int(inline_key.split(":", 1)[0])
139
+ except (ValueError, IndexError):
140
+ return None, None
141
+ if idx < 0 or idx >= len(madmin.inlines or []):
142
+ return None, None
143
+ cls = madmin.inlines[idx]
144
+ return cls, cls(madmin.model, admin_site())
145
+
146
+
147
+ def admin_site():
148
+ from django.contrib import admin
149
+
150
+ return admin.site
151
+
152
+
153
+ def _inline_fk(inst, child_model, parent_model):
154
+ from .registry import _inline_fk_name
155
+
156
+ return _inline_fk_name(inst, child_model, parent_model)
157
+
158
+
159
+ def _inline_fields(inst, child_model, fk):
160
+ from .registry import build_form_fields_meta
161
+
162
+ return build_form_fields_meta(child_model, exclude_files=True, exclude_names=(fk,))
163
+
164
+
165
+ @staff_required
166
+ def inline_list(request, model_key, inline_key):
167
+ """GET:某父记录的内联子记录列表"""
168
+ model, madmin = get_admin(model_key)
169
+ cls, inst = _resolve_inline(madmin, inline_key)
170
+ if inst is None:
171
+ return json_err("内联不存在", status=404)
172
+ child = inst.model
173
+ fk = _inline_fk(inst, child, model)
174
+ if not fk:
175
+ return json_err("内联外键无法确定", status=400)
176
+ preq = perm_request(request.user)
177
+ if not inst.has_view_permission(preq):
178
+ return json_err("无查看权限", status=403)
179
+ parent_id = request.GET.get("parent_id")
180
+ fields = _inline_fields(inst, child, fk)
181
+ qs = child._default_manager.filter(**{fk: parent_id}) if parent_id else child._default_manager.none()
182
+ items = [{"id": o.pk, "fields": {fd["key"]: _raw_value(o, fd) for fd in fields}} for o in qs]
183
+ return json_ok({"fields": fields, "items": items})
184
+
185
+
186
+ def _raw_value(obj, fd):
187
+ """内联行值:日期/时间格式化为字符串,布尔原样,其余 str"""
188
+ import datetime as _dt
189
+
190
+ v = getattr(obj, fd["key"], None)
191
+ t = fd["type"]
192
+ if v is None:
193
+ return "" if t not in ("bool",) else False
194
+ if t == "datetime":
195
+ from django.utils import timezone
196
+
197
+ try:
198
+ return timezone.localtime(v).strftime("%Y-%m-%d %H:%M:%S")
199
+ except (ValueError, OSError):
200
+ return v.strftime("%Y-%m-%d %H:%M:%S")
201
+ if t == "date":
202
+ return v.strftime("%Y-%m-%d")
203
+ if t == "time":
204
+ return v.strftime("%H:%M:%S")
205
+ if isinstance(v, bool):
206
+ return v
207
+ if isinstance(v, _dt.datetime):
208
+ return v.strftime("%Y-%m-%d %H:%M:%S")
209
+ return str(v)
210
+
211
+
212
+ @staff_required
213
+ def inline_save(request, model_key, inline_key):
214
+ """POST:内联子记录批量保存 {parent_id, added, updated, deleted}"""
215
+ model, madmin = get_admin(model_key)
216
+ cls, inst = _resolve_inline(madmin, inline_key)
217
+ if inst is None:
218
+ return json_err("内联不存在", status=404)
219
+ child = inst.model
220
+ fk = _inline_fk(inst, child, model)
221
+ if not fk:
222
+ return json_err("内联外键无法确定", status=400)
223
+ preq = perm_request(request.user)
224
+ body = read_body(request)
225
+ parent_id = body.get("parent_id")
226
+ if not parent_id:
227
+ return json_err("缺少 parent_id")
228
+ parent_obj = model._default_manager.get(pk=parent_id)
229
+ fields = _inline_fields(inst, child, fk)
230
+ names = [f["key"] for f in fields]
231
+
232
+ added = updated = deleted = 0
233
+ for row in body.get("added") or []:
234
+ if not inst.has_add_permission(preq, None):
235
+ return json_err("无新增权限", status=403)
236
+ form = modelform_factory(child, fields=names)(data={k: v for k, v in row.items() if k in names})
237
+ if not form.is_valid():
238
+ return json_err("内联行校验失败", status=400, errors=form.errors)
239
+ obj = form.save(commit=False)
240
+ setattr(obj, fk, parent_obj)
241
+ obj.save()
242
+ _log_action(request.user, obj, ADDITION, "通过 SimpleUI AntD 内联新增")
243
+ added += 1
244
+
245
+ for row in body.get("updated") or []:
246
+ obj = get_object_or_404(child, pk=row.get("id"))
247
+ if not inst.has_change_permission(preq, obj):
248
+ return json_err("无修改权限", status=403)
249
+ form = modelform_factory(child, fields=names)(
250
+ data={k: v for k, v in row.items() if k in names}, instance=obj
251
+ )
252
+ if not form.is_valid():
253
+ return json_err("内联行校验失败", status=400, errors=form.errors)
254
+ obj = form.save()
255
+ _log_action(request.user, obj, CHANGE, "通过 SimpleUI AntD 内联修改")
256
+ updated += 1
257
+
258
+ for rid in body.get("deleted") or []:
259
+ obj = get_object_or_404(child, pk=rid)
260
+ if not inst.has_delete_permission(preq, obj):
261
+ return json_err("无删除权限", status=403)
262
+ _log_action(request.user, obj, DELETION, "")
263
+ obj.delete()
264
+ deleted += 1
265
+
266
+ return json_ok({"added": added, "updated": updated, "deleted": deleted})
267
+
268
+
269
+ # ---------------- 批量 Action ----------------
270
+
271
+
272
+ @staff_required
273
+ def action_view(request, model_key):
274
+ """执行 ModelAdmin.actions(仅无中间页的 Action)"""
275
+ if request.method != "POST":
276
+ return json_err("仅支持 POST", status=405)
277
+ model, madmin = get_admin(model_key)
278
+ preq = perm_request(request.user)
279
+ body = read_body(request)
280
+ name = body.get("name")
281
+ ids = body.get("ids") or []
282
+ if not ids:
283
+ return json_err("请先勾选记录")
284
+ try:
285
+ actions = madmin.get_actions(preq)
286
+ except Exception:
287
+ actions = {}
288
+ if name not in actions:
289
+ return json_err("Action 不存在", status=404)
290
+ func = actions[name][0]
291
+ qs = madmin.get_queryset(request).filter(pk__in=ids)
292
+ try:
293
+ result = func(madmin, request, qs)
294
+ except Exception as e: # noqa: BLE001
295
+ return json_err(f"Action 执行失败:{e}")
296
+ if isinstance(result, HttpResponse):
297
+ return json_err("该 Action 需要跳转中间页,暂不支持", status=400)
298
+ msgs = [{"level": m.level_tag, "text": str(m.message)} for m in messages.get_messages(request)]
299
+ return json_ok({"messages": msgs})
@@ -0,0 +1,84 @@
1
+ """数据大屏接口:布局持久化 + 图表数据源"""
2
+
3
+ import datetime
4
+
5
+ from django.contrib.admin.models import LogEntry
6
+ from django.utils import timezone
7
+
8
+ from ..models import DashboardLayout
9
+ from .base import json_err, json_ok, read_body, staff_required
10
+
11
+
12
+ @staff_required
13
+ def layout(request):
14
+ """GET:读取当前用户大屏布局;PUT:保存拖拽后的布局"""
15
+ if request.method == "PUT":
16
+ body = read_body(request)
17
+ layout = body.get("layout")
18
+ if not isinstance(layout, list):
19
+ return json_err("layout 必须是数组")
20
+ clean = []
21
+ for w in layout:
22
+ if not isinstance(w, dict) or "id" not in w or "type" not in w:
23
+ continue
24
+ clean.append(
25
+ {
26
+ "id": str(w["id"])[:64],
27
+ "type": str(w["type"])[:32],
28
+ "title": str(w.get("title", ""))[:64],
29
+ "span": min(4, max(1, int(w.get("span", 2) or 2))),
30
+ }
31
+ )
32
+ obj, _ = DashboardLayout.objects.get_or_create(user=request.user)
33
+ obj.layout = clean
34
+ obj.save(update_fields=["layout", "updated_at"])
35
+ return json_ok({"saved": len(clean)})
36
+
37
+ obj = getattr(request.user, "simpleui_antd_dashboard", None)
38
+ return json_ok({"layout": obj.layout if obj else None})
39
+
40
+
41
+ @staff_required
42
+ def data(request):
43
+ """图表数据源:模型记录数 + 近 7 日管理动作趋势"""
44
+ preq_items = []
45
+ from .base import perm_request
46
+
47
+ preq = perm_request(request.user)
48
+ stats = []
49
+ for model, madmin in admin_registry().items():
50
+ if not madmin.has_view_permission(preq):
51
+ continue
52
+ stats.append(
53
+ {
54
+ "key": f"{model._meta.app_label}.{model._meta.model_name}",
55
+ "label": str(model._meta.verbose_name),
56
+ "count": model._default_manager.count(),
57
+ }
58
+ )
59
+ stats.sort(key=lambda x: -x["count"])
60
+
61
+ today = timezone.localdate()
62
+ days = [today - datetime.timedelta(days=i) for i in range(6, -1, -1)]
63
+ activity = []
64
+ for d in days:
65
+ count = LogEntry.objects.filter(action_time__date=d).count()
66
+ activity.append({"date": d.strftime("%m-%d"), "count": count})
67
+
68
+ logs = []
69
+ flags = dict(LogEntry._meta.get_field("action_flag").choices)
70
+ for e in LogEntry.objects.select_related("content_type", "user").order_by("-action_time")[:6]:
71
+ who = str(e.user) if e.user else "系统"
72
+ logs.append(
73
+ {
74
+ "date": timezone.localtime(e.action_time).strftime("%Y年%m月%d日 %H:%M"),
75
+ "text": f'{who}: {flags.get(e.action_flag, "操作")} "{e.object_repr}"',
76
+ }
77
+ )
78
+ return json_ok({"stats": stats, "activity": activity, "logs": logs})
79
+
80
+
81
+ def admin_registry():
82
+ from django.contrib import admin
83
+
84
+ return admin.site._registry