From a40a0137cf68e7984cfabd70d05fdd86565cb819 Mon Sep 17 00:00:00 2001 From: cnbugs Date: Sat, 25 Apr 2026 08:04:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4=EF=BC=9A?= =?UTF-8?q?=E5=B8=8C=E5=A7=86=E8=AE=A1=E7=AE=97=E7=A1=AC=E4=BB=B6=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能: - Django + MySQL + 深色主题 - 资产增删改查(含资产编号、BMC地址、设备位置、备注) - Excel导入导出(分类自动创建) - 设备分类管理 - 资产变更记录追踪 - 质保到期提醒 - 用户认证系统 - Docker部署支持 --- .gitignore | 41 ++ Dockerfile | 28 ++ assetapp/__init__.py | 0 assetapp/admin.py | 24 + assetapp/apps.py | 6 + assetapp/excel_utils.py | 224 +++++++++ assetapp/forms.py | 34 ++ assetapp/management/__init__.py | 0 assetapp/management/commands/__init__.py | 0 assetapp/management/commands/init_assets.py | 38 ++ assetapp/migrations/0001_initial.py | 81 ++++ assetapp/migrations/0002_asset_bmc_address.py | 18 + assetapp/migrations/__init__.py | 0 assetapp/models.py | 116 +++++ assetapp/templatetags/__init__.py | 0 assetapp/templatetags/asset_tags.py | 9 + assetapp/tests.py | 3 + assetapp/urls.py | 32 ++ assetapp/views.py | 416 ++++++++++++++++ config/__init__.py | 0 config/asgi.py | 16 + config/settings.py | 95 ++++ config/urls.py | 18 + config/wsgi.py | 16 + docker-compose.yml | 45 ++ entrypoint.sh | 25 + manage.py | 22 + requirements.txt | 4 + static/css/style.css | 453 ++++++++++++++++++ templates/assetapp/asset_confirm_delete.html | 32 ++ templates/assetapp/asset_detail.html | 150 ++++++ templates/assetapp/asset_form.html | 140 ++++++ templates/assetapp/asset_import.html | 71 +++ templates/assetapp/asset_list.html | 154 ++++++ templates/assetapp/base.html | 89 ++++ .../assetapp/category_confirm_delete.html | 26 + templates/assetapp/category_form.html | 40 ++ templates/assetapp/category_list.html | 52 ++ templates/assetapp/changelog.html | 110 +++++ templates/assetapp/dashboard.html | 151 ++++++ templates/assetapp/login.html | 54 +++ 41 files changed, 2833 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 assetapp/__init__.py create mode 100644 assetapp/admin.py create mode 100644 assetapp/apps.py create mode 100644 assetapp/excel_utils.py create mode 100644 assetapp/forms.py create mode 100644 assetapp/management/__init__.py create mode 100644 assetapp/management/commands/__init__.py create mode 100644 assetapp/management/commands/init_assets.py create mode 100644 assetapp/migrations/0001_initial.py create mode 100644 assetapp/migrations/0002_asset_bmc_address.py create mode 100644 assetapp/migrations/__init__.py create mode 100644 assetapp/models.py create mode 100644 assetapp/templatetags/__init__.py create mode 100644 assetapp/templatetags/asset_tags.py create mode 100644 assetapp/tests.py create mode 100644 assetapp/urls.py create mode 100644 assetapp/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 docker-compose.yml create mode 100755 entrypoint.sh create mode 100755 manage.py create mode 100644 requirements.txt create mode 100644 static/css/style.css create mode 100644 templates/assetapp/asset_confirm_delete.html create mode 100644 templates/assetapp/asset_detail.html create mode 100644 templates/assetapp/asset_form.html create mode 100644 templates/assetapp/asset_import.html create mode 100644 templates/assetapp/asset_list.html create mode 100644 templates/assetapp/base.html create mode 100644 templates/assetapp/category_confirm_delete.html create mode 100644 templates/assetapp/category_form.html create mode 100644 templates/assetapp/category_list.html create mode 100644 templates/assetapp/changelog.html create mode 100644 templates/assetapp/dashboard.html create mode 100644 templates/assetapp/login.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8f79bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*.so +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environment +venv/ +env/ +.venv/ + +# Database +*.sqlite3 +db.sqlite3 + +# Django +*.log +local_settings.py +media/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Docker data +data/ + +# Environment +.env + +# Static files (collected) +staticfiles/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c52f2ae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + default-libmysqlclient-dev \ + build-essential \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# 安装Python依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制项目 +COPY . . + +# 收集静态文件 +RUN python manage.py collectstatic --noinput 2>/dev/null || true + +# 创建启动脚本 +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/assetapp/__init__.py b/assetapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assetapp/admin.py b/assetapp/admin.py new file mode 100644 index 0000000..534028f --- /dev/null +++ b/assetapp/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin +from .models import Asset, Category, AssetChangeLog + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ['name', 'description', 'created_at'] + search_fields = ['name'] + + +@admin.register(Asset) +class AssetAdmin(admin.ModelAdmin): + list_display = ['asset_number', 'name', 'category', 'brand', 'model', 'status', 'location', 'created_at'] + list_filter = ['status', 'category', 'brand'] + search_fields = ['asset_number', 'name', 'serial_number', 'ip_address'] + readonly_fields = ['created_at', 'updated_at'] + + +@admin.register(AssetChangeLog) +class AssetChangeLogAdmin(admin.ModelAdmin): + list_display = ['asset_number', 'action', 'field_name', 'operator', 'created_at'] + list_filter = ['action'] + search_fields = ['asset_number'] + readonly_fields = ['created_at'] diff --git a/assetapp/apps.py b/assetapp/apps.py new file mode 100644 index 0000000..a2b8f66 --- /dev/null +++ b/assetapp/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AssetappConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'assetapp' diff --git a/assetapp/excel_utils.py b/assetapp/excel_utils.py new file mode 100644 index 0000000..7bf909f --- /dev/null +++ b/assetapp/excel_utils.py @@ -0,0 +1,224 @@ +"""Excel导入导出工具""" +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter +from django.utils import timezone +from .models import Category + + +# Excel列定义 +EXPORT_COLUMNS = [ + ('asset_number', '资产编号', 18), + ('name', '设备名称', 20), + ('category', '设备分类', 12), + ('brand', '品牌', 12), + ('model', '型号', 20), + ('serial_number', '序列号', 25), + ('location', '设备位置', 20), + ('cabinet', '机柜', 10), + ('cabinet_position', '机柜位置', 10), + ('bmc_address', 'BMC地址', 16), + ('ip_address', 'IP地址', 16), + ('purchase_date', '采购日期', 12), + ('warranty_expire', '质保到期', 12), + ('supplier', '供应商', 15), + ('responsible_person', '负责人', 10), + ('status', '状态', 8), + ('remark', '备注', 30), +] + +STATUS_MAP = { + 'in_use': '在用', 'idle': '闲置', 'maintenance': '维修中', 'scrapped': '已报废', +} +STATUS_MAP_REVERSE = {v: k for k, v in STATUS_MAP.items()} + +# 深色主题样式 +HEADER_FILL = PatternFill(start_color='1B2838', end_color='1B2838', fill_type='solid') +HEADER_FONT = Font(name='微软雅黑', bold=True, color='FFFFFF', size=11) +CELL_FONT = Font(name='微软雅黑', size=10) +THIN_BORDER = Border( + left=Side(style='thin', color='3A4A5C'), + right=Side(style='thin', color='3A4A5C'), + top=Side(style='thin', color='3A4A5C'), + bottom=Side(style='thin', color='3A4A5C'), +) + + +def export_assets_to_excel(queryset): + """导出资产到Excel""" + wb = Workbook() + ws = wb.active + ws.title = '硬件资产' + + # 写表头 + for col_idx, (field, header, width) in enumerate(EXPORT_COLUMNS, 1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.border = THIN_BORDER + ws.column_dimensions[get_column_letter(col_idx)].width = width + + # 写数据 + for row_idx, asset in enumerate(queryset, 2): + for col_idx, (field, _, _) in enumerate(EXPORT_COLUMNS, 1): + if field == 'category': + value = str(asset.category) if asset.category else '' + elif field == 'status': + value = STATUS_MAP.get(asset.status, asset.status) + elif field in ('purchase_date', 'warranty_expire'): + value = str(getattr(asset, field, '')) or '' + else: + value = getattr(asset, field, '') or '' + + cell = ws.cell(row=row_idx, column=col_idx, value=value) + cell.font = CELL_FONT + cell.border = THIN_BORDER + cell.alignment = Alignment(vertical='center') + + # 冻结首行 + ws.freeze_panes = 'A2' + + return wb + + +def generate_import_template(): + """生成导入模板""" + wb = Workbook() + ws = wb.active + ws.title = '资产导入模板' + + headers = [header for _, header, width in EXPORT_COLUMNS] + for col_idx, (field, header, width) in enumerate(EXPORT_COLUMNS, 1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.border = THIN_BORDER + ws.column_dimensions[get_column_letter(col_idx)].width = width + + # 示例数据行 + example_data = [ + 'IT-2024-0001', '测试服务器', '服务器', 'Dell', 'PowerEdge R740', + 'ABC123456', '3楼机房A区', 'A01', 'U10-U15', '192.168.1.200', + '192.168.1.100', '2024-01-15', '2027-01-15', '戴尔科技', '张三', '在用', '测试备注' + ] + for col_idx, value in enumerate(example_data, 1): + cell = ws.cell(row=2, column=col_idx, value=value) + cell.font = Font(name='微软雅黑', size=10, color='666666') + cell.border = THIN_BORDER + + ws.freeze_panes = 'A2' + + return wb + + +def import_assets_from_excel(ws, category_map, operator=None): + """从Excel导入资产,返回结果统计""" + from .models import Asset, AssetChangeLog + + header_row = [cell.value for cell in ws[1]] + field_map = {header: field for field, header, _ in EXPORT_COLUMNS} + + results = { + 'success': 0, + 'skipped': 0, + 'errors': [], + 'total': 0, + } + + for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), 2): + if not row or not row[0]: + continue + + results['total'] += 1 + + try: + data = {} + for col_idx, value in enumerate(row): + if col_idx < len(header_row): + field = field_map.get(header_row[col_idx]) + if field: + data[field] = str(value).strip() if value else '' + + # 资产编号必填 + asset_number = data.get('asset_number', '').strip() + if not asset_number: + results['errors'].append(f'第{row_idx}行: 缺少资产编号') + continue + + # 检查重复 + if Asset.objects.filter(asset_number=asset_number).exists(): + results['skipped'] += 1 + results['errors'].append(f'第{row_idx}行: 资产编号 {asset_number} 已存在') + continue + + # 处理分类 - 不存在则自动创建 + category_name = data.get('category', '').strip() + category = category_map.get(category_name) + if not category and category_name: + category = Category.objects.create(name=category_name) + category_map[category_name] = category + elif not category_name: + category = category_map.get('未分类') + if not category: + category = Category.objects.create(name='未分类') + category_map['未分类'] = category + + # 处理状态 + status = STATUS_MAP_REVERSE.get(data.get('status', '在用'), 'in_use') + + # 处理日期 + from datetime import datetime as dt + purchase_date = None + warranty_expire = None + if data.get('purchase_date'): + try: + purchase_date = dt.strptime(data['purchase_date'], '%Y-%m-%d').date() + except ValueError: + pass + if data.get('warranty_expire'): + try: + warranty_expire = dt.strptime(data['warranty_expire'], '%Y-%m-%d').date() + except ValueError: + pass + + # 处理IP + bmc_address = data.get('bmc_address') or None + ip_address = data.get('ip_address') or None + + asset = Asset.objects.create( + asset_number=asset_number, + name=data.get('name', ''), + category=category, + brand=data.get('brand', ''), + model=data.get('model', ''), + serial_number=data.get('serial_number', ''), + location=data.get('location', ''), + cabinet=data.get('cabinet', ''), + cabinet_position=data.get('cabinet_position', ''), + bmc_address=bmc_address, + ip_address=ip_address, + purchase_date=purchase_date, + warranty_expire=warranty_expire, + supplier=data.get('supplier', ''), + responsible_person=data.get('responsible_person', ''), + status=status, + remark=data.get('remark', ''), + created_by=operator, + ) + + AssetChangeLog.objects.create( + asset=asset, + asset_number=asset.asset_number, + action='import', + description=f'通过Excel导入创建', + operator=operator, + ) + + results['success'] += 1 + + except Exception as e: + results['errors'].append(f'第{row_idx}行: {str(e)}') + + return results diff --git a/assetapp/forms.py b/assetapp/forms.py new file mode 100644 index 0000000..fed44af --- /dev/null +++ b/assetapp/forms.py @@ -0,0 +1,34 @@ +from django import forms +from .models import Asset, Category + + +class AssetForm(forms.ModelForm): + class Meta: + model = Asset + fields = [ + 'asset_number', 'name', 'category', 'brand', 'model', 'serial_number', + 'location', 'cabinet', 'cabinet_position', 'bmc_address', 'ip_address', + 'purchase_date', 'warranty_expire', 'supplier', + 'responsible_person', 'status', 'remark', + ] + widgets = { + 'purchase_date': forms.DateInput(attrs={'type': 'date'}), + 'warranty_expire': forms.DateInput(attrs={'type': 'date'}), + 'remark': forms.Textarea(attrs={'rows': 3}), + 'status': forms.Select(attrs={'class': 'form-select'}), + 'category': forms.Select(attrs={'class': 'form-select'}), + } + + +class AssetImportForm(forms.Form): + excel_file = forms.FileField( + label='Excel文件', + help_text='支持 .xlsx 格式', + widget=forms.FileInput(attrs={'accept': '.xlsx,.xls'}) + ) + + +class CategoryForm(forms.ModelForm): + class Meta: + model = Category + fields = ['name', 'description'] diff --git a/assetapp/management/__init__.py b/assetapp/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assetapp/management/commands/__init__.py b/assetapp/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assetapp/management/commands/init_assets.py b/assetapp/management/commands/init_assets.py new file mode 100644 index 0000000..2be7be0 --- /dev/null +++ b/assetapp/management/commands/init_assets.py @@ -0,0 +1,38 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from assetapp.models import Category + + +class Command(BaseCommand): + help = '初始化系统:创建管理员用户和默认分类' + + def handle(self, *args, **options): + # 创建管理员 + if not User.objects.filter(username='admin').exists(): + User.objects.create_superuser('admin', 'admin@example.com', 'admin123') + self.stdout.write(self.style.SUCCESS('✓ 管理员用户已创建 (admin/admin123)')) + else: + self.stdout.write(' 管理员用户已存在,跳过') + + # 创建默认分类 + default_categories = [ + ('服务器', '各类物理服务器、虚拟化主机'), + ('交换机', '网络交换设备'), + ('路由器', '网络路由设备'), + ('存储设备', 'SAN/NAS等存储设备'), + ('防火墙', '网络安全设备'), + ('UPS', '不间断电源设备'), + ('其他', '其他类型设备'), + ] + + created_count = 0 + for name, desc in default_categories: + cat, created = Category.objects.get_or_create( + name=name, + defaults={'description': desc} + ) + if created: + created_count += 1 + + self.stdout.write(self.style.SUCCESS(f'✓ 分类初始化完成 (新增 {created_count} 个)')) + self.stdout.write(self.style.SUCCESS('\n初始化完成!请使用 admin/admin123 登录')) diff --git a/assetapp/migrations/0001_initial.py b/assetapp/migrations/0001_initial.py new file mode 100644 index 0000000..ff91681 --- /dev/null +++ b/assetapp/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 5.2.13 on 2026-04-24 10:33 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True, verbose_name='分类名称')), + ('description', models.CharField(blank=True, default='', max_length=200, verbose_name='描述')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': '设备分类', + 'verbose_name_plural': '设备分类', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Asset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('asset_number', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='资产编号')), + ('name', models.CharField(max_length=100, verbose_name='设备名称')), + ('brand', models.CharField(blank=True, default='', max_length=50, verbose_name='品牌')), + ('model', models.CharField(blank=True, default='', max_length=100, verbose_name='型号')), + ('serial_number', models.CharField(blank=True, db_index=True, default='', max_length=100, verbose_name='序列号')), + ('location', models.CharField(blank=True, default='', help_text='楼层/房间/区域', max_length=200, verbose_name='设备位置')), + ('cabinet', models.CharField(blank=True, default='', max_length=50, verbose_name='机柜')), + ('cabinet_position', models.CharField(blank=True, default='', help_text='U位', max_length=50, verbose_name='机柜位置')), + ('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP地址')), + ('purchase_date', models.DateField(blank=True, null=True, verbose_name='采购日期')), + ('warranty_expire', models.DateField(blank=True, null=True, verbose_name='质保到期')), + ('supplier', models.CharField(blank=True, default='', max_length=100, verbose_name='供应商')), + ('responsible_person', models.CharField(blank=True, default='', max_length=50, verbose_name='负责人')), + ('status', models.CharField(choices=[('in_use', '在用'), ('idle', '闲置'), ('maintenance', '维修中'), ('scrapped', '已报废')], default='in_use', max_length=20, verbose_name='状态')), + ('remark', models.TextField(blank=True, default='', verbose_name='备注')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assets', to='assetapp.category', verbose_name='设备分类')), + ], + options={ + 'verbose_name': '硬件资产', + 'verbose_name_plural': '硬件资产', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='AssetChangeLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('asset_number', models.CharField(db_index=True, max_length=50, verbose_name='资产编号')), + ('action', models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除'), ('import', '导入'), ('export', '导出'), ('status_change', '状态变更')], max_length=20, verbose_name='操作类型')), + ('field_name', models.CharField(blank=True, default='', max_length=50, verbose_name='变更字段')), + ('old_value', models.TextField(blank=True, default='', verbose_name='旧值')), + ('new_value', models.TextField(blank=True, default='', verbose_name='新值')), + ('description', models.TextField(blank=True, default='', verbose_name='描述')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='操作时间')), + ('asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='change_logs', to='assetapp.asset', verbose_name='资产')), + ('operator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='操作人')), + ], + options={ + 'verbose_name': '变更记录', + 'verbose_name_plural': '变更记录', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/assetapp/migrations/0002_asset_bmc_address.py b/assetapp/migrations/0002_asset_bmc_address.py new file mode 100644 index 0000000..bb0198f --- /dev/null +++ b/assetapp/migrations/0002_asset_bmc_address.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-04-24 23:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assetapp', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='asset', + name='bmc_address', + field=models.GenericIPAddressField(blank=True, null=True, verbose_name='BMC地址'), + ), + ] diff --git a/assetapp/migrations/__init__.py b/assetapp/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assetapp/models.py b/assetapp/models.py new file mode 100644 index 0000000..be16ff1 --- /dev/null +++ b/assetapp/models.py @@ -0,0 +1,116 @@ +from django.db import models +from django.contrib.auth.models import User +from datetime import date + + +class Category(models.Model): + """设备分类""" + name = models.CharField('分类名称', max_length=50, unique=True) + description = models.CharField('描述', max_length=200, blank=True, default='') + created_at = models.DateTimeField('创建时间', auto_now_add=True) + + class Meta: + verbose_name = '设备分类' + verbose_name_plural = verbose_name + ordering = ['name'] + + def __str__(self): + return self.name + + +class Asset(models.Model): + """硬件资产""" + + STATUS_CHOICES = [ + ('in_use', '在用'), + ('idle', '闲置'), + ('maintenance', '维修中'), + ('scrapped', '已报废'), + ] + + # 基本信息 + asset_number = models.CharField('资产编号', max_length=50, unique=True, db_index=True) + name = models.CharField('设备名称', max_length=100) + category = models.ForeignKey(Category, on_delete=models.PROTECT, verbose_name='设备分类', related_name='assets') + + # 硬件信息 + brand = models.CharField('品牌', max_length=50, blank=True, default='') + model = models.CharField('型号', max_length=100, blank=True, default='') + serial_number = models.CharField('序列号', max_length=100, blank=True, default='', db_index=True) + + # 位置信息 + location = models.CharField('设备位置', max_length=200, blank=True, default='', help_text='楼层/房间/区域') + cabinet = models.CharField('机柜', max_length=50, blank=True, default='') + cabinet_position = models.CharField('机柜位置', max_length=50, blank=True, default='', help_text='U位') + + # 网络信息 + bmc_address = models.GenericIPAddressField('BMC地址', blank=True, null=True) + ip_address = models.GenericIPAddressField('IP地址', blank=True, null=True) + + # 采购与质保 + purchase_date = models.DateField('采购日期', blank=True, null=True) + warranty_expire = models.DateField('质保到期', blank=True, null=True) + supplier = models.CharField('供应商', max_length=100, blank=True, default='') + + # 管理信息 + responsible_person = models.CharField('负责人', max_length=50, blank=True, default='') + status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='in_use') + remark = models.TextField('备注', blank=True, default='') + + # 系统字段 + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='创建人') + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + verbose_name = '硬件资产' + verbose_name_plural = verbose_name + ordering = ['-created_at'] + + def __str__(self): + return f'{self.asset_number} - {self.name}' + + @property + def is_expired(self): + if self.warranty_expire: + return self.warranty_expire < date.today() + return False + + @property + def is_expiring_soon(self): + if self.warranty_expire: + from datetime import timedelta + return date.today() <= self.warranty_expire <= date.today() + timedelta(days=30) + return False + + +class AssetChangeLog(models.Model): + """资产变更记录""" + + ACTION_CHOICES = [ + ('create', '创建'), + ('update', '更新'), + ('delete', '删除'), + ('import', '导入'), + ('export', '导出'), + ('status_change', '状态变更'), + ] + + asset = models.ForeignKey(Asset, on_delete=models.SET_NULL, null=True, blank=True, + verbose_name='资产', related_name='change_logs') + asset_number = models.CharField('资产编号', max_length=50, db_index=True) + action = models.CharField('操作类型', max_length=20, choices=ACTION_CHOICES) + field_name = models.CharField('变更字段', max_length=50, blank=True, default='') + old_value = models.TextField('旧值', blank=True, default='') + new_value = models.TextField('新值', blank=True, default='') + description = models.TextField('描述', blank=True, default='') + operator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='操作人') + created_at = models.DateTimeField('操作时间', auto_now_add=True) + + class Meta: + verbose_name = '变更记录' + verbose_name_plural = verbose_name + ordering = ['-created_at'] + + def __str__(self): + return f'{self.asset_number} - {self.get_action_display()}' diff --git a/assetapp/templatetags/__init__.py b/assetapp/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assetapp/templatetags/asset_tags.py b/assetapp/templatetags/asset_tags.py new file mode 100644 index 0000000..c997dd2 --- /dev/null +++ b/assetapp/templatetags/asset_tags.py @@ -0,0 +1,9 @@ +from django import template + +register = template.Library() + +@register.filter +def get_item(dictionary, key): + if isinstance(dictionary, dict): + return dictionary.get(key, 0) + return 0 diff --git a/assetapp/tests.py b/assetapp/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/assetapp/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/assetapp/urls.py b/assetapp/urls.py new file mode 100644 index 0000000..ec57d49 --- /dev/null +++ b/assetapp/urls.py @@ -0,0 +1,32 @@ +from django.urls import path +from . import views + +urlpatterns = [ + # 认证 + path('login/', views.login_view, name='login'), + path('logout/', views.logout_view, name='logout'), + + # 仪表盘 + path('', views.dashboard, name='dashboard'), + + # 资产CRUD + path('assets/', views.asset_list, name='asset_list'), + path('assets/create/', views.asset_create, name='asset_create'), + path('assets//', views.asset_detail, name='asset_detail'), + path('assets//edit/', views.asset_update, name='asset_update'), + path('assets//delete/', views.asset_delete, name='asset_delete'), + + # Excel导入导出 + path('assets/export/', views.asset_export, name='asset_export'), + path('assets/import/', views.asset_import, name='asset_import'), + path('assets/import/template/', views.download_template, name='download_template'), + + # 变更记录 + path('changelog/', views.change_log_list, name='change_log_list'), + + # 分类管理 + path('categories/', views.category_list, name='category_list'), + path('categories/create/', views.category_create, name='category_create'), + path('categories//edit/', views.category_update, name='category_update'), + path('categories//delete/', views.category_delete, name='category_delete'), +] diff --git a/assetapp/views.py b/assetapp/views.py new file mode 100644 index 0000000..f88cc2d --- /dev/null +++ b/assetapp/views.py @@ -0,0 +1,416 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.contrib.auth import authenticate, login, logout +from django.contrib import messages +from django.http import HttpResponse, JsonResponse +from django.db.models import Count, Q +from django.core.paginator import Paginator +from django.conf import settings +from openpyxl import load_workbook +from io import BytesIO +from datetime import date, timedelta + +from .models import Asset, Category, AssetChangeLog +from .forms import AssetForm, AssetImportForm, CategoryForm +from .excel_utils import ( + export_assets_to_excel, generate_import_template, + import_assets_from_excel, STATUS_MAP, +) + + +# ─── 认证 ──────────────────────────────────────── + +def login_view(request): + if request.user.is_authenticated: + return redirect('dashboard') + + if request.method == 'POST': + username = request.POST.get('username', '') + password = request.POST.get('password', '') + user = authenticate(request, username=username, password=password) + if user is not None: + login(request, user) + next_url = request.GET.get('next', '/') + return redirect(next_url) + else: + messages.error(request, '用户名或密码错误') + + return render(request, 'assetapp/login.html') + + +def logout_view(request): + logout(request) + return redirect('login') + + +# ─── 仪表盘 ────────────────────────────────────── + +@login_required +def dashboard(request): + total_assets = Asset.objects.count() + status_stats = Asset.objects.values('status').annotate(count=Count('id')) + status_data = {item['status']: item['count'] for item in status_stats} + + category_stats = Asset.objects.values('category__name').annotate( + count=Count('id') + ).order_by('-count') + + # 即将过保(30天内) + today = date.today() + expiring_soon = Asset.objects.filter( + warranty_expire__lte=today + timedelta(days=30), + warranty_expire__gte=today, + status='in_use', + ).count() + expired = Asset.objects.filter( + warranty_expire__lt=today, + status='in_use', + ).count() + + # 最近变更 + recent_changes = AssetChangeLog.objects.select_related('operator')[:10] + + context = { + 'total_assets': total_assets, + 'status_data': status_data, + 'status_map': STATUS_MAP, + 'category_stats': category_stats, + 'expiring_soon': expiring_soon, + 'expired': expired, + 'recent_changes': recent_changes, + } + return render(request, 'assetapp/dashboard.html', context) + + +# ─── 资产列表 ───────────────────────────────────── + +@login_required +def asset_list(request): + queryset = Asset.objects.select_related('category') + + # 搜索 + search = request.GET.get('search', '').strip() + if search: + queryset = queryset.filter( + Q(asset_number__icontains=search) | + Q(name__icontains=search) | + Q(serial_number__icontains=search) | + Q(ip_address__icontains=search) | + Q(brand__icontains=search) | + Q(model__icontains=search) | + Q(location__icontains=search) | + Q(responsible_person__icontains=search) + ) + + # 筛选 + category_id = request.GET.get('category') + if category_id: + queryset = queryset.filter(category_id=category_id) + + status = request.GET.get('status') + if status: + queryset = queryset.filter(status=status) + + location = request.GET.get('location') + if location: + queryset = queryset.filter(location__icontains=location) + + # 排序 + sort = request.GET.get('sort', '-created_at') + valid_sorts = ['asset_number', '-asset_number', 'name', '-name', + 'created_at', '-created_at', 'updated_at', '-updated_at', + 'purchase_date', '-purchase_date'] + if sort not in valid_sorts: + sort = '-created_at' + queryset = queryset.order_by(sort) + + # 分页 + paginator = Paginator(queryset, settings.ASSETS_PER_PAGE) + page_number = request.GET.get('page', 1) + page_obj = paginator.get_page(page_number) + + categories = Category.objects.all() + locations = Asset.objects.values_list('location', flat=True).exclude(location='').distinct() + + context = { + 'page_obj': page_obj, + 'search': search, + 'categories': categories, + 'current_category': category_id, + 'current_status': status, + 'current_location': location, + 'locations': locations, + 'status_map': STATUS_MAP, + 'sort': sort, + } + return render(request, 'assetapp/asset_list.html', context) + + +# ─── 资产详情 ───────────────────────────────────── + +@login_required +def asset_detail(request, pk): + asset = get_object_or_404(Asset.objects.select_related('category', 'created_by'), pk=pk) + change_logs = asset.change_logs.select_related('operator')[:20] + context = { + 'asset': asset, + 'change_logs': change_logs, + 'status_map': STATUS_MAP, + } + return render(request, 'assetapp/asset_detail.html', context) + + +# ─── 资产创建 ───────────────────────────────────── + +@login_required +def asset_create(request): + if request.method == 'POST': + form = AssetForm(request.POST) + if form.is_valid(): + asset = form.save(commit=False) + asset.created_by = request.user + asset.save() + + AssetChangeLog.objects.create( + asset=asset, + asset_number=asset.asset_number, + action='create', + description='创建资产', + operator=request.user, + ) + + messages.success(request, f'资产 {asset.asset_number} 创建成功!') + return redirect('asset_detail', pk=asset.pk) + else: + form = AssetForm() + + context = {'form': form, 'action': 'create'} + return render(request, 'assetapp/asset_form.html', context) + + +# ─── 资产编辑 ───────────────────────────────────── + +@login_required +def asset_update(request, pk): + asset = get_object_or_404(Asset, pk=pk) + + if request.method == 'POST': + form = AssetForm(request.POST, instance=asset) + if form.is_valid(): + # 记录变更 + changes = [] + for field in form.changed_data: + old_val = str(form.initial.get(field, '')) + new_val = str(form.cleaned_data.get(field, '')) + changes.append(f'{field}: {old_val} → {new_val}') + + AssetChangeLog.objects.create( + asset=asset, + asset_number=asset.asset_number, + action='update', + field_name=field, + old_value=str(form.initial.get(field, '')), + new_value=str(form.cleaned_data.get(field, '')), + operator=request.user, + ) + + asset = form.save() + + if changes: + messages.success(request, f'已更新 {len(changes)} 个字段') + return redirect('asset_detail', pk=asset.pk) + else: + form = AssetForm(instance=asset) + + context = {'form': form, 'action': 'update', 'asset': asset} + return render(request, 'assetapp/asset_form.html', context) + + +# ─── 资产删除 ───────────────────────────────────── + +@login_required +def asset_delete(request, pk): + asset = get_object_or_404(Asset, pk=pk) + + if request.method == 'POST': + AssetChangeLog.objects.create( + asset_number=asset.asset_number, + action='delete', + description=f'删除资产: {asset.name}', + operator=request.user, + ) + asset.delete() + messages.success(request, f'资产 {asset.asset_number} 已删除') + return redirect('asset_list') + + context = {'asset': asset} + return render(request, 'assetapp/asset_confirm_delete.html', context) + + +# ─── Excel导出 ──────────────────────────────────── + +@login_required +def asset_export(request): + queryset = Asset.objects.select_related('category') + + # 支持筛选导出 + category_id = request.GET.get('category') + if category_id: + queryset = queryset.filter(category_id=category_id) + + status = request.GET.get('status') + if status: + queryset = queryset.filter(status=status) + + wb = export_assets_to_excel(queryset) + + output = BytesIO() + wb.save(output) + output.seek(0) + + AssetChangeLog.objects.create( + asset_number='-', + action='export', + description=f'导出 {queryset.count()} 条资产记录', + operator=request.user, + ) + + today_str = date.today().strftime('%Y%m%d') + response = HttpResponse( + output.read(), + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = f'attachment; filename=硬件资产_{today_str}.xlsx' + return response + + +# ─── Excel导入 ──────────────────────────────────── + +@login_required +def asset_import(request): + if request.method == 'POST': + form = AssetImportForm(request.POST, request.FILES) + if form.is_valid(): + try: + wb = load_workbook(request.FILES['excel_file']) + ws = wb.active + + category_map = {c.name: c for c in Category.objects.all()} + results = import_assets_from_excel(ws, category_map, operator=request.user) + + if results['success'] > 0: + messages.success(request, f"成功导入 {results['success']} 条资产") + + if results['skipped'] > 0: + messages.warning(request, f"跳过 {results['skipped']} 条(资产编号重复)") + + if results['errors']: + for error in results['errors'][:10]: + messages.error(request, error) + if len(results['errors']) > 10: + messages.error(request, f"...还有 {len(results['errors']) - 10} 条错误") + + except Exception as e: + messages.error(request, f'导入失败: {str(e)}') + + return redirect('asset_list') + else: + form = AssetImportForm() + + return render(request, 'assetapp/asset_import.html', {'form': form}) + + +# ─── 下载导入模板 ────────────────────────────────── + +@login_required +def download_template(request): + wb = generate_import_template() + output = BytesIO() + wb.save(output) + output.seek(0) + + response = HttpResponse( + output.read(), + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = 'attachment; filename=资产导入模板.xlsx' + return response + + +# ─── 变更记录 ───────────────────────────────────── + +@login_required +def change_log_list(request): + logs = AssetChangeLog.objects.select_related('operator', 'asset') + + asset_number = request.GET.get('asset_number', '').strip() + if asset_number: + logs = logs.filter(asset_number__icontains=asset_number) + + action = request.GET.get('action') + if action: + logs = logs.filter(action=action) + + paginator = Paginator(logs, 30) + page_number = request.GET.get('page', 1) + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'asset_number': asset_number, + 'current_action': action, + } + return render(request, 'assetapp/changelog.html', context) + + +# ─── 分类管理 ───────────────────────────────────── + +@login_required +def category_list(request): + categories = Category.objects.annotate(asset_count=Count('assets')).order_by('name') + return render(request, 'assetapp/category_list.html', {'categories': categories}) + + +@login_required +def category_create(request): + if request.method == 'POST': + form = CategoryForm(request.POST) + if form.is_valid(): + form.save() + messages.success(request, f'分类 "{form.instance.name}" 创建成功') + return redirect('category_list') + else: + form = CategoryForm() + + return render(request, 'assetapp/category_form.html', {'form': form, 'action': 'create'}) + + +@login_required +def category_update(request, pk): + category = get_object_or_404(Category, pk=pk) + if request.method == 'POST': + form = CategoryForm(request.POST, instance=category) + if form.is_valid(): + form.save() + messages.success(request, f'分类 "{category.name}" 已更新') + return redirect('category_list') + else: + form = CategoryForm(instance=category) + + return render(request, 'assetapp/category_form.html', {'form': form, 'action': 'update', 'category': category}) + + +@login_required +def category_delete(request, pk): + category = get_object_or_404(Category, pk=pk) + asset_count = category.assets.count() + if asset_count > 0: + messages.error(request, f'分类 "{category.name}" 下还有 {asset_count} 个资产,无法删除') + return redirect('category_list') + + if request.method == 'POST': + category.delete() + messages.success(request, f'分类 "{category.name}" 已删除') + return redirect('category_list') + + return render(request, 'assetapp/category_confirm_delete.html', {'category': category}) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ed7c431 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..c3077bb --- /dev/null +++ b/config/settings.py @@ -0,0 +1,95 @@ +""" +Django settings for asset-management project. +""" +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-asset-mgmt-change-me-in-production') + +DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() == 'true' + +ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split(',') + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'assetapp', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +# Database +DATABASES = { + 'default': { + 'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.mysql'), + 'NAME': os.environ.get('DB_NAME', 'asset_management'), + 'USER': os.environ.get('DB_USER', 'root'), + 'PASSWORD': os.environ.get('DB_PASSWORD', 'password123'), + 'HOST': os.environ.get('DB_HOST', '127.0.0.1'), + 'PORT': os.environ.get('DB_PORT', '3306'), + 'OPTIONS': { + 'charset': 'utf8mb4', + 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", + }, + } +} + +# Default primary key field type +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Auth +LOGIN_URL = '/login/' +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/login/' + +# Static files +STATIC_URL = '/static/' +STATICFILES_DIRS = [BASE_DIR / 'static'] +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# Media files +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +# Internationalization +LANGUAGE_CODE = 'zh-hans' +TIME_ZONE = 'Asia/Shanghai' +USE_I18N = True +USE_TZ = True + +# Pagination +ASSETS_PER_PAGE = 20 diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..70c8d78 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from django.views.static import serve +import os + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('assetapp.urls')), + path('', include('django.contrib.auth.urls')), +] + +# Serve static and media files (works in both DEBUG and non-DEBUG modes) +urlpatterns += [ + path('static/', serve, {'document_root': settings.STATIC_ROOT}), + path('media/', serve, {'document_root': settings.MEDIA_ROOT}), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..e2fbd58 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..094816f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +version: '3.8' + +services: + db: + image: mysql:8.0 + restart: always + environment: + MYSQL_ROOT_PASSWORD: password123 + MYSQL_DATABASE: asset_management + MYSQL_CHARSET: utf8mb4 + MYSQL_COLLATION: utf8mb4_unicode_ci + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + + web: + build: . + restart: always + ports: + - "8010:8000" + environment: + - DB_ENGINE=django.db.backends.mysql + - DB_NAME=asset_management + - DB_USER=root + - DB_PASSWORD=password123 + - DB_HOST=db + - DB_PORT=3306 + - DJANGO_DEBUG=False + - DJANGO_ALLOWED_HOSTS=* + depends_on: + db: + condition: service_healthy + volumes: + - static_data:/app/staticfiles + +volumes: + mysql_data: + static_data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..35eff10 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +echo "==> Running migrations..." +python manage.py migrate --noinput + +echo "==> Collecting static files..." +python manage.py collectstatic --noinput 2>/dev/null || true + +echo "==> Creating default admin user..." +python manage.py shell << EOF +from assetapp.models import User +if not User.objects.filter(username='admin').exists(): + User.objects.create_superuser('admin', 'admin@example.com', 'admin123') + print('Admin user created: admin/admin123') +else: + print('Admin user already exists') +EOF + +echo "==> Starting server..." +if [ "$1" = "gunicorn" ]; then + exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 +else + exec python manage.py runserver 0.0.0.0:8000 +fi diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ade9141 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Django>=4.2,<5.0 +mysqlclient>=2.2 +openpyxl>=3.1 +gunicorn>=21.2 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..46dfc7e --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,453 @@ +/* ===== 硬件资产管理系统 - 深色主题 ===== */ + +:root { + --bg-primary: #0E1621; + --bg-secondary: #1B2838; + --bg-card: #1E2A3A; + --bg-input: #0E1621; + --border-color: #2A3A4A; + --text-primary: #E0E0E0; + --text-muted: #8B9DAF; + --accent-blue: #4A9EFF; + --accent-green: #4CAF50; + --accent-orange: #FF9800; + --accent-red: #F44336; + --accent-purple: #9C27B0; +} + +* { + box-sizing: border-box; +} + +body { + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; + min-height: 100vh; +} + +/* === 导航栏 === */ +.bg-navy { + background-color: var(--bg-secondary) !important; + border-bottom: 1px solid var(--border-color); +} + +.navbar-brand { + color: var(--accent-blue) !important; + font-size: 1.1rem; +} + +.nav-link { + color: var(--text-muted) !important; + font-size: 0.9rem; + padding: 0.5rem 0.8rem !important; + border-radius: 4px; + transition: all 0.2s; +} + +.nav-link:hover, .nav-link.active { + color: var(--text-primary) !important; + background-color: rgba(74, 158, 255, 0.1); +} + +.dropdown-menu-dark { + background-color: var(--bg-card); + border-color: var(--border-color); +} + +.dropdown-menu-dark .dropdown-item:hover { + background-color: rgba(74, 158, 255, 0.15); +} + +/* === 登录页面 === */ +.login-body { + background: linear-gradient(135deg, #0E1621 0%, #1B2838 50%, #0E1621 100%); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.login-container { + width: 100%; + max-width: 420px; + padding: 20px; +} + +.login-card { + background-color: var(--bg-card); + border-radius: 12px; + border: 1px solid var(--border-color); + overflow: hidden; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.login-header { + background: linear-gradient(135deg, #1a3a5c 0%, #0E1621 100%); + text-align: center; + padding: 2rem 1rem; + color: var(--accent-blue); +} + +.login-header h3 { + color: var(--text-primary); + font-weight: 600; +} + +.login-body { + padding: 2rem; + min-height: auto; +} + +.btn-login { + background: linear-gradient(135deg, #4A9EFF, #2563EB); + border: none; + padding: 0.6rem; + font-size: 1rem; + border-radius: 8px; + transition: transform 0.15s; +} + +.btn-login:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(74, 158, 255, 0.3); +} + +/* === 输入框样式 === */ +.input-group-text { + background-color: var(--bg-secondary); + border-color: var(--border-color); + color: var(--text-muted); +} + +.form-control, .form-select { + background-color: var(--bg-input); + border-color: var(--border-color); + color: var(--text-primary); +} + +.form-control:focus, .form-select:focus { + background-color: var(--bg-input); + border-color: var(--accent-blue); + color: var(--text-primary); + box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.15); +} + +.form-control::placeholder { + color: var(--text-muted); + opacity: 0.6; +} + +textarea.form-control { + background-color: var(--bg-input); +} + +/* === 卡片 === */ +.card-dark { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.card-dark .card-header { + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); + font-weight: 500; + padding: 0.75rem 1rem; + border-radius: 8px 8px 0 0; +} + +.card-dark .card-body { + padding: 1rem; +} + +/* === 统计卡片 === */ +.stat-card { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 1.25rem; + display: flex; + align-items: center; + gap: 1rem; + transition: transform 0.2s, box-shadow 0.2s; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.stat-icon { + font-size: 2.2rem; + opacity: 0.8; +} + +.stat-total .stat-icon { color: var(--accent-blue); } +.stat-in-use .stat-icon { color: var(--accent-green); } +.stat-warning .stat-icon { color: var(--accent-orange); } +.stat-danger .stat-icon { color: var(--accent-red); } + +.stat-value { + font-size: 1.8rem; + font-weight: 700; + line-height: 1.2; +} + +.stat-total .stat-value { color: var(--accent-blue); } +.stat-in-use .stat-value { color: var(--accent-green); } +.stat-warning .stat-value { color: var(--accent-orange); } +.stat-danger .stat-value { color: var(--accent-red); } + +.stat-label { + color: var(--text-muted); + font-size: 0.85rem; +} + +/* === 表格 === */ +.table-dark { + --bs-table-bg: var(--bg-card); + --bs-table-hover-bg: var(--bg-secondary); + --bs-table-striped-bg: rgba(30, 42, 58, 0.5); + color: var(--text-primary); +} + +.table-dark th { + background-color: var(--bg-secondary); + border-color: var(--border-color); + color: var(--text-muted); + font-weight: 500; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0.6rem 0.75rem; + white-space: nowrap; +} + +.table-dark td { + border-color: var(--border-color); + padding: 0.5rem 0.75rem; + vertical-align: middle; + font-size: 0.9rem; +} + +.detail-table td { + padding: 0.4rem 0; + border: none !important; +} + +.detail-table td:first-child { + color: var(--text-muted); + font-size: 0.85rem; +} + +/* === 按钮 === */ +.btn-primary { + background-color: var(--accent-blue); + border-color: var(--accent-blue); +} + +body { + background-color: #0f1923; + color: #c9d1d9; +} + +/* 深色主题全局文字颜色修正 */ +.text-muted { + color: #8b949e !important; +} +.text-secondary { + color: #8b949e !important; +} +.form-label { + color: #c9d1d9 !important; +} +.detail-table td:first-child { + color: #8b949e !important; +} +small.text-muted, .small.text-muted { + color: #8b949e !important; +} + +.btn-primary:hover { + background-color: #3A8EEF; + border-color: #3A8EEF; +} + +.btn-outline-success { + color: var(--accent-green); + border-color: var(--accent-green); +} + +.btn-outline-success:hover { + background-color: var(--accent-green); + color: white; +} + +.btn-outline-info { + color: var(--accent-blue); + border-color: var(--accent-blue); +} + +.btn-outline-info:hover { + background-color: var(--accent-blue); + color: white; +} + +.btn-outline-warning { + color: var(--accent-orange); + border-color: var(--accent-orange); +} + +.btn-outline-warning:hover { + background-color: var(--accent-orange); + color: #000; +} + +.btn-outline-danger { + color: var(--accent-red); + border-color: var(--accent-red); +} + +.btn-outline-danger:hover { + background-color: var(--accent-red); + color: white; +} + +.btn-outline-secondary { + color: var(--text-muted); + border-color: var(--border-color); +} + +.btn-outline-secondary:hover { + background-color: var(--bg-secondary); + color: var(--text-primary); + border-color: var(--border-color); +} + +.btn-outline-light { + color: var(--text-muted); + border-color: var(--border-color); +} + +.btn-outline-light:hover { + background-color: var(--bg-secondary); + color: var(--text-primary); +} + +.btn-xs { + padding: 0.15rem 0.4rem; + font-size: 0.75rem; + border-radius: 3px; +} + +/* === 分页 === */ +.pagination .page-link { + background-color: var(--bg-card); + border-color: var(--border-color); + color: var(--text-muted); +} + +.pagination .page-link:hover { + background-color: var(--bg-secondary); + color: var(--text-primary); +} + +.pagination .active .page-link { + background-color: var(--accent-blue); + border-color: var(--accent-blue); +} + +/* === 徽章 === */ +.badge { + font-weight: 500; + font-size: 0.78rem; + padding: 0.3em 0.6em; +} + +/* === 代码 === */ +code { + color: var(--accent-blue); + background-color: rgba(74, 158, 255, 0.1); + padding: 0.15em 0.4em; + border-radius: 3px; + font-size: 0.85em; +} + +/* === 进度条 === */ +.progress { + background-color: var(--bg-input); +} + +/* === Alert === */ +.alert-info { + background-color: rgba(74, 158, 255, 0.1); + border-color: rgba(74, 158, 255, 0.2); + color: var(--text-primary); +} + +.alert-success { + background-color: rgba(76, 175, 80, 0.1); + border-color: rgba(76, 175, 80, 0.2); + color: #81C784; +} + +.alert-warning { + background-color: rgba(255, 152, 0, 0.1); + border-color: rgba(255, 152, 0, 0.2); + color: #FFB74D; +} + +.alert-danger { + background-color: rgba(244, 67, 54, 0.1); + border-color: rgba(244, 67, 54, 0.2); + color: #EF9A9A; +} + +.btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +/* === 滚动条 === */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* === 文件上传 === */ +.file-upload-wrapper { + display: flex; + align-items: center; +} +.file-name-text { + color: #8b949e; + font-size: 0.9rem; +} + +/* === 响应式 === */ +@media (max-width: 768px) { + .stat-card { + padding: 1rem; + } + .stat-icon { + font-size: 1.8rem; + } + .stat-value { + font-size: 1.4rem; + } +} + diff --git a/templates/assetapp/asset_confirm_delete.html b/templates/assetapp/asset_confirm_delete.html new file mode 100644 index 0000000..6324c4a --- /dev/null +++ b/templates/assetapp/asset_confirm_delete.html @@ -0,0 +1,32 @@ +{% extends "assetapp/base.html" %} + +{% block title %}删除确认{% endblock %} + +{% block content %} +
+
+
+
+ 删除确认 +
+
+ +
确定要删除以下资产吗?
+
+

