yanleafadmin 2.0.7__tar.gz → 2.1.0__tar.gz

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 (125) hide show
  1. {yanleafadmin-2.0.7/yanleafadmin.egg-info → yanleafadmin-2.1.0}/PKG-INFO +16 -5
  2. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/README.md +14 -3
  3. yanleafadmin-2.1.0/apps/ai_assistant/migrations/0001_initial.py +32 -0
  4. yanleafadmin-2.1.0/apps/ai_assistant/migrations/0002_aichatsession_aichatmessage_session_and_more.py +49 -0
  5. yanleafadmin-2.1.0/apps/ai_assistant/models.py +34 -0
  6. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/ai_assistant/services.py +1 -2
  7. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/ai_assistant/static/ai/ai-search.js +35 -18
  8. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/ai_assistant/templates/ai/fullpage.html +71 -1
  9. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/ai_assistant/urls.py +3 -0
  10. yanleafadmin-2.1.0/apps/ai_assistant/views.py +116 -0
  11. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/apps.py +18 -1
  12. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/css/change-list.css +9 -1
  13. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/css/login.css +5 -3
  14. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/change_list.html +20 -0
  15. yanleafadmin-2.1.0/apps/users/migrations/__init__.py +0 -0
  16. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/setup.py +3 -2
  17. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0/yanleafadmin.egg-info}/PKG-INFO +16 -5
  18. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/yanleafadmin.egg-info/SOURCES.txt +4 -0
  19. yanleafadmin-2.0.7/apps/ai_assistant/views.py +0 -40
  20. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/LICENSE +0 -0
  21. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/MANIFEST.in +0 -0
  22. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/__init__.py +0 -0
  23. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/ai_assistant/__init__.py +0 -0
  24. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/ai_assistant/apps.py +0 -0
  25. {yanleafadmin-2.0.7/apps/dashboard_engine → yanleafadmin-2.1.0/apps/ai_assistant/migrations}/__init__.py +0 -0
  26. {yanleafadmin-2.0.7/apps/dashboard_engine/migrations → yanleafadmin-2.1.0/apps/dashboard_engine}/__init__.py +0 -0
  27. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/dashboard_engine/admin.py +0 -0
  28. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/dashboard_engine/apps.py +0 -0
  29. {yanleafadmin-2.0.7/apps/erd_engine → yanleafadmin-2.1.0/apps/dashboard_engine/migrations}/__init__.py +0 -0
  30. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/dashboard_engine/models.py +0 -0
  31. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/dashboard_engine/templates/admin/base_site.html +0 -0
  32. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/dashboard_engine/templates/admin/dashboard_index.html +0 -0
  33. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/dashboard_engine/tests.py +0 -0
  34. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/dashboard_engine/views.py +0 -0
  35. {yanleafadmin-2.0.7/apps/theme → yanleafadmin-2.1.0/apps/erd_engine}/__init__.py +0 -0
  36. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/erd_engine/apps.py +0 -0
  37. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/erd_engine/sql_parser.py +0 -0
  38. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/erd_engine/static/erd/er-diagram.js +0 -0
  39. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/erd_engine/templates/erd/er_diagram.html +0 -0
  40. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/erd_engine/urls.py +0 -0
  41. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/erd_engine/views.py +0 -0
  42. {yanleafadmin-2.0.7/apps/theme/components → yanleafadmin-2.1.0/apps/theme}/__init__.py +0 -0
  43. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/admin.py +0 -0
  44. {yanleafadmin-2.0.7/apps/theme/migrations → yanleafadmin-2.1.0/apps/theme/components}/__init__.py +0 -0
  45. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/components/actions.py +0 -0
  46. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/components/charts.py +0 -0
  47. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/context_processors.py +0 -0
  48. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/forms.py +0 -0
  49. {yanleafadmin-2.0.7/apps/theme/templatetags → yanleafadmin-2.1.0/apps/theme/migrations}/__init__.py +0 -0
  50. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/models.py +0 -0
  51. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/settings.py +0 -0
  52. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/css/admin.css +0 -0
  53. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/css/change-form.css +0 -0
  54. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/css/dashboard.css +0 -0
  55. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/css/password-form.css +0 -0
  56. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/css/user-change-form.css +0 -0
  57. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/js/components.js +0 -0
  58. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/js/core.js +0 -0
  59. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/js/filter-widget.js +0 -0
  60. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/js/i18n/datatables.en.json +0 -0
  61. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/js/i18n/datatables.zh-hans.json +0 -0
  62. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/js/login.js +0 -0
  63. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/js/password-check.js +0 -0
  64. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/js/smart-chart.js +0 -0
  65. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/js/user-password-field.js +0 -0
  66. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/bulma/bulma.min.css +0 -0
  67. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/bulma-calendar/bulma-calendar.min.css +0 -0
  68. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/bulma-calendar/bulma-calendar.min.js +0 -0
  69. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/datatables/css/buttons.bulma.min.css +0 -0
  70. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/datatables/css/dataTables.bulma.min.css +0 -0
  71. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/datatables/js/buttons.html5.min.js +0 -0
  72. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/datatables/js/buttons.print.min.js +0 -0
  73. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/datatables/js/dataTables.bulma.min.js +0 -0
  74. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/datatables/js/dataTables.buttons.min.js +0 -0
  75. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/datatables/js/jquery.dataTables.min.js +0 -0
  76. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/dropzone/dropzone-min.js +0 -0
  77. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/dropzone/dropzone.css +0 -0
  78. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/echarts/echarts.min.js +0 -0
  79. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/fontawesome/css/all.min.css +0 -0
  80. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/fontawesome/webfonts/fa-brands-400.woff2 +0 -0
  81. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/fontawesome/webfonts/fa-regular-400.woff2 +0 -0
  82. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/fontawesome/webfonts/fa-solid-900.woff2 +0 -0
  83. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/gojs/go.js +0 -0
  84. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/jquery/jquery.min.js +0 -0
  85. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/jszip/jszip.min.js +0 -0
  86. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/select2/css/select2.min.css +0 -0
  87. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/select2/js/select2.min.js +0 -0
  88. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/static/yanleafadmin/vendor/sweetalert2/sweetalert2.all.min.js +0 -0
  89. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/app_index.html +0 -0
  90. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/auth/user/add_form.html +0 -0
  91. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/auth/user/change_form.html +0 -0
  92. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/auth/user/change_password.html +0 -0
  93. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/base.html +0 -0
  94. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/base_site.html +0 -0
  95. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/change_form.html +0 -0
  96. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/change_list_results.html +0 -0
  97. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/dashboard_index.html +0 -0
  98. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/edit_inline/tabular.html +0 -0
  99. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/login.html +0 -0
  100. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/admin/pagination.html +0 -0
  101. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/registration/password_change_done.html +0 -0
  102. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templates/registration/password_change_form.html +0 -0
  103. {yanleafadmin-2.0.7/apps/users → yanleafadmin-2.1.0/apps/theme/templatetags}/__init__.py +0 -0
  104. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templatetags/yla_charts.py +0 -0
  105. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/templatetags/yla_components.py +0 -0
  106. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/tests.py +0 -0
  107. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/urls.py +0 -0
  108. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/theme/views.py +0 -0
  109. {yanleafadmin-2.0.7/apps/users/management → yanleafadmin-2.1.0/apps/users}/__init__.py +0 -0
  110. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/users/admin.py +0 -0
  111. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/users/apps.py +0 -0
  112. {yanleafadmin-2.0.7/apps/users/management/commands → yanleafadmin-2.1.0/apps/users/management}/__init__.py +0 -0
  113. {yanleafadmin-2.0.7/apps/users/migrations → yanleafadmin-2.1.0/apps/users/management/commands}/__init__.py +0 -0
  114. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/users/management/commands/seed_demo_data.py +0 -0
  115. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/users/migrations/0001_initial.py +0 -0
  116. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/users/models.py +0 -0
  117. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/users/tests.py +0 -0
  118. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/apps/users/views.py +0 -0
  119. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/requirements.txt +0 -0
  120. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/setup.cfg +0 -0
  121. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/yanleafadmin/__init__.py +0 -0
  122. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/yanleafadmin/apps.py +0 -0
  123. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/yanleafadmin.egg-info/dependency_links.txt +0 -0
  124. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/yanleafadmin.egg-info/requires.txt +0 -0
  125. {yanleafadmin-2.0.7 → yanleafadmin-2.1.0}/yanleafadmin.egg-info/top_level.txt +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: yanleafadmin
