diff --git a/backend/system/apis/report.py b/backend/system/apis/report.py new file mode 100644 index 00000000..40e8bd2c --- /dev/null +++ b/backend/system/apis/report.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +# @Time : 2024/4/17 +# @Author : 数据报表模块 +# @FileName: report.py +# @Software: PyCharm +from typing import List +from datetime import datetime, timedelta +from django.db.models import Count, Q +from django.db.models.functions import TruncDate +from ninja import Field, ModelSchema, Query, Router, Schema +from ninja.pagination import paginate +from system.models import Users, Role, Dept, OperationLog, LoginLog +from utils.fu_ninja import FuFilters, MyPagination +from utils.fu_response import FuResponse + +router = Router() + + +class ReportSchema(Schema): + total_count: int = Field(0, description="总数") + active_count: int = Field(0, description="活跃数") + inactive_count: int = Field(0, description="非活跃数") + + +class UserTrendItem(Schema): + date: str + count: int + + +class UserDistributionItem(Schema): + name: str + value: int + + +class LogTrendItem(Schema): + date: str + success_count: int + fail_count: int + + +class DeptDistributionItem(Schema): + name: str + user_count: int + role_count: int + + +class RoleDistributionItem(Schema): + name: str + user_count: int + + +@router.get("/report/user/overview", response=ReportSchema) +def user_overview(request): + total_count = Users.objects.count() + active_count = Users.objects.filter(status=True).count() + inactive_count = Users.objects.filter(status=False).count() + return { + "total_count": total_count, + "active_count": active_count, + "inactive_count": inactive_count, + } + + +@router.get("/report/user/trend") +def user_trend(request, days: int = 30): + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + users = ( + Users.objects + .filter(create_datetime__gte=start_date) + .annotate(date=TruncDate('create_datetime')) + .values('date') + .annotate(count=Count('id')) + .order_by('date') + ) + + result = [] + date_dict = {item['date']: item['count'] for item in users} + + for i in range(days): + current_date = (start_date + timedelta(days=i)).date() + result.append({ + "date": current_date.strftime("%Y-%m-%d"), + "count": date_dict.get(current_date, 0) + }) + + return result + + +@router.get("/report/user/distribution/gender") +def user_distribution_gender(request): + result = [] + for value, name in Users.GENDER_CHOICES: + count = Users.objects.filter(gender=value).count() + if count > 0: + result.append({"name": name, "value": count}) + return result + + +@router.get("/report/user/distribution/status") +def user_distribution_status(request): + active_count = Users.objects.filter(status=True).count() + inactive_count = Users.objects.filter(status=False).count() + return [ + {"name": "启用", "value": active_count}, + {"name": "禁用", "value": inactive_count}, + ] + + +@router.get("/report/user/distribution/type") +def user_distribution_type(request): + result = [] + for value, name in Users.USER_TYPE: + count = Users.objects.filter(user_type=value).count() + if count > 0: + result.append({"name": name, "value": count}) + return result + + +@router.get("/report/log/overview") +def log_overview(request, days: int = 30): + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + operation_count = OperationLog.objects.filter(create_datetime__gte=start_date).count() + login_count = LoginLog.objects.filter(create_datetime__gte=start_date).count() + success_count = OperationLog.objects.filter( + create_datetime__gte=start_date, + status=True + ).count() + fail_count = operation_count - success_count + + return { + "operation_count": operation_count, + "login_count": login_count, + "success_count": success_count, + "fail_count": fail_count, + } + + +@router.get("/report/log/operation/trend") +def log_operation_trend(request, days: int = 30): + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + logs = ( + OperationLog.objects + .filter(create_datetime__gte=start_date) + .annotate(date=TruncDate('create_datetime')) + .values('date', 'status') + .annotate(count=Count('id')) + .order_by('date') + ) + + result = [] + date_dict = {} + + for item in logs: + date_key = item['date'] + if date_key not in date_dict: + date_dict[date_key] = {"success_count": 0, "fail_count": 0} + if item['status']: + date_dict[date_key]['success_count'] = item['count'] + else: + date_dict[date_key]['fail_count'] = item['count'] + + for i in range(days): + current_date = (start_date + timedelta(days=i)).date() + data = date_dict.get(current_date, {"success_count": 0, "fail_count": 0}) + result.append({ + "date": current_date.strftime("%Y-%m-%d"), + "success_count": data.get('success_count', 0), + "fail_count": data.get('fail_count', 0) + }) + + return result + + +@router.get("/report/log/login/trend") +def log_login_trend(request, days: int = 30): + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + logs = ( + LoginLog.objects + .filter(create_datetime__gte=start_date) + .annotate(date=TruncDate('create_datetime')) + .values('date') + .annotate(count=Count('id')) + .order_by('date') + ) + + result = [] + date_dict = {item['date']: item['count'] for item in logs} + + for i in range(days): + current_date = (start_date + timedelta(days=i)).date() + result.append({ + "date": current_date.strftime("%Y-%m-%d"), + "count": date_dict.get(current_date, 0) + }) + + return result + + +@router.get("/report/log/operation/modules") +def log_operation_modules(request, days: int = 30): + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + logs = ( + OperationLog.objects + .filter(create_datetime__gte=start_date) + .values('request_modular') + .annotate(count=Count('id')) + .order_by('-count') + ) + + result = [] + for item in logs: + if item['request_modular']: + result.append({ + "name": item['request_modular'], + "value": item['count'] + }) + + return result[:10] + + +@router.get("/report/dept/overview") +def dept_overview(request): + total_dept = Dept.objects.count() + active_dept = Dept.objects.filter(status=True).count() + total_user = Users.objects.count() + + return { + "total_dept": total_dept, + "active_dept": active_dept, + "total_user": total_user, + } + + +@router.get("/report/dept/distribution") +def dept_distribution(request): + depts = Dept.objects.all() + result = [] + + for dept in depts: + user_count = Users.objects.filter(dept=dept).count() + result.append({ + "name": dept.name, + "user_count": user_count, + }) + + return sorted(result, key=lambda x: x['user_count'], reverse=True) + + +@router.get("/report/role/overview") +def role_overview(request): + total_role = Role.objects.count() + active_role = Role.objects.filter(status=True).count() + + return { + "total_role": total_role, + "active_role": active_role, + } + + +@router.get("/report/role/distribution") +def role_distribution(request): + roles = Role.objects.all() + result = [] + + for role in roles: + user_count = Users.objects.filter(role=role).count() + result.append({ + "name": role.name, + "user_count": user_count, + }) + + return sorted(result, key=lambda x: x['user_count'], reverse=True) diff --git a/backend/system/apis/report_export.py b/backend/system/apis/report_export.py new file mode 100644 index 00000000..a36f935b --- /dev/null +++ b/backend/system/apis/report_export.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- +# @Time : 2024/4/17 +# @Author : 数据报表导出模块 +# @FileName: report_export.py +# @Software: PyCharm +import io +import json +from datetime import datetime, timedelta +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, Border, Side, PatternFill +from django.http import HttpResponse +from ninja import Router, Query +from django.db.models import Count +from django.db.models.functions import TruncDate +from system.models import Users, Role, Dept, OperationLog, LoginLog +from utils.fu_response import FuResponse + +router = Router() + +EXCEL_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + + +def create_styles(workbook): + styles = { + 'header': Font(name='微软雅黑', size=12, bold=True, color='FFFFFF'), + 'header_fill': PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid'), + 'header_alignment': Alignment(horizontal='center', vertical='center', wrap_text=True), + 'header_border': Border( + left=Side(style='thin', color='000000'), + right=Side(style='thin', color='000000'), + top=Side(style='thin', color='000000'), + bottom=Side(style='thin', color='000000') + ), + 'cell': Font(name='微软雅黑', size=10), + 'cell_alignment': Alignment(horizontal='center', vertical='center', wrap_text=True), + 'cell_border': Border( + left=Side(style='thin', color='D3D3D3'), + right=Side(style='thin', color='D3D3D3'), + top=Side(style='thin', color='D3D3D3'), + bottom=Side(style='thin', color='D3D3D3') + ), + } + return styles + + +def apply_header_style(cell, styles): + cell.font = styles['header'] + cell.fill = styles['header_fill'] + cell.alignment = styles['header_alignment'] + cell.border = styles['header_border'] + + +def apply_cell_style(cell, styles): + cell.font = styles['cell'] + cell.alignment = styles['cell_alignment'] + cell.border = styles['cell_border'] + + +@router.get("/export/user/trend/excel") +def export_user_trend_excel(request, days: int = 30): + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + users = ( + Users.objects + .filter(create_datetime__gte=start_date) + .annotate(date=TruncDate('create_datetime')) + .values('date') + .annotate(count=Count('id')) + .order_by('date') + ) + + date_dict = {item['date']: item['count'] for item in users} + data = [] + for i in range(days): + current_date = (start_date + timedelta(days=i)).date() + data.append({ + "日期": current_date.strftime("%Y-%m-%d"), + "新增用户数": date_dict.get(current_date, 0) + }) + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "用户增长趋势" + + styles = create_styles(workbook) + + headers = ["日期", "新增用户数"] + for col, header in enumerate(headers, 1): + cell = worksheet.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + worksheet.column_dimensions[chr(64 + col)].width = 20 + + for row_idx, item in enumerate(data, 2): + for col_idx, header in enumerate(headers, 1): + cell = worksheet.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + output = io.BytesIO() + workbook.save(output) + output.seek(0) + + response = HttpResponse( + output, + content_type=EXCEL_CONTENT_TYPE + ) + filename = f"用户增长趋势_{datetime.now().strftime('%Y%m%d')}.xlsx" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + +@router.get("/export/user/distribution/excel") +def export_user_distribution_excel(request): + workbook = Workbook() + + styles = create_styles(workbook) + + ws_gender = workbook.active + ws_gender.title = "性别分布" + + gender_data = [] + for value, name in Users.GENDER_CHOICES: + count = Users.objects.filter(gender=value).count() + gender_data.append({"性别": name, "人数": count}) + + headers = ["性别", "人数"] + for col, header in enumerate(headers, 1): + cell = ws_gender.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + ws_gender.column_dimensions[chr(64 + col)].width = 15 + + for row_idx, item in enumerate(gender_data, 2): + for col_idx, header in enumerate(headers, 1): + cell = ws_gender.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + ws_status = workbook.create_sheet("状态分布") + status_data = [ + {"状态": "启用", "人数": Users.objects.filter(status=True).count()}, + {"状态": "禁用", "人数": Users.objects.filter(status=False).count()} + ] + + headers = ["状态", "人数"] + for col, header in enumerate(headers, 1): + cell = ws_status.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + ws_status.column_dimensions[chr(64 + col)].width = 15 + + for row_idx, item in enumerate(status_data, 2): + for col_idx, header in enumerate(headers, 1): + cell = ws_status.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + ws_dept = workbook.create_sheet("部门分布") + dept_data = [] + for dept in Dept.objects.all(): + user_count = Users.objects.filter(dept=dept).count() + dept_data.append({"部门": dept.name, "人数": user_count}) + + headers = ["部门", "人数"] + for col, header in enumerate(headers, 1): + cell = ws_dept.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + ws_dept.column_dimensions[chr(64 + col)].width = 20 + + for row_idx, item in enumerate(dept_data, 2): + for col_idx, header in enumerate(headers, 1): + cell = ws_dept.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + output = io.BytesIO() + workbook.save(output) + output.seek(0) + + response = HttpResponse( + output, + content_type=EXCEL_CONTENT_TYPE + ) + filename = f"用户分布统计_{datetime.now().strftime('%Y%m%d')}.xlsx" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + +@router.get("/export/log/operation/excel") +def export_log_operation_excel(request, days: int = 30): + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + logs = ( + OperationLog.objects + .filter(create_datetime__gte=start_date) + .annotate(date=TruncDate('create_datetime')) + .values('date', 'status') + .annotate(count=Count('id')) + .order_by('date') + ) + + date_dict = {} + for item in logs: + date_key = item['date'] + if date_key not in date_dict: + date_dict[date_key] = {"success_count": 0, "fail_count": 0} + if item['status']: + date_dict[date_key]['success_count'] = item['count'] + else: + date_dict[date_key]['fail_count'] = item['count'] + + data = [] + for i in range(days): + current_date = (start_date + timedelta(days=i)).date() + item_data = date_dict.get(current_date, {"success_count": 0, "fail_count": 0}) + data.append({ + "日期": current_date.strftime("%Y-%m-%d"), + "成功次数": item_data.get('success_count', 0), + "失败次数": item_data.get('fail_count', 0), + "总计": item_data.get('success_count', 0) + item_data.get('fail_count', 0) + }) + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "操作日志趋势" + + styles = create_styles(workbook) + + headers = ["日期", "成功次数", "失败次数", "总计"] + for col, header in enumerate(headers, 1): + cell = worksheet.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + worksheet.column_dimensions[chr(64 + col)].width = 15 + + for row_idx, item in enumerate(data, 2): + for col_idx, header in enumerate(headers, 1): + cell = worksheet.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + output = io.BytesIO() + workbook.save(output) + output.seek(0) + + response = HttpResponse( + output, + content_type=EXCEL_CONTENT_TYPE + ) + filename = f"操作日志趋势_{datetime.now().strftime('%Y%m%d')}.xlsx" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + +@router.get("/export/log/login/excel") +def export_log_login_excel(request, days: int = 30): + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + logs = ( + LoginLog.objects + .filter(create_datetime__gte=start_date) + .annotate(date=TruncDate('create_datetime')) + .values('date') + .annotate(count=Count('id')) + .order_by('date') + ) + + date_dict = {item['date']: item['count'] for item in logs} + data = [] + for i in range(days): + current_date = (start_date + timedelta(days=i)).date() + data.append({ + "日期": current_date.strftime("%Y-%m-%d"), + "登录次数": date_dict.get(current_date, 0) + }) + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "登录日志趋势" + + styles = create_styles(workbook) + + headers = ["日期", "登录次数"] + for col, header in enumerate(headers, 1): + cell = worksheet.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + worksheet.column_dimensions[chr(64 + col)].width = 20 + + for row_idx, item in enumerate(data, 2): + for col_idx, header in enumerate(headers, 1): + cell = worksheet.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + output = io.BytesIO() + workbook.save(output) + output.seek(0) + + response = HttpResponse( + output, + content_type=EXCEL_CONTENT_TYPE + ) + filename = f"登录日志趋势_{datetime.now().strftime('%Y%m%d')}.xlsx" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + +@router.get("/export/dept/excel") +def export_dept_excel(request): + data = [] + for dept in Dept.objects.all(): + user_count = Users.objects.filter(dept=dept).count() + data.append({ + "部门名称": dept.name, + "负责人": dept.owner or "无", + "联系电话": dept.phone or "无", + "邮箱": dept.email or "无", + "用户数量": user_count, + "状态": "启用" if dept.status else "禁用" + }) + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "部门统计" + + styles = create_styles(workbook) + + headers = ["部门名称", "负责人", "联系电话", "邮箱", "用户数量", "状态"] + for col, header in enumerate(headers, 1): + cell = worksheet.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + worksheet.column_dimensions[chr(64 + col)].width = 18 + + for row_idx, item in enumerate(data, 2): + for col_idx, header in enumerate(headers, 1): + cell = worksheet.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + output = io.BytesIO() + workbook.save(output) + output.seek(0) + + response = HttpResponse( + output, + content_type=EXCEL_CONTENT_TYPE + ) + filename = f"部门统计_{datetime.now().strftime('%Y%m%d')}.xlsx" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + +@router.get("/export/role/excel") +def export_role_excel(request): + data = [] + for role in Role.objects.all(): + user_count = Users.objects.filter(role=role).count() + data.append({ + "角色名称": role.name, + "角色编码": role.code, + "用户数量": user_count, + "状态": "启用" if role.status else "禁用", + "是否管理员": "是" if role.admin else "否" + }) + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "角色统计" + + styles = create_styles(workbook) + + headers = ["角色名称", "角色编码", "用户数量", "状态", "是否管理员"] + for col, header in enumerate(headers, 1): + cell = worksheet.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + worksheet.column_dimensions[chr(64 + col)].width = 18 + + for row_idx, item in enumerate(data, 2): + for col_idx, header in enumerate(headers, 1): + cell = worksheet.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + output = io.BytesIO() + workbook.save(output) + output.seek(0) + + response = HttpResponse( + output, + content_type=EXCEL_CONTENT_TYPE + ) + filename = f"角色统计_{datetime.now().strftime('%Y%m%d')}.xlsx" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response diff --git a/backend/system/router.py b/backend/system/router.py index 89df0841..1d4a3cac 100644 --- a/backend/system/router.py +++ b/backend/system/router.py @@ -26,6 +26,8 @@ from system.apis.monitor import router as monitor_router from system.apis.menu_column import router as menu_column_field_router from system.apis.code_generator import router as generator_template_router +from system.apis.report import router as report_router +from system.apis.report_export import router as report_export_router system_router = Router() system_router.add_router('/', dept_router, tags=["Dept"]) @@ -49,3 +51,5 @@ system_router.add_router('/', monitor_router, tags=["Monitor"]) system_router.add_router('/', menu_column_field_router, tags=["MenuColumnField"]) system_router.add_router('/', generator_template_router, tags=["GeneratorTemplate"]) +system_router.add_router('/', report_router, tags=["Report"]) +system_router.add_router('/', report_export_router, tags=["ReportExport"]) diff --git a/backend/system/tasks.py b/backend/system/tasks.py index cc24713b..ccaf8c68 100644 --- a/backend/system/tasks.py +++ b/backend/system/tasks.py @@ -1,14 +1,302 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# time: 2022/6/14 17:53 +# time: 2024/4/17 # file: tasks.py -# author: 臧成龙 +# author: 报表定时生成任务 # QQ: 939589097 -from celery.app import task - +import os +import io +from datetime import datetime, timedelta +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, Border, Side, PatternFill +from django.db.models import Count +from django.db.models.functions import TruncDate +from system.models import Users, Role, Dept, OperationLog, LoginLog from fuadmin.celery import app +from django.conf import settings + + +def create_styles(workbook): + styles = { + 'header': Font(name='微软雅黑', size=12, bold=True, color='FFFFFF'), + 'header_fill': PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid'), + 'header_alignment': Alignment(horizontal='center', vertical='center', wrap_text=True), + 'header_border': Border( + left=Side(style='thin', color='000000'), + right=Side(style='thin', color='000000'), + top=Side(style='thin', color='000000'), + bottom=Side(style='thin', color='000000') + ), + 'cell': Font(name='微软雅黑', size=10), + 'cell_alignment': Alignment(horizontal='center', vertical='center', wrap_text=True), + 'cell_border': Border( + left=Side(style='thin', color='D3D3D3'), + right=Side(style='thin', color='D3D3D3'), + top=Side(style='thin', color='D3D3D3'), + bottom=Side(style='thin', color='D3D3D3') + ), + } + return styles + + +def apply_header_style(cell, styles): + cell.font = styles['header'] + cell.fill = styles['header_fill'] + cell.alignment = styles['header_alignment'] + cell.border = styles['header_border'] + + +def apply_cell_style(cell, styles): + cell.font = styles['cell'] + cell.alignment = styles['cell_alignment'] + cell.border = styles['cell_border'] + + +@app.task(name="system.tasks.generate_user_report") +def generate_user_report(days: int = 30): + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + workbook = Workbook() + styles = create_styles(workbook) + + ws_trend = workbook.active + ws_trend.title = "用户增长趋势" + + users = ( + Users.objects + .filter(create_datetime__gte=start_date) + .annotate(date=TruncDate('create_datetime')) + .values('date') + .annotate(count=Count('id')) + .order_by('date') + ) + + date_dict = {item['date']: item['count'] for item in users} + data = [] + for i in range(days): + current_date = (start_date + timedelta(days=i)).date() + data.append({ + "日期": current_date.strftime("%Y-%m-%d"), + "新增用户数": date_dict.get(current_date, 0) + }) + + headers = ["日期", "新增用户数"] + for col, header in enumerate(headers, 1): + cell = ws_trend.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + ws_trend.column_dimensions[chr(64 + col)].width = 20 + + for row_idx, item in enumerate(data, 2): + for col_idx, header in enumerate(headers, 1): + cell = ws_trend.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + ws_distribution = workbook.create_sheet("用户分布") + + distribution_data = [] + total_users = Users.objects.count() + active_users = Users.objects.filter(status=True).count() + inactive_users = Users.objects.filter(status=False).count() + + distribution_data.append({"指标": "总用户数", "数值": total_users}) + distribution_data.append({"指标": "活跃用户数", "数值": active_users}) + distribution_data.append({"指标": "非活跃用户数", "数值": inactive_users}) + + for value, name in Users.GENDER_CHOICES: + count = Users.objects.filter(gender=value).count() + distribution_data.append({"指标": f"{name}用户数", "数值": count}) + + headers = ["指标", "数值"] + for col, header in enumerate(headers, 1): + cell = ws_distribution.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + ws_distribution.column_dimensions[chr(64 + col)].width = 20 + + for row_idx, item in enumerate(distribution_data, 2): + for col_idx, header in enumerate(headers, 1): + cell = ws_distribution.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + report_dir = os.path.join(settings.BASE_DIR, 'reports') + if not os.path.exists(report_dir): + os.makedirs(report_dir) + + filename = f"用户报表_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + filepath = os.path.join(report_dir, filename) + workbook.save(filepath) + + return f"用户报表已生成: {filepath}" + + +@app.task(name="system.tasks.generate_log_report") +def generate_log_report(days: int = 30): + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + workbook = Workbook() + styles = create_styles(workbook) + + ws_operation = workbook.active + ws_operation.title = "操作日志趋势" + + logs = ( + OperationLog.objects + .filter(create_datetime__gte=start_date) + .annotate(date=TruncDate('create_datetime')) + .values('date', 'status') + .annotate(count=Count('id')) + .order_by('date') + ) + + date_dict = {} + for item in logs: + date_key = item['date'] + if date_key not in date_dict: + date_dict[date_key] = {"success_count": 0, "fail_count": 0} + if item['status']: + date_dict[date_key]['success_count'] = item['count'] + else: + date_dict[date_key]['fail_count'] = item['count'] + + data = [] + for i in range(days): + current_date = (start_date + timedelta(days=i)).date() + item_data = date_dict.get(current_date, {"success_count": 0, "fail_count": 0}) + data.append({ + "日期": current_date.strftime("%Y-%m-%d"), + "成功次数": item_data.get('success_count', 0), + "失败次数": item_data.get('fail_count', 0), + "总计": item_data.get('success_count', 0) + item_data.get('fail_count', 0) + }) + + headers = ["日期", "成功次数", "失败次数", "总计"] + for col, header in enumerate(headers, 1): + cell = ws_operation.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + ws_operation.column_dimensions[chr(64 + col)].width = 18 + + for row_idx, item in enumerate(data, 2): + for col_idx, header in enumerate(headers, 1): + cell = ws_operation.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + ws_login = workbook.create_sheet("登录日志趋势") + + login_logs = ( + LoginLog.objects + .filter(create_datetime__gte=start_date) + .annotate(date=TruncDate('create_datetime')) + .values('date') + .annotate(count=Count('id')) + .order_by('date') + ) + + date_dict = {item['date']: item['count'] for item in login_logs} + login_data = [] + for i in range(days): + current_date = (start_date + timedelta(days=i)).date() + login_data.append({ + "日期": current_date.strftime("%Y-%m-%d"), + "登录次数": date_dict.get(current_date, 0) + }) + + headers = ["日期", "登录次数"] + for col, header in enumerate(headers, 1): + cell = ws_login.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + ws_login.column_dimensions[chr(64 + col)].width = 20 + + for row_idx, item in enumerate(login_data, 2): + for col_idx, header in enumerate(headers, 1): + cell = ws_login.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + report_dir = os.path.join(settings.BASE_DIR, 'reports') + if not os.path.exists(report_dir): + os.makedirs(report_dir) + + filename = f"日志报表_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + filepath = os.path.join(report_dir, filename) + workbook.save(filepath) + + return f"日志报表已生成: {filepath}" + + +@app.task(name="system.tasks.generate_org_report") +def generate_org_report(): + workbook = Workbook() + styles = create_styles(workbook) + + ws_dept = workbook.active + ws_dept.title = "部门统计" + + dept_data = [] + for dept in Dept.objects.all(): + user_count = Users.objects.filter(dept=dept).count() + dept_data.append({ + "部门名称": dept.name, + "负责人": dept.owner or "无", + "联系电话": dept.phone or "无", + "邮箱": dept.email or "无", + "用户数量": user_count, + "状态": "启用" if dept.status else "禁用" + }) + + headers = ["部门名称", "负责人", "联系电话", "邮箱", "用户数量", "状态"] + for col, header in enumerate(headers, 1): + cell = ws_dept.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + ws_dept.column_dimensions[chr(64 + col)].width = 18 + + for row_idx, item in enumerate(dept_data, 2): + for col_idx, header in enumerate(headers, 1): + cell = ws_dept.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + ws_role = workbook.create_sheet("角色统计") + + role_data = [] + for role in Role.objects.all(): + user_count = Users.objects.filter(role=role).count() + role_data.append({ + "角色名称": role.name, + "角色编码": role.code, + "用户数量": user_count, + "状态": "启用" if role.status else "禁用", + "是否管理员": "是" if role.admin else "否" + }) + + headers = ["角色名称", "角色编码", "用户数量", "状态", "是否管理员"] + for col, header in enumerate(headers, 1): + cell = ws_role.cell(row=1, column=col, value=header) + apply_header_style(cell, styles) + ws_role.column_dimensions[chr(64 + col)].width = 18 + + for row_idx, item in enumerate(role_data, 2): + for col_idx, header in enumerate(headers, 1): + cell = ws_role.cell(row=row_idx, column=col_idx, value=item[header]) + apply_cell_style(cell, styles) + + report_dir = os.path.join(settings.BASE_DIR, 'reports') + if not os.path.exists(report_dir): + os.makedirs(report_dir) + + filename = f"组织报表_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + filepath = os.path.join(report_dir, filename) + workbook.save(filepath) + + return f"组织报表已生成: {filepath}" + + +@app.task(name="system.tasks.generate_all_reports") +def generate_all_reports(days: int = 30): + generate_user_report.delay(days) + generate_log_report.delay(days) + generate_org_report.delay() + return "所有报表生成任务已提交" @app.task(name="system.tasks.test_task") def test_task(): - print('test') \ No newline at end of file + print('test') diff --git a/web/src/router/routes/modules/report.ts b/web/src/router/routes/modules/report.ts new file mode 100644 index 00000000..f5ef4b79 --- /dev/null +++ b/web/src/router/routes/modules/report.ts @@ -0,0 +1,54 @@ +import type { AppRouteModule } from '/@/router/types'; +import { LAYOUT } from '/@/router/constant'; + +const report: AppRouteModule = { + path: '/report', + name: 'Report', + component: LAYOUT, + redirect: '/report/user', + meta: { + orderNo: 50, + icon: 'ant-design:bar-chart-outlined', + title: '数据报表', + }, + children: [ + { + path: 'user', + name: 'ReportUser', + component: () => import('/@/views/fuadmin/report/user/index.vue'), + meta: { + title: '用户统计', + icon: 'ant-design:user-outlined', + }, + }, + { + path: 'log', + name: 'ReportLog', + component: () => import('/@/views/fuadmin/report/log/index.vue'), + meta: { + title: '操作日志统计', + icon: 'ant-design:file-text-outlined', + }, + }, + { + path: 'org', + name: 'ReportOrg', + component: () => import('/@/views/fuadmin/report/org/index.vue'), + meta: { + title: '部门/角色统计', + icon: 'ant-design:apartment-outlined', + }, + }, + { + path: 'manage', + name: 'ReportManage', + component: () => import('/@/views/fuadmin/report/manage/index.vue'), + meta: { + title: '报表管理', + icon: 'ant-design:setting-outlined', + }, + }, + ], +}; + +export default report; diff --git a/web/src/views/fuadmin/report/components/DeptChart.vue b/web/src/views/fuadmin/report/components/DeptChart.vue new file mode 100644 index 00000000..c1ea5f32 --- /dev/null +++ b/web/src/views/fuadmin/report/components/DeptChart.vue @@ -0,0 +1,98 @@ + + diff --git a/web/src/views/fuadmin/report/components/LogTrendChart.vue b/web/src/views/fuadmin/report/components/LogTrendChart.vue new file mode 100644 index 00000000..3c003745 --- /dev/null +++ b/web/src/views/fuadmin/report/components/LogTrendChart.vue @@ -0,0 +1,112 @@ + + diff --git a/web/src/views/fuadmin/report/components/ModuleChart.vue b/web/src/views/fuadmin/report/components/ModuleChart.vue new file mode 100644 index 00000000..b857b7d5 --- /dev/null +++ b/web/src/views/fuadmin/report/components/ModuleChart.vue @@ -0,0 +1,98 @@ + + diff --git a/web/src/views/fuadmin/report/components/RoleChart.vue b/web/src/views/fuadmin/report/components/RoleChart.vue new file mode 100644 index 00000000..4a9bb32a --- /dev/null +++ b/web/src/views/fuadmin/report/components/RoleChart.vue @@ -0,0 +1,99 @@ + + diff --git a/web/src/views/fuadmin/report/components/UserDistributionChart.vue b/web/src/views/fuadmin/report/components/UserDistributionChart.vue new file mode 100644 index 00000000..164c3114 --- /dev/null +++ b/web/src/views/fuadmin/report/components/UserDistributionChart.vue @@ -0,0 +1,94 @@ + + diff --git a/web/src/views/fuadmin/report/components/UserTrendChart.vue b/web/src/views/fuadmin/report/components/UserTrendChart.vue new file mode 100644 index 00000000..e214d3dd --- /dev/null +++ b/web/src/views/fuadmin/report/components/UserTrendChart.vue @@ -0,0 +1,124 @@ + + diff --git a/web/src/views/fuadmin/report/components/props.ts b/web/src/views/fuadmin/report/components/props.ts new file mode 100644 index 00000000..a59edeff --- /dev/null +++ b/web/src/views/fuadmin/report/components/props.ts @@ -0,0 +1,43 @@ +import { PropType } from 'vue'; + +export interface BasicProps { + width: string; + height: string; +} + +export const basicProps = { + width: { + type: String as PropType, + default: '100%', + }, + height: { + type: String as PropType, + default: '280px', + }, +}; + +export interface TrendDataItem { + date: string; + count: number; +} + +export interface DistributionItem { + name: string; + value: number; +} + +export interface LogTrendDataItem { + date: string; + success_count: number; + fail_count: number; +} + +export interface DeptDistributionItem { + name: string; + user_count: number; +} + +export interface RoleDistributionItem { + name: string; + user_count: number; +} diff --git a/web/src/views/fuadmin/report/log/index.vue b/web/src/views/fuadmin/report/log/index.vue new file mode 100644 index 00000000..ab497695 --- /dev/null +++ b/web/src/views/fuadmin/report/log/index.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/web/src/views/fuadmin/report/manage/index.vue b/web/src/views/fuadmin/report/manage/index.vue new file mode 100644 index 00000000..927fd651 --- /dev/null +++ b/web/src/views/fuadmin/report/manage/index.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/web/src/views/fuadmin/report/org/index.vue b/web/src/views/fuadmin/report/org/index.vue new file mode 100644 index 00000000..56962c97 --- /dev/null +++ b/web/src/views/fuadmin/report/org/index.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/web/src/views/fuadmin/report/report.api.ts b/web/src/views/fuadmin/report/report.api.ts new file mode 100644 index 00000000..1da8996a --- /dev/null +++ b/web/src/views/fuadmin/report/report.api.ts @@ -0,0 +1,99 @@ +import { defHttp } from '/@/utils/http/axios'; + +enum Api { + UserOverview = '/api/system/report/user/overview', + UserTrend = '/api/system/report/user/trend', + UserDistributionGender = '/api/system/report/user/distribution/gender', + UserDistributionStatus = '/api/system/report/user/distribution/status', + UserDistributionType = '/api/system/report/user/distribution/type', + LogOverview = '/api/system/report/log/overview', + LogOperationTrend = '/api/system/report/log/operation/trend', + LogLoginTrend = '/api/system/report/log/login/trend', + LogOperationModules = '/api/system/report/log/operation/modules', + DeptOverview = '/api/system/report/dept/overview', + DeptDistribution = '/api/system/report/dept/distribution', + RoleOverview = '/api/system/report/role/overview', + RoleDistribution = '/api/system/report/role/distribution', + ExportUserTrend = '/api/system/export/user/trend/excel', + ExportUserDistribution = '/api/system/export/user/distribution/excel', + ExportLogOperation = '/api/system/export/log/operation/excel', + ExportLogLogin = '/api/system/export/log/login/excel', + ExportDept = '/api/system/export/dept/excel', + ExportRole = '/api/system/export/role/excel', +} + +export function getUserOverview() { + return defHttp.get({ url: Api.UserOverview }); +} + +export function getUserTrend(days: number = 30) { + return defHttp.get({ url: Api.UserTrend, params: { days } }); +} + +export function getUserDistributionGender() { + return defHttp.get({ url: Api.UserDistributionGender }); +} + +export function getUserDistributionStatus() { + return defHttp.get({ url: Api.UserDistributionStatus }); +} + +export function getUserDistributionType() { + return defHttp.get({ url: Api.UserDistributionType }); +} + +export function getLogOverview(days: number = 30) { + return defHttp.get({ url: Api.LogOverview, params: { days } }); +} + +export function getLogOperationTrend(days: number = 30) { + return defHttp.get({ url: Api.LogOperationTrend, params: { days } }); +} + +export function getLogLoginTrend(days: number = 30) { + return defHttp.get({ url: Api.LogLoginTrend, params: { days } }); +} + +export function getLogOperationModules(days: number = 30) { + return defHttp.get({ url: Api.LogOperationModules, params: { days } }); +} + +export function getDeptOverview() { + return defHttp.get({ url: Api.DeptOverview }); +} + +export function getDeptDistribution() { + return defHttp.get({ url: Api.DeptDistribution }); +} + +export function getRoleOverview() { + return defHttp.get({ url: Api.RoleOverview }); +} + +export function getRoleDistribution() { + return defHttp.get({ url: Api.RoleDistribution }); +} + +export function exportUserTrend(days: number = 30) { + return defHttp.get({ url: Api.ExportUserTrend, params: { days }, { isReturnNativeResponse: true }); +} + +export function exportUserDistribution() { + return defHttp.get({ url: Api.ExportUserDistribution, {}, { isReturnNativeResponse: true }); +} + +export function exportLogOperation(days: number = 30) { + return defHttp.get({ url: Api.ExportLogOperation, params: { days }, { isReturnNativeResponse: true }); +} + +export function exportLogLogin(days: number = 30) { + return defHttp.get({ url: Api.ExportLogLogin, params: { days }, { isReturnNativeResponse: true }); +} + +export function exportDept() { + return defHttp.get({ url: Api.ExportDept, {}, { isReturnNativeResponse: true }); +} + +export function exportRole() { + return defHttp.get({ url: Api.ExportRole, {}, { isReturnNativeResponse: true }); +} diff --git a/web/src/views/fuadmin/report/user/index.vue b/web/src/views/fuadmin/report/user/index.vue new file mode 100644 index 00000000..eba4b467 --- /dev/null +++ b/web/src/views/fuadmin/report/user/index.vue @@ -0,0 +1,145 @@ + + + + +