资产编号:{{ asset.asset_number }}

+

设备名称:{{ asset.name }}

+

分类:{{ asset.category.name }}

+
+

此操作不可撤销,相关变更记录将保留。

+
+ {% csrf_token %} + +
+ 取消 +
+
+
+
+{% endblock %} diff --git a/templates/assetapp/asset_detail.html b/templates/assetapp/asset_detail.html new file mode 100644 index 0000000..b124f6c --- /dev/null +++ b/templates/assetapp/asset_detail.html @@ -0,0 +1,150 @@ +{% extends "assetapp/base.html" %} + +{% block title %}{{ asset.asset_number }} - 资产详情{% endblock %} + +{% block content %} +
+
+ + 返回 + +

{{ asset.asset_number }}

+ + {{ asset.get_status_display }} + +
+ +
+ +
+ +
+
+
基本信息
+
+ + + + + + + +
设备名称{{ asset.name }}
资产编号{{ asset.asset_number }}
设备分类{{ asset.category.name }}
品牌{{ asset.brand|default:"-" }}
型号{{ asset.model|default:"-" }}
序列号{{ asset.serial_number|default:"-" }}
+
+
+
+ + +
+
+
位置与网络
+
+ + + + + + + + + + +
设备位置{{ asset.location|default:"-" }}
机柜{{ asset.cabinet|default:"-" }}
机柜位置{{ asset.cabinet_position|default:"-" }}
BMC地址{{ asset.bmc_address|default:"-" }}
IP地址{{ asset.ip_address|default:"-" }}
负责人{{ asset.responsible_person|default:"-" }}
状态 + {{ asset.get_status_display }} +
+
+
+
+ + +
+
+
采购与质保
+
+ + + + + + + +
采购日期{{ asset.purchase_date|default:"-" }}
质保到期 + {{ asset.warranty_expire|default:"-" }} + {% if asset.warranty_expire %} + {% if asset.is_expired %} + 已过保 + {% elif asset.is_expiring_soon %} + 即将过保 + {% endif %} + {% endif %} +
供应商{{ asset.supplier|default:"-" }}
+
+
+
+ + +
+
+
备注
+
+