3
- Version: 2.0.7
3
+ Version: 2.1.0
4
4
  Summary: 极简白色现代 Django Admin 主题体系 — 基于 Django + Bulma CSS
5
5
  Home-page: https://github.com/zhouyanye/yanleafadmin
6
- Author: zhouyanye
6
+ Author: zhouyanye,YanLeaf AI Lab
7
7
  Classifier: Framework :: Django
8
8
  Classifier: Framework :: Django :: 5.0
9
9
  Classifier: Programming Language :: Python :: 3.10
@@ -29,6 +29,8 @@ License-File: LICENSE
29
29
  - 侧边栏导航 + 顶部面包屑 + 用户菜单
30
30
  - 登录页验证码(django-simple-captcha)
31
31
 
32
+ ![首页](screenshots/dashboard.png)
33
+
32
34
  ### 组件与交互
33
35
  - **DataTables**:列表页一键导出 Excel / CSV / 打印
34
36
  - **SweetAlert2**:编辑/删除确认弹窗,Toast 弱提示
@@ -36,12 +38,16 @@ License-File: LICENSE
36
38
  - **Dropzone**:拖拽文件上传
37
39
  - **穿梭框重写**:Bulma 卡片式左右选择器
38
40
 
41
+ ![列表页](screenshots/change-list.png)
42
+
39
43
  ### 可视化引擎
40
44
  - **SmartChart**:根据 Model 字段类型自动生成 ECharts 图表(趋势/饼图/柱状图)
41
45
  - **仪表盘热力图**:近半年操作活跃度(类 GitHub 绿格子墙)
42
46
  - **模块玫瑰图**:近 7 天模块活跃度分布
