diff --git a/backend/system/management/commands/gen_menu_json.py b/backend/system/management/commands/gen_menu_json.py index fbbb4aa..42e14f8 100644 --- a/backend/system/management/commands/gen_menu_json.py +++ b/backend/system/management/commands/gen_menu_json.py @@ -23,7 +23,7 @@ def gen_menu(app_name, model_name, parent_menu_name, creator='admin'): meta = MenuMeta.objects.create( title=f"{app_name}.{model_lower}.title", icon="", - order=0, + sort=0, affix_tab=False, badge="", badge_type="", @@ -38,7 +38,7 @@ def gen_menu(app_name, model_name, parent_menu_name, creator='admin'): name=model_title, status=1, type="menu", - sort=50, + sort=100, path=f"/{app_name}/{model_lower}", component=f"/{app_name}/{model_lower}/list", auth_code="", @@ -56,7 +56,7 @@ def gen_menu(app_name, model_name, parent_menu_name, creator='admin'): btn_meta = MenuMeta.objects.create( title=btn["title"], icon="", - order=0, + sort=0, affix_tab=False, badge="", badge_type="", diff --git a/backend/system/management/commands/generate_crud.py b/backend/system/management/commands/generate_crud.py index a1e0a89..85bb460 100644 --- a/backend/system/management/commands/generate_crud.py +++ b/backend/system/management/commands/generate_crud.py @@ -77,7 +77,7 @@ class Command(BaseCommand): raise CommandError(f'模型 {app_name}.{model_name} 不存在') # 生成后端代码 - # self.generate_backend_code(app_name, model_name, model, model_name_snake) + self.generate_backend_code(app_name, model_name, model, model_name_snake) if generate_frontend: # 生成前端代码 @@ -210,14 +210,13 @@ class Command(BaseCommand): """生成表单字段配置""" field_name = field.name field_label = getattr(field, 'verbose_name', field_name) - col_props = ",\n colProps: { span: 12 }" if isinstance(field, models.CharField): - return f''' {{\n component: 'Input',\n fieldName: '{field_name}',\n label: '{field_label}',{col_props}\n rules: z\n .string()\n .min(1, $t('ui.formRules.required', ['{field_label}']))\n .max(100, $t('ui.formRules.maxLength', ['{field_label}', 100])),\n }},''' + return f''' {{\n component: 'Input',\n fieldName: '{field_name}',\n label: '{field_label}',\n rules: z\n .string()\n .min(1, $t('ui.formRules.required', ['{field_label}']))\n .max(100, $t('ui.formRules.maxLength', ['{field_label}', 100])),\n }},''' elif isinstance(field, models.TextField): - return f''' {{\n component: 'Input',\n componentProps: {{\n rows: 3,\n showCount: true,\n }},\n fieldName: '{field_name}',\n label: '{field_label}',{col_props}\n rules: z\n .string()\n .max(500, $t('ui.formRules.maxLength', ['{field_label}', 500]))\n .optional(),\n }},''' + return f''' {{\n component: 'Input',\n componentProps: {{\n rows: 3,\n showCount: true,\n }},\n fieldName: '{field_name}',\n label: '{field_label}',\n rules: z\n .string()\n .max(500, $t('ui.formRules.maxLength', ['{field_label}', 500]))\n .optional(),\n }},''' elif isinstance(field, models.IntegerField): - return f''' {{\n component: 'InputNumber',\n fieldName: '{field_name}',\n label: '{field_label}',{col_props}\n }},''' + return f''' {{\n component: 'InputNumber',\n fieldName: '{field_name}',\n label: '{field_label}',\n }},''' elif isinstance(field, models.BooleanField): - return f''' {{\n component: 'RadioGroup',\n componentProps: {{\n buttonStyle: 'solid',\n options: [\n {{ label: '开启', value: 1 }},\n {{ label: '关闭', value: 0 }},\n ],\n optionType: 'button',\n }},{col_props}\n defaultValue: 1,\n fieldName: '{field_name}',\n label: '{field_label}',\n }},''' + return f''' {{\n component: 'RadioGroup',\n componentProps: {{\n buttonStyle: 'solid',\n options: [\n {{ label: '开启', value: 1 }},\n {{ label: '关闭', value: 0 }},\n ],\n optionType: 'button',\n }},\n defaultValue: 1,\n fieldName: '{field_name}',\n label: '{field_label}',\n }},''' else: - return f''' {{\n component: 'Input',\n fieldName: '{field_name}',\n label: '{field_label}',{col_props}\n }},''' \ No newline at end of file + return f''' {{\n component: 'Input',\n fieldName: '{field_name}',\n label: '{field_label}',\n }},''' \ No newline at end of file diff --git a/backend/system/management/commands/tpl/frontend_data.ts.tpl b/backend/system/management/commands/tpl/frontend_data.ts.tpl index c0dfe1d..9004138 100644 --- a/backend/system/management/commands/tpl/frontend_data.ts.tpl +++ b/backend/system/management/commands/tpl/frontend_data.ts.tpl @@ -32,7 +32,7 @@ ${columns} cellRender: { attrs: { nameField: 'name', - nameTitle: $t('${app_name}.${model_name_snake}.name'), + nameTitle: $$t('${app_name}.${model_name_snake}.name'), onClick: onActionClick, }, name: 'CellOperation', diff --git a/backend/system/management/commands/tpl/frontend_model.ts.tpl b/backend/system/management/commands/tpl/frontend_model.ts.tpl index d60bf28..0fb1efb 100644 --- a/backend/system/management/commands/tpl/frontend_model.ts.tpl +++ b/backend/system/management/commands/tpl/frontend_model.ts.tpl @@ -8,6 +8,6 @@ $interface_fields export class ${app_name_camel}${model_name}Model extends BaseModel<${app_name_camel}${model_name}Api.${app_name_camel}${model_name}> { constructor() { - super('/$app_name/${model_name_lower}/'); + super('/$app_name/${model_name_snake}/'); } } diff --git a/backend/system/migrations/0008_systemloginlog.py b/backend/system/migrations/0008_systemloginlog.py new file mode 100644 index 0000000..d8a0780 --- /dev/null +++ b/backend/system/migrations/0008_systemloginlog.py @@ -0,0 +1,102 @@ +# Generated by Django 5.2.1 on 2025-07-02 07:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("system", "0007_alter_menu_options_rename_order_menumeta_sort"), + ] + + operations = [ + migrations.CreateModel( + name="SystemLoginLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "remark", + models.CharField( + blank=True, + db_comment="备注", + help_text="备注", + max_length=256, + null=True, + ), + ), + ( + "creator", + models.CharField( + blank=True, + db_comment="创建人", + help_text="创建人", + max_length=64, + null=True, + ), + ), + ( + "modifier", + models.CharField( + blank=True, + db_comment="修改人", + help_text="修改人", + max_length=64, + null=True, + ), + ), + ( + "update_time", + models.DateTimeField( + auto_now=True, + db_comment="修改时间", + help_text="修改时间", + null=True, + ), + ), + ( + "create_time", + models.DateTimeField( + auto_now_add=True, + db_comment="创建时间", + help_text="创建时间", + null=True, + ), + ), + ( + "is_deleted", + models.BooleanField(db_comment="是否软删除", default=False), + ), + ( + "username", + models.CharField(db_comment="用户账号", default="", max_length=50), + ), + ( + "result", + models.IntegerField( + choices=[(0, "失败"), (1, "成功")], + db_comment="登录结果", + default=1, + ), + ), + ("user_ip", models.CharField(db_comment="用户 IP", max_length=50)), + ( + "user_agent", + models.CharField(db_comment="浏览器 UA", max_length=512), + ), + ], + options={ + "verbose_name": "系统访问记录", + "verbose_name_plural": "系统访问记录", + "db_table": "system_login_log", + "ordering": ["-id"], + }, + ), + ] diff --git a/backend/system/models.py b/backend/system/models.py index ff63ea1..aca0339 100644 --- a/backend/system/models.py +++ b/backend/system/models.py @@ -273,7 +273,7 @@ class User(AbstractUser, CoreModel): verbose_name='状态' ) login_ip = models.GenericIPAddressField(blank=True, null=True, db_comment="最后登录IP") - + class Meta: verbose_name = '用户数据' verbose_name_plural = verbose_name @@ -281,4 +281,26 @@ class User(AbstractUser, CoreModel): @property def get_role_name(self): - return [role.name for role in self.role.all()] \ No newline at end of file + return [role.name for role in self.role.all()] + + +class LoginLog(CoreModel): + """ + 系统访问记录 + """ + class LoginResult(models.IntegerChoices): + FAILED = 0, '失败' + SUCCESS = 1, '成功' + username = models.CharField(max_length=50, default='', db_comment='用户账号') + result = models.IntegerField(choices=LoginResult.choices, default=LoginResult.SUCCESS, db_comment='登录结果') + user_ip = models.CharField(max_length=50, db_comment='用户 IP') + user_agent = models.CharField(max_length=512, db_comment='浏览器 UA') + + class Meta: + db_table = 'system_login_log' + verbose_name = '系统访问记录' + verbose_name_plural = verbose_name + ordering = ['-id'] + + def __str__(self): + return f"{self.username} - {self.user_ip}" diff --git a/backend/system/urls.py b/backend/system/urls.py index a2ae9f8..e221ac0 100644 --- a/backend/system/urls.py +++ b/backend/system/urls.py @@ -12,6 +12,7 @@ router.register(r'dict_data', views.DictDataViewSet) router.register(r'dict_type', views.DictTypeViewSet) router.register(r'post', views.PostViewSet) router.register(r'user', views.UserViewSet) +router.register(r'login_log', views.LoginLogViewSet) urlpatterns = [ path('', include(router.urls)), diff --git a/backend/system/views/__init__.py b/backend/system/views/__init__.py index d4eabc7..c738cfe 100644 --- a/backend/system/views/__init__.py +++ b/backend/system/views/__init__.py @@ -7,6 +7,7 @@ __all__ = [ 'DictTypeViewSet', 'PostViewSet', 'UserViewSet', + 'LoginLogViewSet', ] from system.views.dict_data import DictDataViewSet @@ -16,4 +17,5 @@ from system.views.role import RoleViewSet from system.views.dept import DeptViewSet from system.views.post import PostViewSet +from system.views.login_log import LoginLogViewSet from system.views.user import * \ No newline at end of file diff --git a/backend/system/views/login_log.py b/backend/system/views/login_log.py new file mode 100644 index 0000000..0c72f6a --- /dev/null +++ b/backend/system/views/login_log.py @@ -0,0 +1,30 @@ +from system.models import LoginLog +from utils.serializers import CustomModelSerializer +from utils.custom_model_viewSet import CustomModelViewSet +from rest_framework import serializers + +class LoginLogSerializer(CustomModelSerializer): + """ + 系统访问记录 序列化器 + """ + result_text = serializers.SerializerMethodField() + + class Meta: + model = LoginLog + fields = '__all__' + read_only_fields = ['id', 'create_time', 'update_time'] + + def get_result_text(self, obj): + return obj.get_result_display() + + +class LoginLogViewSet(CustomModelViewSet): + """ + 系统访问记录 视图集 + """ + queryset = LoginLog.objects.filter(is_deleted=False).order_by('-id') + serializer_class = LoginLogSerializer + filterset_fields = ['id', 'remark', 'creator', 'modifier', 'is_deleted', 'username', 'result', 'user_ip', 'user_agent'] + search_fields = ['name'] # 根据实际字段调整 + ordering_fields = ['create_time', 'id'] + ordering = ['-create_time'] diff --git a/backend/system/views/menu.py b/backend/system/views/menu.py index d49b772..60e25bf 100644 --- a/backend/system/views/menu.py +++ b/backend/system/views/menu.py @@ -66,7 +66,6 @@ class MenuSerializer(CustomModelSerializer): """更新菜单及关联的元数据""" self.set_audit_user_fields(validated_data, is_create=False) meta_data = validated_data.pop('meta', {}) - print(self.fields['meta'], "self.fields['meta']") meta_serializer = self.fields['meta'] meta_serializer.update(instance.meta, meta_data) return super().update(instance, validated_data) @@ -74,9 +73,13 @@ class MenuSerializer(CustomModelSerializer): class MenuUserSerializer(MenuSerializer): def get_children(self, obj): - children = obj.children.exclude(type='button').order_by('sort') - if children: - return MenuUserSerializer(children, many=True).data + request = self.context.get('request') + children_qs = obj.children.exclude(type='button').order_by('sort') + if request and hasattr(request, 'user') and request.user.is_authenticated and not request.user.is_superuser: + role_ids = request.user.role.values_list('id', flat=True) + children_qs = children_qs.filter(role__id__in=role_ids).distinct() + if children_qs: + return MenuUserSerializer(children_qs, many=True, context=self.context).data return [] @@ -131,9 +134,11 @@ class MenuViewSet(CustomModelViewSet): if user.is_superuser: menus = Menu.objects.filter(pid__isnull=True).exclude(type='button').order_by('sort') else: + role_ids = user.role.values_list('id', flat=True) menus = Menu.objects.filter(pid__isnull=True, - role__users=user).exclude(type='button').order_by('sort').distinct() - menus_data = MenuUserSerializer(menus, many=True).data + role__id__in=role_ids + ).exclude(type='button').order_by('sort').distinct() + menus_data = MenuUserSerializer(menus, many=True, context={'request': request}).data return self._build_response(data=menus_data) def update(self, request, *args, **kwargs): diff --git a/backend/system/views/user.py b/backend/system/views/user.py index f393350..60ecfd3 100644 --- a/backend/system/views/user.py +++ b/backend/system/views/user.py @@ -7,7 +7,7 @@ from rest_framework.views import APIView from django.contrib.auth.hashers import make_password from rest_framework.permissions import IsAuthenticated -from system.models import User, Menu +from system.models import User, Menu, LoginLog from system.views.menu import MenuSerializer from utils.serializers import CustomModelSerializer @@ -54,6 +54,13 @@ class UserLogin(ObtainAuthToken): user.last_login = timezone.now() user.save(update_fields=['login_ip', 'last_login']) user_data = UserSerializer(user).data + # 记录登录日志 + LoginLog.objects.create( + username=user.username, + result=LoginLog.LoginResult.SUCCESS, + user_ip=request.META.get('REMOTE_ADDR', ''), + user_agent=request.META.get('HTTP_USER_AGENT', '') + ) # 在序列化后的数据中加入 accessToken user_data['accessToken'] = token.key return Response({ diff --git a/web/apps/web-antd/src/locales/langs/en-US/system.json b/web/apps/web-antd/src/locales/langs/en-US/system.json index 717354a..bbd9845 100644 --- a/web/apps/web-antd/src/locales/langs/en-US/system.json +++ b/web/apps/web-antd/src/locales/langs/en-US/system.json @@ -98,6 +98,10 @@ "user": { "name": "User", "title": "User Management" + }, + "login_log": { + "name": "login log", + "title": "login log" }, "status": "Status", "remark": "Remarks", diff --git a/web/apps/web-antd/src/locales/langs/zh-CN/system.json b/web/apps/web-antd/src/locales/langs/zh-CN/system.json index 139477b..2cb169f 100644 --- a/web/apps/web-antd/src/locales/langs/zh-CN/system.json +++ b/web/apps/web-antd/src/locales/langs/zh-CN/system.json @@ -100,6 +100,10 @@ "name": "用户", "title": "用户管理" }, + "login_log": { + "name": "登录日志", + "title": "登录日志" + }, "status": "状态", "remark": "备注", "creator": "创建人", diff --git a/web/apps/web-antd/src/models/system/login_log.ts b/web/apps/web-antd/src/models/system/login_log.ts new file mode 100644 index 0000000..2d5df7b --- /dev/null +++ b/web/apps/web-antd/src/models/system/login_log.ts @@ -0,0 +1,23 @@ +import { BaseModel } from '#/models/base'; + +export namespace SystemLoginLogApi { + export interface SystemLoginLog { + id: number; + remark: string; + creator: string; + modifier: string; + update_time: string; + create_time: string; + is_deleted: boolean; + username: string; + result: number; + user_ip: string; + user_agent: string; + } +} + +export class SystemLoginLogModel extends BaseModel { + constructor() { + super('/system/login_log/'); + } +} diff --git a/web/apps/web-antd/src/views/_core/authentication/login.vue b/web/apps/web-antd/src/views/_core/authentication/login.vue index c55eb64..ab66762 100644 --- a/web/apps/web-antd/src/views/_core/authentication/login.vue +++ b/web/apps/web-antd/src/views/_core/authentication/login.vue @@ -24,7 +24,7 @@ const MOCK_USER_OPTIONS: BasicOption[] = [ }, { label: 'User', - value: 'jack', + value: 'xj', }, ]; diff --git a/web/apps/web-antd/src/views/system/login_log/data.ts b/web/apps/web-antd/src/views/system/login_log/data.ts new file mode 100644 index 0000000..be38c54 --- /dev/null +++ b/web/apps/web-antd/src/views/system/login_log/data.ts @@ -0,0 +1,95 @@ +import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; + +import type { VbenFormSchema } from '#/adapter/form'; +import type { SystemLoginLogApi } from '#/models/system/login_log'; + +import { z } from '#/adapter/form'; +import { $t } from '#/locales'; +import { format_datetime } from '#/utils/date'; + +/** + * 获取编辑表单的字段配置 + */ +export function useSchema(): VbenFormSchema[] { + return [ + { + component: 'Input', + fieldName: 'username', + label: 'username', + rules: z + .string() + .min(1, $t('ui.formRules.required', ['username'])) + .max(100, $t('ui.formRules.maxLength', ['username', 100])), + }, + { + component: 'InputNumber', + fieldName: 'result', + label: 'result', + }, + { + component: 'Input', + fieldName: 'user_ip', + label: 'user ip', + rules: z + .string() + .min(1, $t('ui.formRules.required', ['user ip'])) + .max(100, $t('ui.formRules.maxLength', ['user ip', 100])), + }, + { + component: 'Input', + fieldName: 'user_agent', + label: 'user agent', + rules: z + .string() + .min(1, $t('ui.formRules.required', ['user agent'])) + .max(100, $t('ui.formRules.maxLength', ['user agent', 100])), + }, + { + component: 'Input', + fieldName: 'remark', + label: 'remark', + rules: z + .string() + .min(1, $t('ui.formRules.required', ['remark'])) + .max(100, $t('ui.formRules.maxLength', ['remark', 100])), + }, + ]; +} + +/** + * 获取表格列配置 + * @description 使用函数的形式返回列数据而不是直接export一个Array常量,是为了响应语言切换时重新翻译表头 + */ +export function useColumns(): VxeTableGridOptions['columns'] { + return [ + { + field: 'id', + title: 'ID', + }, + { + field: 'username', + title: '用户名', + }, + { + cellRender: { + name: 'CellTag', + }, + field: 'result_text', + title: '登录结果', + }, + { + field: 'user_ip', + title: '登录地址', + }, + { + field: 'user_agent', + title: '浏览器', + }, + { + field: 'create_time', + title: $t('system.createTime'), + width: 150, + formatter: ({ cellValue }) => format_datetime(cellValue), + }, + ]; +} diff --git a/web/apps/web-antd/src/views/system/login_log/list.vue b/web/apps/web-antd/src/views/system/login_log/list.vue new file mode 100644 index 0000000..22745c1 --- /dev/null +++ b/web/apps/web-antd/src/views/system/login_log/list.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/apps/web-antd/src/views/system/login_log/modules/form.vue b/web/apps/web-antd/src/views/system/login_log/modules/form.vue new file mode 100644 index 0000000..6fddbb9 --- /dev/null +++ b/web/apps/web-antd/src/views/system/login_log/modules/form.vue @@ -0,0 +1,79 @@ + + + + diff --git a/web/apps/web-antd/src/views/system/post/data.ts b/web/apps/web-antd/src/views/system/post/data.ts index c063399..c719fd7 100644 --- a/web/apps/web-antd/src/views/system/post/data.ts +++ b/web/apps/web-antd/src/views/system/post/data.ts @@ -102,7 +102,7 @@ export function useColumns( cellRender: { attrs: { nameField: 'name', - nameTitle: $t('system.{model_name_snake}.name'), + nameTitle: $t('system.post.name'), onClick: onActionClick, }, name: 'CellOperation',