{{ asset.remark|default:"暂无备注"|linebreaksbr }}

+
+ + 创建人:{{ asset.created_by|default:"-" }} | + 创建时间:{{ asset.created_at|date:"Y-m-d H:i" }} | + 更新时间:{{ asset.updated_at|date:"Y-m-d H:i" }} + +
+
+
+
+ + +
+
变更记录
+
+
+ + + + + + {% for log in change_logs %} + + + + + + + + + {% empty %} + + {% endfor %} + +
时间操作字段旧值新值操作人
{{ log.created_at|date:"Y-m-d H:i" }} + {{ log.get_action_display }}{{ log.field_name|default:"-" }}{{ log.old_value|default:"-" }}{{ log.new_value|default:"-" }}{{ log.operator|default:"-" }}
暂无变更记录
+
+
+
+{% endblock %} diff --git a/templates/assetapp/asset_form.html b/templates/assetapp/asset_form.html new file mode 100644 index 0000000..a285d58 --- /dev/null +++ b/templates/assetapp/asset_form.html @@ -0,0 +1,140 @@ +{% extends "assetapp/base.html" %} + +{% block title %}{% if action == 'create' %}新增资产{% else %}编辑资产{% endif %}{% endblock %} + +{% block content %} +
+ + 返回 + +

+ + {% if action == 'create' %}新增资产{% else %}编辑资产 - {{ asset.asset_number }}{% endif %} +