43
47
  - **系统动态时间线**:10 条最新操作记录,Badge 四级颜色标记,展开变更 Diff
44
48
 
49
+ ![系统日志](screenshots/activity.png)
50
+
45
51
  ### ER 图引擎
46
52
  - **Django 模型直读**:一键生成当前项目所有表的实体关系图
47
53
  - **SQL DDL 解析**:粘贴建表语句自动渲染 ER 图
@@ -50,14 +56,18 @@ License-File: LICENSE
50
56
  - **中英文切换**:中文优先 COMMENT 注释,英文用原名
51
57
  - **导出 PNG** + **导出 Word 三线表 (.docx)**
52
58
 
59
+ ![ER图](screenshots/er-diagram.png)
60
+
53
61
  ### AI 数据助手
54
62
  - **右下角悬浮聊天机器人**,点击即可对话
55
63
  - **自然语言查询**:基于 DeepSeek 驱动,自动转 Django ORM
56
64
  - **ECharts 图表**:查询结果原地渲染折线图/饼图/柱状图
57
65
  - **多模型支持**:DeepSeek Chat / Reasoner / GPT-4o / 通义千问
58
66
  - **配置保存在浏览器**:API Key 不上传服务器
59
- - **聊天历史**:localStorage 持久化最近 50 条对话
60
- - **全屏展开**:新标签页全屏对话
67
+ - **聊天历史**:数据库持久化,支持多会话管理,会话 UUID 可分享
68
+ - **全屏展开**:新标签页全屏对话,历史记录下拉切换
69
+
70
+ ![AI助手](screenshots/ai-assistant.png)
61
71
 
62
72
  ### 开发者体验
63
73
  - **pip install**:`pip install yanleafadmin`
@@ -229,6 +239,7 @@ yanleaf_admin_project/
229
239
  ├── yanleafadmin/ # pip 入口包(代理 apps/theme)
230
240
  │ ├── __init__.py
231
241
  │ └── apps.py
242
+ ├── screenshots/ # 截图
232
243
  ├── docs/ # 文档与设计规格
233
244
  ├── setup.py # pip 安装包
234
245
  ├── MANIFEST.in
@@ -243,7 +254,7 @@ yanleaf_admin_project/
243
254
  from django.urls import path, include
244
255
 