+
+ +
+ {% csrf_token %} + +
+ +
+
+
基本信息
+
+
+ + {{ form.asset_number }} + {% if form.asset_number.errors %}
{{ form.asset_number.errors.0 }}
{% endif %} +
+
+ + {{ form.name }} + {% if form.name.errors %}
{{ form.name.errors.0 }}
{% endif %} +
+
+ + {{ form.category }} +
+
+
+ + {{ form.brand }} +
+
+ + {{ form.model }} +
+
+
+ + {{ form.serial_number }} +
+
+
+
+ + +
+
+
位置与网络
+
+
+ + {{ form.location }} +
+
+
+ + {{ form.cabinet }} +
+
+ + {{ form.cabinet_position }} +
+
+
+ + {{ form.bmc_address }} +
+
+ + {{ form.ip_address }} +
+
+ + {{ form.status }} +
+
+ + {{ form.responsible_person }} +
+
+
+
+ + +
+
+
采购与质保
+
+
+
+ + {{ form.purchase_date }} +
+
+ + {{ form.warranty_expire }} +
+
+
+ + {{ form.supplier }} +
+
+
+
+ + +
+
+
备注
+
+
+ + {{ form.remark }} +
+
+
+
+
+ +
+ + 取消 +
+
+{% endblock %} diff --git a/templates/assetapp/asset_import.html b/templates/assetapp/asset_import.html new file mode 100644 index 0000000..101548d --- /dev/null +++ b/templates/assetapp/asset_import.html @@ -0,0 +1,71 @@ +{% extends "assetapp/base.html" %} + +{% block title %}导入Excel{% endblock %} + +{% block content %} +
+ + 返回 + +

导入Excel

+
+ +
+
+
+
上传文件
+
+
+ + 使用说明: +
    +
  1. 下载导入模板
  2. +
  3. 按模板格式填写资产数据
  4. +
  5. 上传填写好的Excel文件(.xlsx)
  6. +
  7. 资产编号重复的记录会被自动跳过
  8. +
  9. 分类不存在时会自动创建
  10. +
+
+ +
+ {% csrf_token %} +
+ +
+ + + 未选择文件 +
+ {% if form.excel_file.errors %} +
{{ form.excel_file.errors.0 }}
+ {% endif %} +
+
+ + + 下载模板 + +
+
+
+
+
+
+ + +{% endblock %} diff --git a/templates/assetapp/asset_list.html b/templates/assetapp/asset_list.html new file mode 100644 index 0000000..0f4ccf7 --- /dev/null +++ b/templates/assetapp/asset_list.html @@ -0,0 +1,154 @@ +{% extends "assetapp/base.html" %} + +{% block title %}资产列表 - 希姆计算资产管理{% endblock %} + +{% block content %} + + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + {% for asset in page_obj %} + + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
资产编号设备名称分类品牌/型号位置BMC地址IP地址负责人状态操作
{{ asset.asset_number }}{{ asset.name }}{{ asset.category.name }}{{ asset.brand }} {% if asset.model %}{{ asset.model }}{% endif %} + {{ asset.location }} + {% if asset.cabinet %} {{ asset.cabinet }}{% if asset.cabinet_position %}/{{ asset.cabinet_position }}{% endif %}{% endif %} + {{ asset.bmc_address|default:"-" }}{{ asset.ip_address|default:"-" }}{{ asset.responsible_person|default:"-" }} + + {{ asset.get_status_display }} + + + + + + + + + + + +
暂无资产数据
+
+
+
+ + +{% if page_obj.has_other_pages %} + +{% endif %} + +
+ 共 {{ page_obj.paginator.count }} 条记录,第 {{ page_obj.number }}/{{ page_obj.paginator.num_pages }} 页 +
+{% endblock %} diff --git a/templates/assetapp/base.html b/templates/assetapp/base.html new file mode 100644 index 0000000..7b82427 --- /dev/null +++ b/templates/assetapp/base.html @@ -0,0 +1,89 @@ +{% load static %} + + + + + + {% block title %}希姆计算资产管理{% endblock %} + + + + {% block extra_css %}{% endblock %} + + + {% if user.is_authenticated %} + + {% endif %} + + {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + +
+ {% block content %}{% endblock %} +
+ + + {% block extra_js %}{% endblock %} + + diff --git a/templates/assetapp/category_confirm_delete.html b/templates/assetapp/category_confirm_delete.html new file mode 100644 index 0000000..269973f --- /dev/null +++ b/templates/assetapp/category_confirm_delete.html @@ -0,0 +1,26 @@ +{% extends "assetapp/base.html" %} + +{% block title %}删除分类{% endblock %} + +{% block content %} +
+
+
+
+ 删除分类 +
+
+
确定要删除分类 "{{ category.name }}" 吗?
+

{{ category.description }}

+
+ {% csrf_token %} + +
+ 取消 +
+
+
+
+{% endblock %} diff --git a/templates/assetapp/category_form.html b/templates/assetapp/category_form.html new file mode 100644 index 0000000..e071c01 --- /dev/null +++ b/templates/assetapp/category_form.html @@ -0,0 +1,40 @@ +{% extends "assetapp/base.html" %} + +{% block title %}{% if action == 'create' %}新增分类{% else %}编辑分类{% endif %}{% endblock %} + +{% block content %} +
+ + 返回 + +

+ + {% if action == 'create' %}新增分类{% else %}编辑分类 - {{ category.name }}{% endif %} +

+
+ +
+
+
+
+
+ {% csrf_token %} +
+ + {{ form.name }} + {% if form.name.errors %}
{{ form.name.errors.0 }}
{% endif %} +
+
+ + {{ form.description }} +
+ + 取消 +
+
+
+
+
+{% endblock %} diff --git a/templates/assetapp/category_list.html b/templates/assetapp/category_list.html new file mode 100644 index 0000000..d24fa03 --- /dev/null +++ b/templates/assetapp/category_list.html @@ -0,0 +1,52 @@ +{% extends "assetapp/base.html" %} + +{% block title %}分类管理{% endblock %} + +{% block content %} +
+

分类管理

+ + 新增分类 + +
+ +
+
+
+ + + + + + + + + + + + + {% for category in categories %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ID分类名称描述资产数量创建时间操作
{{ category.id }}{{ category.name }}{{ category.description|default:"-" }}{{ category.asset_count }}{{ category.created_at|date:"Y-m-d H:i" }} + + + + + + +
暂无分类
+
+
+
+{% endblock %} diff --git a/templates/assetapp/changelog.html b/templates/assetapp/changelog.html new file mode 100644 index 0000000..f2c96de --- /dev/null +++ b/templates/assetapp/changelog.html @@ -0,0 +1,110 @@ +{% extends "assetapp/base.html" %} + +{% block title %}变更记录{% endblock %} + +{% block content %} +
+

变更记录

+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + {% for log in page_obj %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
时间资产编号操作变更字段旧值新值描述操作人
{{ log.created_at|date:"Y-m-d H:i:s" }} + {% if log.asset %} + {{ log.asset_number }} + {% else %} + {{ log.asset_number }} + {% endif %} + + {{ log.get_action_display }}{{ log.field_name|default:"-" }}{{ log.old_value|default:"-"|truncatechars:30 }}{{ log.new_value|default:"-"|truncatechars:30 }}{{ log.description|default:"-" }}{{ log.operator|default:"-" }}
暂无变更记录
+
+
+
+ + +{% if page_obj.has_other_pages %} + +{% endif %} +{% endblock %} diff --git a/templates/assetapp/dashboard.html b/templates/assetapp/dashboard.html new file mode 100644 index 0000000..a06ab61 --- /dev/null +++ b/templates/assetapp/dashboard.html @@ -0,0 +1,151 @@ +{% extends "assetapp/base.html" %} +{% load asset_tags %} + +{% block title %}仪表盘 - 希姆计算资产管理{% endblock %} + +{% block content %} +
+

仪表盘

+ + 新增资产 + +
+ + +
+
+
+
+
+
{{ total_assets }}
+
资产总数
+
+
+
+
+
+
+
+
{{ status_data.in_use|default:0 }}
+
在用
+
+
+
+
+
+
+
+
{{ expiring_soon }}
+
即将过保(30天)
+
+
+
+
+
+
+
+
{{ expired }}
+
已过保
+
+
+
+
+ +
+ +
+
+
+ 分类统计 +
+
+ {% if category_stats %} + {% for item in category_stats %} +
+ {{ item.category__name }} +
+
+ {% widthratio item.count total_assets 100 as pct %} +
+
+ {{ item.count }} +
+
+ {% endfor %} + {% else %} +

暂无数据

+ {% endif %} +
+
+
+ + +
+
+
+ 状态分布 +
+
+ {% for key, label in status_map.items %} +
+ {{ label }} + + {{ status_data|get_item:key|default:0 }} + +
+ {% endfor %} +
+
+
+
+ + +
+
+ 最近变更 + 查看全部 +
+
+
+ + + + + + + + + + + + {% for log in recent_changes %} + + + + + + + + {% empty %} + + {% endfor %} + +
时间资产编号操作描述操作人
{{ log.created_at|date:"m-d H:i" }}{{ log.asset_number }} + + {{ log.get_action_display }} + + {{ log.description|default:"-" }}{{ log.operator|default:"-" }}
暂无变更记录
+
+
+
+{% endblock %} diff --git a/templates/assetapp/login.html b/templates/assetapp/login.html new file mode 100644 index 0000000..df8105a --- /dev/null +++ b/templates/assetapp/login.html @@ -0,0 +1,54 @@ +{% load static %} + + + + + + 登录 - 希姆计算资产管理 + + + + + + + + +