245
256
  urlpatterns = [
246
- path('i18n/', include('django.conf.urls.i18n')), # 语言切换(必须)
257
+ path('i18n/', include('django.conf.urls.i18n')), # 语言切换
247
258
  path('admin/', admin.site.urls),
248
259
  path('admin/erd/', include('apps.erd_engine.urls')), # ER 图
249
260
  path('api/ai/', include('apps.ai_assistant.urls')), # AI 助手
@@ -10,6 +10,8 @@
10
10
  - 侧边栏导航 + 顶部面包屑 + 用户菜单
11
11
  - 登录页验证码(django-simple-captcha)
12
12
 
13
+ ![首页](screenshots/dashboard.png)
14
+
13
15
  ### 组件与交互
14
16
  - **DataTables**:列表页一键导出 Excel / CSV / 打印
15
17
  - **SweetAlert2**:编辑/删除确认弹窗,Toast 弱提示
@@ -17,12 +19,16 @@
17
19
  - **Dropzone**:拖拽文件上传
18
20
  - **穿梭框重写**:Bulma 卡片式左右选择器
19
21
 
22
+ ![列表页](screenshots/change-list.png)
23
+
20
24
  ### 可视化引擎
21
25
  - **SmartChart**:根据 Model 字段类型自动生成 ECharts 图表(趋势/饼图/柱状图)
22
26
  - **仪表盘热力图**:近半年操作活跃度(类 GitHub 绿格子墙)
23
27
  - **模块玫瑰图**:近 7 天模块活跃度分布
24
28
  - **系统动态时间线**:10 条最新操作记录,Badge 四级颜色标记,展开变更 Diff
25
29
 
30
+ ![系统日志](screenshots/activity.png)
31
+
26
32
  ### ER 图引擎
27
33
  - **Django 模型直读**:一键生成当前项目所有表的实体关系图
28
34
  - **SQL DDL 解析**:粘贴建表语句自动渲染 ER 图
@@ -31,14 +37,18 @@
31
37
  - **中英文切换**:中文优先 COMMENT 注释,英文用原名
32
38
  - **导出 PNG** + **导出 Word 三线表 (.docx)**
33
39
 
40
+ ![ER图](screenshots/er-diagram.png)
41
+
34
42
  ### AI 数据助手
35
43
  - **右下角悬浮聊天机器人**,点击即可对话
36
44
  - **自然语言查询**:基于 DeepSeek 驱动,自动转 Django ORM
37
45
  - **ECharts 图表**:查询结果原地渲染折线图/饼图/柱状图
38
46
  - **多模型支持**:DeepSeek Chat / Reasoner / GPT-4o / 通义千问
39
47
  - **配置保存在浏览器**:API Key 不上传服务器
40
- - **聊天历史**:localStorage 持久化最近 50 条对话
41
- - **全屏展开**:新标签页全屏对话
48
+ - **聊天历史**:数据库持久化,支持多会话管理,会话 UUID 可分享
49
+ - **全屏展开**:新标签页全屏对话,历史记录下拉切换
50
+
51
+ ![AI助手](screenshots/ai-assistant.png)
42
52
 
43
53
  ### 开发者体验
44
54
  - **pip install**:`pip install yanleafadmin`
@@ -210,6 +220,7 @@ yanleaf_admin_project/
210
220
  ├── yanleafadmin/ # pip 入口包(代理 apps/theme)
211
221
  │ ├── __init__.py
212
222
  │ └── apps.py
223
+ ├── screenshots/ # 截图
213
224
  ├── docs/ # 文档与设计规格
214
225
  ├── setup.py # pip 安装包
215
226
  ├── MANIFEST.in
@@ -224,7 +235,7 @@ yanleaf_admin_project/
224
235
  from django.urls import path, include
225
236
 
226
237
  urlpatterns = [
227
- path('i18n/', include('django.conf.urls.i18n')), # 语言切换(必须)
238
+ path('i18n/', include('django.conf.urls.i18n')), # 语言切换
228
239
  path('admin/', admin.site.urls),
229
240
  path('admin/erd/', include('apps.erd_engine.urls')), # ER 图
230
241
  path('api/ai/', include('apps.ai_assistant.urls')), # AI 助手
@@ -0,0 +1,32 @@
1
+ # Generated by Django 5.2.12 on 2026-05-28 01:53
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name='AiChatMessage',
19
+ fields=[
20
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+ ('role', models.CharField(max_length=10)),
22
+ ('content', models.TextField()),
23
+ ('created_at', models.DateTimeField(auto_now_add=True)),
24
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
25
+ ],
26
+ options={
27
+ 'verbose_name': 'AI 聊天记录',
28
+ 'verbose_name_plural': 'AI 聊天记录',
29
+ 'ordering': ['created_at'],
30
+ },
31
+ ),
32
+ ]
@@ -0,0 +1,49 @@
1
+ # Generated by Django 5.2.12 on 2026-05-28 02:10
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ('ai_assistant', '0001_initial'),
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name='AiChatSession',
18
+ fields=[
19
+ ('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
20
+ ('title', models.CharField(default='新对话', max_length=100)),
21
+ ('created_at', models.DateTimeField(auto_now_add=True)),
22
+ ('updated_at', models.DateTimeField(auto_now=True)),
23
+ ],
24
+ options={
25
+ 'verbose_name': 'AI 对话会话',
26
+ 'verbose_name_plural': 'AI 对话会话',
27
+ 'ordering': ['-updated_at'],
28
+ },
29
+ ),
30
+ migrations.AddField(
31
+ model_name='aichatmessage',
32
+ name='session',
33
+ field=models.CharField(db_index=True, default='default', max_length=36),
34
+ ),
35
+ migrations.AddField(
36
+ model_name='aichatmessage',
37
+ name='title',
38
+ field=models.CharField(blank=True, default='', max_length=100),
39
+ ),
40
+ migrations.AddIndex(
41
+ model_name='aichatmessage',
42
+ index=models.Index(fields=['user', 'session'], name='ai_assistan_user_id_562e9d_idx'),
43
+ ),
44
+ migrations.AddField(
45
+ model_name='aichatsession',
46
+ name='user',
47
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
48
+ ),
49
+ ]
@@ -0,0 +1,34 @@
1
+ """AI Chat History model"""
2
+ from django.db import models
3
+ from django.conf import settings
4
+
5
+
6
+ class AiChatMessage(models.Model):
7
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
8
+ session = models.CharField(max_length=36, default='default', db_index=True)
9
+ role = models.CharField(max_length=10)
10
+ content = models.TextField()
11
+ title = models.CharField(max_length=100, blank=True, default='')
12
+ created_at = models.DateTimeField(auto_now_add=True)
13
+
14
+ class Meta:
15
+ ordering = ['created_at']
16
+ verbose_name = 'AI 聊天记录'
17
+ verbose_name_plural = 'AI 聊天记录'
18
+ indexes = [
19
+ models.Index(fields=['user', 'session']),
20
+ ]
21
+
22
+
23
+ class AiChatSession(models.Model):
24
+ """对话会话"""
25
+ id = models.CharField(max_length=36, primary_key=True)
26
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
27
+ title = models.CharField(max_length=100, default='新对话')
28
+ created_at = models.DateTimeField(auto_now_add=True)
29
+ updated_at = models.DateTimeField(auto_now=True)
30
+
31
+ class Meta:
32
+ ordering = ['-updated_at']
33
+ verbose_name = 'AI 对话会话'
34
+ verbose_name_plural = 'AI 对话会话'
@@ -158,10 +158,9 @@ class AIQueryService:
158
158
  })
159
159
  result_data = list(qs[:parsed.get('limit', 30)])
160
160
  else:
161
- # Normalize order_by: AI might say "-count" but annotation is "_count"
161
+ # Normalize order_by: AI might return "-count" but annotation is "_count"
162
162
  order = parsed.get('order_by', '-_count')
163
163
  if order in ('count', '-count'):
164
- order = '-' + order if order.startswith('-') else order
165
164
  order = order.replace('count', '_count')
166
165
  qs = qs.values(gb_field).annotate(_count=Count('id')).order_by(order)
167
166
  chart_data = []
@@ -12,8 +12,13 @@
12
12
 
13
13
  function loadCfg() { try { return JSON.parse(localStorage.getItem('yla_ai_config') || '{}'); } catch(e) { return {}; } }
14
14
  function saveCfg(c) { localStorage.setItem('yla_ai_config', JSON.stringify(c)); }
15
+ function sessionId() {
16
+ var id = localStorage.getItem('yla_ai_session');
17
+ if (!id) { id = 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); localStorage.setItem('yla_ai_session', id); }
18
+ return id;
19
+ }
15
20
 
16
- var T = '#485fc7'; // YanLeafAdmin 主题色
21
+ var T = '#485fc7';
17
22
 
18
23
  var html = '' +
19
24
  '<div id="yla-ai-widget">' +
@@ -139,13 +144,15 @@
139
144
  $('#yla-ai-settings-back').on('click', function() { $settings.hide(); $panel.show(); $input.focus(); });
140
145
  // 新窗口展开
141
146
  $('#yla-ai-expand').on('click', function() {
142
- window.open('/api/ai/full/', '_blank');
147
+ window.open('/api/ai/full/' + sessionId() + '/', '_blank');
143
148
  });
144
149
 
145
150
  $('#yla-ai-clear-chat').on('click', function() {
146
151
  $body.find('.yla-ai-msg').remove();
147
152
  $body.append('<div class="yla-ai-msg ai-bot"><div class="yla-ai-bubble">对话已清空。<br><br>你可以继续问我数据问题,比如:<br>• "最近 7 天新增了多少用户?"<br>• "每个组有多少用户?"<br>• "活跃用户占比是多少?"</div></div>');
148
153
  $body.scrollTop($body[0].scrollHeight);
154
+ // Clear from server
155
+ $.ajax({ url: '/api/ai/history/?session=' + sessionId(), method: 'DELETE', headers: { 'X-CSRFToken': window.YLA.csrfToken || '' } });
149
156
  });
150
157
 
151
158
  function loadCfgForm() {
@@ -177,7 +184,7 @@
177
184
  var label = type === 'bot' ? '<span class="ai-gen-label">AI 生成</span>' : '';
178
185
  $body.append('<div class="yla-ai-msg ' + cls + '"><div class="yla-ai-bubble">' + html + label + '</div></div>');
179
186
  $body.scrollTop($body[0].scrollHeight);
180
- saveHistory();
187
+ _queueSave();
181
188
  }
182
189
  function addTyping() {
183
190
  var $t = $('<div class="yla-ai-msg ai-bot"><div class="yla-ai-typing"><span></span><span></span><span></span></div></div>');
@@ -228,31 +235,41 @@
228
235
  }
229
236
  }
230
237
 
231
- function saveHistory() {
238
+ var _saveTimer = null;
239
+ function _queueSave() {
240
+ clearTimeout(_saveTimer);
241
+ _saveTimer = setTimeout(_doSave, 500);
242
+ }
243
+ function _doSave() {
232
244
  var msgs = [];
245
+ var title = '';
233
246
  $('#yla-ai-body .yla-ai-msg').each(function() {
234
247
  var type = $(this).hasClass('ai-user') ? 'user' : 'bot';
235
248
  var html = $(this).find('.yla-ai-bubble').html();
236
- msgs.push({ type: type, html: html });
249
+ if (!title && type === 'user') title = $(this).find('.yla-ai-bubble').text().substring(0, 50);
250
+ msgs.push({ role: type, content: html });
237
251
  });
238
- // Keep only last 50 messages
239
252
  if (msgs.length > 50) msgs = msgs.slice(-50);
240
- try { localStorage.setItem('yla_ai_history', JSON.stringify(msgs)); } catch(e) {}
253
+ if (!msgs.length) return;
254
+ $.ajax({
255
+ url: '/api/ai/history/', method: 'POST',
256
+ contentType: 'application/json',
257
+ data: JSON.stringify({ session: sessionId(), title: title, messages: msgs }),
258
+ headers: { 'X-CSRFToken': window.YLA.csrfToken || '' }
259
+ });
241
260
  }
242
261
 
243
262
  function loadHistory() {
244
- try {
245
- var msgs = JSON.parse(localStorage.getItem('yla_ai_history') || '[]');
246
- if (msgs.length) {
247
- $('#yla-ai-body').empty();
248
- msgs.forEach(function(m) { addMsgSilent(m.type, m.html); });
263
+ $.getJSON('/api/ai/history/?session=' + sessionId(), function(data) {
264
+ if (data.messages && data.messages.length) {
265
+ $body.empty();
266
+ data.messages.forEach(function(m) {
267
+ var cls = m.role === 'user' ? 'ai-user' : 'ai-bot';
268
+ var label = m.role === 'bot' ? '<span class="ai-gen-label">AI 生成</span>' : '';
269
+ $body.append('<div class="yla-ai-msg ' + cls + '"><div class="yla-ai-bubble">' + (m.content || '') + label + '</div></div>');
270
+ });
249
271
  $body.scrollTop($body[0].scrollHeight);
250
272
  }
251
- } catch(e) {}
252
- }
253
-
254
- function addMsgSilent(type, html) {
255
- var cls = type === 'user' ? 'ai-user' : 'ai-bot';
256
- $body.append('<div class="yla-ai-msg ' + cls + '"><div class="yla-ai-bubble">' + html + '</div></div>');
273
+ });
257
274
  }
258
275
  })(jQuery);
@@ -33,12 +33,25 @@
33
33
  .theme-dark .ai-full-msg.bot .ai-full-bubble { background:#2a2a3a;color:#ddd; }
34
34
  .theme-dark #ai-full-input { background:#2a2a3a;border-color:#444;color:#ddd; }
35
35
  .theme-dark .ai-full-example { background:#2a2a3a; }
36
+ .ai-session-bar { display:flex;align-items:center;gap:8px;padding:6px 14px;background:var(--yla-bg-card,#fff);border:1px solid var(--yla-border,#e5e5e5);border-bottom:none;border-radius:12px 12px 0 0;font-size:.78rem }
37
+ .ai-session-bar select { padding:4px 8px;border:1px solid var(--yla-border,#ddd);border-radius:4px;font-size:.78rem;background:var(--yla-bg-input);color:var(--yla-text-primary) }
38
+ .ai-session-bar a { color:var(--yla-text-muted);text-decoration:none;font-size:.75rem }
39
+ .ai-session-bar a:hover { color:#485fc7 }
40
+ .theme-dark .ai-session-bar { background:#1e1e2e;border-color:#333 }
41
+ .theme-dark .ai-session-bar select { background:#2a2a3a;border-color:#444;color:#ddd }
36
42
  </style>
37
43
  {% endblock %}
38
44
 
39
45
  {% block content %}
40
46
  <div class="ai-full-wrap">
41
- <div id="ai-full-body" class="ai-full-body">
47
+ <div class="ai-session-bar" id="ai-session-bar">
48
+ <span style="font-weight:600;">历史对话</span>
49
+ <select id="ai-session-select" onchange="loadSession(this.value)">
50
+ <option value="">新对话</option>
51
+ </select>
52
+ <a href="/api/ai/full/" style="margin-left:auto;">+ 新建</a>
53
+ </div>
54
+ <div id="ai-full-body" class="ai-full-body" style="border-radius:0 0 12px 12px;">
42
55
  <div class="ai-full-empty">
43
56
  <i class="fas fa-robot"></i>
44
57
  <p style="font-size:1.1rem;font-weight:600;">AI 数据助手</p>
@@ -66,6 +79,7 @@
66
79
  var $input = document.getElementById('ai-full-input');
67
80
  var $body = document.getElementById('ai-full-body');
68
81
  var hasMsg = false;
82
+ var sid = '{{ session_id|default:"" }}';
69
83
 
70
84
  function loadCfg() {
71
85
  try { return JSON.parse(localStorage.getItem('yla_ai_config') || '{}'); } catch(e) { return {}; }
@@ -79,6 +93,24 @@
79
93
  d.innerHTML = '<div class="ai-full-bubble">' + html + label + '</div>';
80
94
  $body.appendChild(d);
81
95
  $body.scrollTop = $body.scrollHeight;
96
+ if (sid) _queueSave();
97
+ }
98
+ var _fst = null;
99
+ function _queueSave() { clearTimeout(_fst); _fst = setTimeout(_doSave, 500); }
100
+ function _doSave() {
101
+ var msgs = [], title = '';
102
+ [].forEach.call($body.querySelectorAll('.ai-full-msg'), function(el) {
103
+ var ty = el.classList.contains('user') ? 'user' : 'bot';
104
+ var h = el.querySelector('.ai-full-bubble').innerHTML;
105
+ if (!title && ty === 'user') title = el.querySelector('.ai-full-bubble').textContent.substring(0, 50);
106
+ msgs.push({ role: ty, content: h });
107
+ });
108
+ if (!msgs.length) return;
109
+ var x = new XMLHttpRequest();
110
+ x.open('POST', '/api/ai/history/');
111
+ x.setRequestHeader('Content-Type', 'application/json');
112
+ x.setRequestHeader('X-CSRFToken', window.YLA.csrfToken || '');
113
+ x.send(JSON.stringify({ session: sid, title: title, messages: msgs }));
82
114
  }
83
115
  function addTyping() {
84
116
  if (!hasMsg) { $body.innerHTML = ''; hasMsg = true; }
@@ -138,6 +170,44 @@
138
170
  window.quickAsk = function(el) { $input.value = el.textContent; send(); };
139
171
  document.getElementById('ai-full-send').onclick = send;
140
172
  $input.onkeydown = function(e) { if (e.key === 'Enter') send(); };
173
+ // 加载会话列表
174
+ function loadSessions() {
175
+ var x = new XMLHttpRequest();
176
+ x.open('GET', '/api/ai/sessions/');
177
+ x.onload = function() {
178
+ try { var d = JSON.parse(x.responseText);
179
+ var sel = document.getElementById('ai-session-select');
180
+ if (d.sessions) d.sessions.forEach(function(s) {
181
+ var o = document.createElement('option');
182
+ o.value = s.id; o.textContent = s.title; o.selected = (s.id === sid);
183
+ sel.appendChild(o);
184
+ });
185
+ } catch(e) {}
186
+ }; x.send();
187
+ }
188
+ window.loadSession = function(id) {
189
+ if (id) { window.location.href = '/api/ai/full/' + id + '/'; }
190
+ else { window.location.href = '/api/ai/full/'; }
191
+ };
192
+ loadSessions();
193
+
194
+ if (sid) {
195
+ var xh = new XMLHttpRequest();
196
+ xh.open('GET', '/api/ai/history/?session=' + sid);
197
+ xh.onload = function() {
198
+ try { var d = JSON.parse(xh.responseText); if (d.messages && d.messages.length) {
199
+ $body.innerHTML = ''; hasMsg = true;
200
+ d.messages.forEach(function(m) {
201
+ var cls = m.role === 'user' ? 'user' : 'bot';
202
+ var label = m.role === 'bot' ? '<span class="ai-gen-tag">AI 生成</span>' : '';
203
+ var el = document.createElement('div'); el.className = 'ai-full-msg ' + cls;
204
+ el.innerHTML = '<div class="ai-full-bubble">' + (m.content || '') + label + '</div>';
205
+ $body.appendChild(el);
206
+ });
207
+ $body.scrollTop = $body.scrollHeight;
208
+ }} catch(e) {}
209
+ }; xh.send();
210
+ }
141
211
  })();
142
212
  </script>
143
213
  {% endblock %}
@@ -6,4 +6,7 @@ app_name = 'ai_assistant'
6
6
  urlpatterns = [
7
7
  path('query/', views.ai_query, name='ai_query'),
8
8
  path('full/', views.ai_fullpage, name='ai_fullpage'),
9
+ path('full/<str:session_id>/', views.ai_fullpage, name='ai_fullpage_session'),
10
+ path('history/', views.ai_history, name='ai_history'),
11
+ path('sessions/', views.ai_sessions, name='ai_sessions'),
9
12
  ]
@@ -0,0 +1,116 @@
1
+ import json
2
+ from django.http import JsonResponse
3
+ from django.shortcuts import render
4
+ from django.views.decorators.http import require_POST
5
+ from django.contrib.admin.views.decorators import staff_member_required
6
+ from .services import AIQueryService
7
+
8
+
9
+ @staff_member_required
10
+ def ai_fullpage(request, session_id=None):
11
+ """Full-page AI chat view, optionally with session history"""
12
+ from .models import AiChatSession
13
+ title = 'AI 数据助手'
14
+ if session_id:
15
+ try:
16
+ s = AiChatSession.objects.get(id=session_id, user=request.user)
17
+ title = s.title or title
18
+ except AiChatSession.DoesNotExist:
19
+ pass
20
+ return render(request, 'ai/fullpage.html', {
21
+ 'title': title,
22
+ 'session_id': session_id or '',
23
+ })
24
+
25
+
26
+ @staff_member_required
27
+ @require_POST
28
+ def ai_query(request):
29
+ """AI natural language query endpoint"""
30
+ try:
31
+ body = json.loads(request.body)
32
+ question = body.get('question', '').strip()
33
+ api_key = body.get('api_key', '') or None
34
+ model = body.get('model', '') or None
35
+ api_base = body.get('api_base', '') or None
36
+ except (json.JSONDecodeError, AttributeError):
37
+ return JsonResponse({'error': '请求格式不正确'}, status=400)
38
+
39
+ if not question:
40
+ return JsonResponse({'error': '请输入问题'}, status=400)
41
+ if len(question) > 500:
42
+ return JsonResponse({'error': '问题过长(最多500字)'}, status=400)
43
+
44
+ service = AIQueryService()
45
+ try:
46
+ result = service.query(question, api_key_override=api_key, model_override=model, api_base_override=api_base)
47
+ return JsonResponse(result)
48
+ except ValueError as e:
49
+ return JsonResponse({'error': str(e)}, status=400)
50
+ except Exception as e:
51
+ return JsonResponse({'error': f'AI 调用失败: {str(e)}'}, status=500)
52
+
53
+
54
+ @staff_member_required
55
+ def ai_history(request):
56
+ """Chat history CRUD with session support"""
57
+ from .models import AiChatMessage, AiChatSession
58
+ session_id = request.GET.get('session', 'default')
59
+
60
+ if request.method == 'GET':
61
+ msgs = AiChatMessage.objects.filter(
62
+ user=request.user, session=session_id
63
+ ).order_by('created_at')[:100]
64
+ return JsonResponse({
65
+ 'messages': [{'role': m.role, 'content': m.content} for m in msgs]
66
+ })
67
+
68
+ if request.method == 'POST':
69
+ try:
70
+ body = json.loads(request.body)
71
+ except (json.JSONDecodeError, AttributeError):
72
+ return JsonResponse({'error': '格式错误'}, status=400)
73
+
74
+ # 兼容两种格式:纯列表 或 {session, title, messages}
75
+ if isinstance(body, list):
76
+ msg_list = body
77
+ sid = session_id
78
+ title = ''
79
+ else:
80
+ msg_list = body.get('messages', [])
81
+ sid = body.get('session', session_id)
82
+ title = body.get('title', '')
83
+
84
+ # Save session
85
+ if sid and sid != 'default':
86
+ import django.utils.timezone
87
+ AiChatSession.objects.update_or_create(
88
+ id=sid, user=request.user,
89
+ defaults={'title': title or '对话 ' + sid[:8], 'updated_at': django.utils.timezone.now()}
90
+ )
91
+
92
+ # Save messages
93
+ AiChatMessage.objects.filter(user=request.user, session=sid).delete()
94
+ for m in msg_list[-50:]:
95
+ AiChatMessage.objects.create(
96
+ user=request.user, session=sid,
97
+ role=m.get('role', 'bot'), content=m.get('content', ''),
98
+ title=title or ''
99
+ )
100
+ return JsonResponse({'status': 'ok', 'session': sid})
101
+
102
+ if request.method == 'DELETE':
103
+ AiChatMessage.objects.filter(user=request.user, session=session_id).delete()
104
+ return JsonResponse({'status': 'ok'})
105
+
106
+ return JsonResponse({'error': 'Method not allowed'}, status=405)
107
+
108
+
109
+ @staff_member_required
110
+ def ai_sessions(request):
111
+ """List user's chat sessions"""
112
+ from .models import AiChatSession
113
+ sessions = AiChatSession.objects.filter(user=request.user).order_by('-updated_at')[:20]
114
+ return JsonResponse({
115
+ 'sessions': [{'id': s.id, 'title': s.title, 'updated': s.updated_at.isoformat()} for s in sessions]
116
+ })
@@ -12,6 +12,10 @@ class ThemeConfig(AppConfig):
12
12
 
13
13
  config = get_config()
14
14
 
15
+ # 登出后重定向到登录页
16
+ if not getattr(django_settings, 'LOGOUT_REDIRECT_URL', None):
17
+ django_settings.LOGOUT_REDIRECT_URL = '/admin/login/'
18
+
15
19
  # 自动注入 context processor(pip 用户不用手动配置)
16
20
  cp_path = 'apps.theme.context_processors.yanleaf_settings'
17
21
  for tpl in django_settings.TEMPLATES:
@@ -47,7 +51,20 @@ class ThemeConfig(AppConfig):
47
51
  dashboard_cfg.path = __import__('os').path.dirname(apps.dashboard_engine.__file__)
48
52
  dashboard_cfg.ready()
49
53
  except Exception:
50
- pass # 仪表盘可选
54
+ pass
55
+
56
+ # ER 图引擎(自动添加模板目录,无需注册 app)
57
+ try:
58
+ import apps.erd_engine
59
+ erd_tpl = __import__('os').path.join(
60
+ __import__('os').path.dirname(apps.erd_engine.__file__), 'templates')
61
+ for tpl in django_settings.TEMPLATES:
62
+ dirs = list(tpl.get('DIRS', []))
63
+ if erd_tpl not in dirs:
64
+ dirs.append(erd_tpl)
65
+ tpl['DIRS'] = dirs
66
+ except Exception:
67
+ pass
51
68
 
52
69
  def _patch_changelist_per_page(self):
53
70
  from django.contrib.admin.views.main import ChangeList
@@ -135,9 +135,17 @@
135
135
  background: #450a0a; border-color: #7f1d1d; color: #fca5a5;
136
136
  }
137
137
  .theme-dark #changelist-filter .clear-filters:hover { background: #7f1d1d; color: #fecaca; }
138
- #changelist-filter h3 { font-size: 0.72rem; font-weight: 600; color: var(--yla-text-primary); margin: 0.65rem 0 0.3rem; }
138
+ #changelist-filter h3 { font-size: 0.72rem; font-weight: 600; color: var(--yla-text-primary); margin: 0.65rem 0 0.3rem; cursor: pointer; user-select: none; }
139
+ #changelist-filter h3::after { content: ' ▾'; font-size: 0.6rem; }
140
+ #changelist-filter h3.collapsed::after { content: ' ▸'; }
139
141
  #changelist-filter ul { margin: 0; padding: 0; list-style: none; }
142
+ #changelist-filter ul.collapsed li:nth-child(n+6) { display: none; }
143
+ #changelist-filter ul.collapsed + .yla-filter-toggle { display: block; }
144
+ #changelist-filter .yla-filter-toggle { display: none; font-size: 0.65rem; color: var(--yla-text-muted); cursor: pointer; padding: 2px 4px; }
145
+ #changelist-filter .yla-filter-toggle:hover { color: var(--yla-text-primary); }
140
146
  #changelist-filter li { padding: 0.08rem 0; }
147
+ #changelist-filter li:nth-child(n+6) { display: none; }
148
+ #changelist-filter ul.expanded li { display: block; }
141
149
  #changelist-filter a {
142
150
  font-size: 0.75rem; color: var(--yla-text-secondary); text-decoration: none;
143
151
  display: block; padding: 0.18rem 0.4rem; border-radius: 4px;