Bläddra i källkod

初始提交:希姆计算硬件资产管理系统

功能:
- Django + MySQL + 深色主题
- 资产增删改查(含资产编号、BMC地址、设备位置、备注)
- Excel导入导出(分类自动创建)
- 设备分类管理
- 资产变更记录追踪
- 质保到期提醒
- 用户认证系统
- Docker部署支持
cnbugs 1 månad sedan
incheckning
a40a0137cf
41 ändrade filer med 2833 tillägg och 0 borttagningar
  1. 41 0
      .gitignore
  2. 28 0
      Dockerfile
  3. 0 0
      assetapp/__init__.py
  4. 24 0
      assetapp/admin.py
  5. 6 0
      assetapp/apps.py
  6. 224 0
      assetapp/excel_utils.py
  7. 34 0
      assetapp/forms.py
  8. 0 0
      assetapp/management/__init__.py
  9. 0 0
      assetapp/management/commands/__init__.py
  10. 38 0
      assetapp/management/commands/init_assets.py
  11. 81 0
      assetapp/migrations/0001_initial.py
  12. 18 0
      assetapp/migrations/0002_asset_bmc_address.py
  13. 0 0
      assetapp/migrations/__init__.py
  14. 116 0
      assetapp/models.py
  15. 0 0
      assetapp/templatetags/__init__.py
  16. 9 0
      assetapp/templatetags/asset_tags.py
  17. 3 0
      assetapp/tests.py
  18. 32 0
      assetapp/urls.py
  19. 416 0
      assetapp/views.py
  20. 0 0
      config/__init__.py
  21. 16 0
      config/asgi.py
  22. 95 0
      config/settings.py
  23. 18 0
      config/urls.py
  24. 16 0
      config/wsgi.py
  25. 45 0
      docker-compose.yml
  26. 25 0
      entrypoint.sh
  27. 22 0
      manage.py
  28. 4 0
      requirements.txt
  29. 453 0
      static/css/style.css
  30. 32 0
      templates/assetapp/asset_confirm_delete.html
  31. 150 0
      templates/assetapp/asset_detail.html
  32. 140 0
      templates/assetapp/asset_form.html
  33. 71 0
      templates/assetapp/asset_import.html
  34. 154 0
      templates/assetapp/asset_list.html
  35. 89 0
      templates/assetapp/base.html
  36. 26 0
      templates/assetapp/category_confirm_delete.html
  37. 40 0
      templates/assetapp/category_form.html
  38. 52 0
      templates/assetapp/category_list.html
  39. 110 0
      templates/assetapp/changelog.html
  40. 151 0
      templates/assetapp/dashboard.html
  41. 54 0
      templates/assetapp/login.html

+ 41 - 0
.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/

+ 28 - 0
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"]

+ 0 - 0
assetapp/__init__.py


+ 24 - 0
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']

+ 6 - 0
assetapp/apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class AssetappConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'assetapp'

+ 224 - 0
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

+ 34 - 0
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']

+ 0 - 0
assetapp/management/__init__.py


+ 0 - 0
assetapp/management/commands/__init__.py


+ 38 - 0
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 登录'))

+ 81 - 0
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'],
+            },
+        ),
+    ]

+ 18 - 0
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地址'),
+        ),
+    ]

+ 0 - 0
assetapp/migrations/__init__.py


+ 116 - 0
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()}'

+ 0 - 0
assetapp/templatetags/__init__.py


+ 9 - 0
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

+ 3 - 0
assetapp/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 32 - 0
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/<int:pk>/', views.asset_detail, name='asset_detail'),
+    path('assets/<int:pk>/edit/', views.asset_update, name='asset_update'),
+    path('assets/<int:pk>/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/<int:pk>/edit/', views.category_update, name='category_update'),
+    path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
+]

+ 416 - 0
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})

+ 0 - 0
config/__init__.py


+ 16 - 0
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()

+ 95 - 0
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

+ 18 - 0
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/<path:path>', serve, {'document_root': settings.STATIC_ROOT}),
+    path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
+]

+ 16 - 0
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()

+ 45 - 0
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:

+ 25 - 0
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

+ 22 - 0
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()

+ 4 - 0
requirements.txt

@@ -0,0 +1,4 @@
+Django>=4.2,<5.0
+mysqlclient>=2.2
+openpyxl>=3.1
+gunicorn>=21.2

+ 453 - 0
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;
+    }
+}
+

+ 32 - 0
templates/assetapp/asset_confirm_delete.html

@@ -0,0 +1,32 @@
+{% extends "assetapp/base.html" %}
+
+{% block title %}删除确认{% endblock %}
+
+{% block content %}
+<div class="row justify-content-center mt-5">
+    <div class="col-md-6">
+        <div class="card card-dark">
+            <div class="card-header bg-danger">
+                <i class="bi bi-exclamation-triangle me-2"></i>删除确认
+            </div>
+            <div class="card-body text-center py-4">
+                <i class="bi bi-trash text-danger" style="font-size: 3rem;"></i>
+                <h5 class="mt-3">确定要删除以下资产吗?</h5>
+                <div class="my-3 p-3 rounded" style="background: rgba(255,255,255,0.05);">
+                    <p class="mb-1"><strong>资产编号:</strong>{{ asset.asset_number }}</p>
+                    <p class="mb-1"><strong>设备名称:</strong>{{ asset.name }}</p>
+                    <p class="mb-0"><strong>分类:</strong>{{ asset.category.name }}</p>
+                </div>
+                <p class="text-danger small">此操作不可撤销,相关变更记录将保留。</p>
+                <form method="post" class="d-inline">
+                    {% csrf_token %}
+                    <button type="submit" class="btn btn-danger me-2">
+                        <i class="bi bi-trash me-1"></i>确认删除
+                    </button>
+                </form>
+                <a href="{% url 'asset_detail' asset.pk %}" class="btn btn-outline-secondary">取消</a>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 150 - 0
templates/assetapp/asset_detail.html

@@ -0,0 +1,150 @@
+{% extends "assetapp/base.html" %}
+
+{% block title %}{{ asset.asset_number }} - 资产详情{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <div class="d-flex align-items-center">
+        <a href="{% url 'asset_list' %}" class="btn btn-outline-secondary btn-sm me-3">
+            <i class="bi bi-arrow-left"></i> 返回
+        </a>
+        <h4 class="mb-0"><i class="bi bi-hdd me-2"></i>{{ asset.asset_number }}</h4>
+        <span class="badge ms-3 
+            {% if asset.status == 'in_use' %}bg-success
+            {% elif asset.status == 'idle' %}bg-warning text-dark
+            {% elif asset.status == 'maintenance' %}bg-info
+            {% else %}bg-danger{% endif %}">
+            {{ asset.get_status_display }}
+        </span>
+    </div>
+    <div>
+        <a href="{% url 'asset_update' asset.pk %}" class="btn btn-warning btn-sm">
+            <i class="bi bi-pencil me-1"></i>编辑
+        </a>
+        <a href="{% url 'asset_delete' asset.pk %}" class="btn btn-danger btn-sm">
+            <i class="bi bi-trash me-1"></i>删除
+        </a>
+    </div>
+</div>
+
+<div class="row g-3">
+    <!-- 基本信息 -->
+    <div class="col-md-6">
+        <div class="card card-dark">
+            <div class="card-header"><i class="bi bi-info-circle me-2"></i>基本信息</div>
+            <div class="card-body">
+                <table class="table table-dark table-borderless mb-0 detail-table">
+                    <tr><td class="text-muted" width="120">设备名称</td><td>{{ asset.name }}</td></tr>
+                    <tr><td class="text-muted">资产编号</td><td><code>{{ asset.asset_number }}</code></td></tr>
+                    <tr><td class="text-muted">设备分类</td><td><span class="badge bg-secondary">{{ asset.category.name }}</span></td></tr>
+                    <tr><td class="text-muted">品牌</td><td>{{ asset.brand|default:"-" }}</td></tr>
+                    <tr><td class="text-muted">型号</td><td>{{ asset.model|default:"-" }}</td></tr>
+                    <tr><td class="text-muted">序列号</td><td><code>{{ asset.serial_number|default:"-" }}</code></td></tr>
+                </table>
+            </div>
+        </div>
+    </div>
+
+    <!-- 位置信息 -->
+    <div class="col-md-6">
+        <div class="card card-dark">
+            <div class="card-header"><i class="bi bi-geo-alt me-2"></i>位置与网络</div>
+            <div class="card-body">
+                <table class="table table-dark table-borderless mb-0 detail-table">
+                    <tr><td class="text-muted" width="120">设备位置</td><td>{{ asset.location|default:"-" }}</td></tr>
+                    <tr><td class="text-muted">机柜</td><td>{{ asset.cabinet|default:"-" }}</td></tr>
+                    <tr><td class="text-muted">机柜位置</td><td>{{ asset.cabinet_position|default:"-" }}</td></tr>
+                    <tr><td class="text-muted">BMC地址</td><td><code>{{ asset.bmc_address|default:"-" }}</code></td></tr>
+                    <tr><td class="text-muted">IP地址</td><td><code>{{ asset.ip_address|default:"-" }}</code></td></tr>
+                    <tr><td class="text-muted">负责人</td><td>{{ asset.responsible_person|default:"-" }}</td></tr>
+                    <tr><td class="text-muted">状态</td>
+                        <td><span class="badge 
+                            {% if asset.status == 'in_use' %}bg-success
+                            {% elif asset.status == 'idle' %}bg-warning text-dark
+                            {% elif asset.status == 'maintenance' %}bg-info
+                            {% else %}bg-danger{% endif %}">
+                            {{ asset.get_status_display }}
+                        </span></td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+    </div>
+
+    <!-- 采购与质保 -->
+    <div class="col-md-6">
+        <div class="card card-dark">
+            <div class="card-header"><i class="bi bi-receipt me-2"></i>采购与质保</div>
+            <div class="card-body">
+                <table class="table table-dark table-borderless mb-0 detail-table">
+                    <tr><td class="text-muted" width="120">采购日期</td><td>{{ asset.purchase_date|default:"-" }}</td></tr>
+                    <tr>
+                        <td class="text-muted">质保到期</td>
+                        <td>
+                            {{ asset.warranty_expire|default:"-" }}
+                            {% if asset.warranty_expire %}
+                                {% if asset.is_expired %}
+                                    <span class="badge bg-danger ms-2">已过保</span>
+                                {% elif asset.is_expiring_soon %}
+                                    <span class="badge bg-warning text-dark ms-2">即将过保</span>
+                                {% endif %}
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr><td class="text-muted">供应商</td><td>{{ asset.supplier|default:"-" }}</td></tr>
+                </table>
+            </div>
+        </div>
+    </div>
+
+    <!-- 备注 -->
+    <div class="col-md-6">
+        <div class="card card-dark">
+            <div class="card-header"><i class="bi bi-sticky me-2"></i>备注</div>
+            <div class="card-body">
+                <p class="mb-0">{{ asset.remark|default:"暂无备注"|linebreaksbr }}</p>
+                <hr class="border-secondary">
+                <small class="text-muted">
+                    创建人:{{ asset.created_by|default:"-" }} | 
+                    创建时间:{{ asset.created_at|date:"Y-m-d H:i" }} | 
+                    更新时间:{{ asset.updated_at|date:"Y-m-d H:i" }}
+                </small>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- 变更记录 -->
+<div class="card card-dark mt-3">
+    <div class="card-header"><i class="bi bi-clock-history me-2"></i>变更记录</div>
+    <div class="card-body p-0">
+        <div class="table-responsive">
+            <table class="table table-dark table-hover mb-0">
+                <thead>
+                    <tr><th>时间</th><th>操作</th><th>字段</th><th>旧值</th><th>新值</th><th>操作人</th></tr>
+                </thead>
+                <tbody>
+                    {% for log in change_logs %}
+                    <tr>
+                        <td>{{ log.created_at|date:"Y-m-d H:i" }}</td>
+                        <td><span class="badge 
+                            {% if log.action == 'create' %}bg-success
+                            {% elif log.action == 'update' %}bg-primary
+                            {% elif log.action == 'delete' %}bg-danger
+                            {% elif log.action == 'import' %}bg-info
+                            {% else %}bg-secondary{% endif %}">
+                            {{ log.get_action_display }}</span></td>
+                        <td>{{ log.field_name|default:"-" }}</td>
+                        <td><code>{{ log.old_value|default:"-" }}</code></td>
+                        <td><code>{{ log.new_value|default:"-" }}</code></td>
+                        <td>{{ log.operator|default:"-" }}</td>
+                    </tr>
+                    {% empty %}
+                    <tr><td colspan="6" class="text-center text-light opacity-75 py-3">暂无变更记录</td></tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 140 - 0
templates/assetapp/asset_form.html

@@ -0,0 +1,140 @@
+{% extends "assetapp/base.html" %}
+
+{% block title %}{% if action == 'create' %}新增资产{% else %}编辑资产{% endif %}{% endblock %}
+
+{% block content %}
+<div class="d-flex align-items-center mb-4">
+    <a href="{% url 'asset_list' %}" class="btn btn-outline-secondary btn-sm me-3">
+        <i class="bi bi-arrow-left"></i> 返回
+    </a>
+    <h4 class="mb-0">
+        <i class="bi bi-{% if action == 'create' %}plus-circle{% else %}pencil{% endif %} me-2"></i>
+        {% if action == 'create' %}新增资产{% else %}编辑资产 - {{ asset.asset_number }}{% endif %}
+    </h4>
+</div>
+
+<form method="post" novalidate>
+    {% csrf_token %}
+    
+    <div class="row g-3">
+        <!-- 基本信息 -->
+        <div class="col-md-6">
+            <div class="card card-dark">
+                <div class="card-header"><i class="bi bi-info-circle me-2"></i>基本信息</div>
+                <div class="card-body">
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.asset_number.label }} <span class="text-danger">*</span></label>
+                        {{ form.asset_number }}
+                        {% if form.asset_number.errors %}<div class="text-danger small mt-1">{{ form.asset_number.errors.0 }}</div>{% endif %}
+                    </div>
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.name.label }} <span class="text-danger">*</span></label>
+                        {{ form.name }}
+                        {% if form.name.errors %}<div class="text-danger small mt-1">{{ form.name.errors.0 }}</div>{% endif %}
+                    </div>
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.category.label }} <span class="text-danger">*</span></label>
+                        {{ form.category }}
+                    </div>
+                    <div class="row">
+                        <div class="col-md-6 mb-3">
+                            <label class="form-label text-muted">{{ form.brand.label }}</label>
+                            {{ form.brand }}
+                        </div>
+                        <div class="col-md-6 mb-3">
+                            <label class="form-label text-muted">{{ form.model.label }}</label>
+                            {{ form.model }}
+                        </div>
+                    </div>
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.serial_number.label }}</label>
+                        {{ form.serial_number }}
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- 位置与网络 -->
+        <div class="col-md-6">
+            <div class="card card-dark">
+                <div class="card-header"><i class="bi bi-geo-alt me-2"></i>位置与网络</div>
+                <div class="card-body">
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.location.label }}</label>
+                        {{ form.location }}
+                    </div>
+                    <div class="row">
+                        <div class="col-md-6 mb-3">
+                            <label class="form-label text-muted">{{ form.cabinet.label }}</label>
+                            {{ form.cabinet }}
+                        </div>
+                        <div class="col-md-6 mb-3">
+                            <label class="form-label text-muted">{{ form.cabinet_position.label }}</label>
+                            {{ form.cabinet_position }}
+                        </div>
+                    </div>
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.bmc_address.label }}</label>
+                        {{ form.bmc_address }}
+                    </div>
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.ip_address.label }}</label>
+                        {{ form.ip_address }}
+                    </div>
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.status.label }}</label>
+                        {{ form.status }}
+                    </div>
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.responsible_person.label }}</label>
+                        {{ form.responsible_person }}
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- 采购与质保 -->
+        <div class="col-md-6">
+            <div class="card card-dark">
+                <div class="card-header"><i class="bi bi-receipt me-2"></i>采购与质保</div>
+                <div class="card-body">
+                    <div class="row">
+                        <div class="col-md-6 mb-3">
+                            <label class="form-label text-muted">{{ form.purchase_date.label }}</label>
+                            {{ form.purchase_date }}
+                        </div>
+                        <div class="col-md-6 mb-3">
+                            <label class="form-label text-muted">{{ form.warranty_expire.label }}</label>
+                            {{ form.warranty_expire }}
+                        </div>
+                    </div>
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.supplier.label }}</label>
+                        {{ form.supplier }}
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- 备注 -->
+        <div class="col-md-6">
+            <div class="card card-dark">
+                <div class="card-header"><i class="bi bi-sticky me-2"></i>备注</div>
+                <div class="card-body">
+                    <div class="mb-3">
+                        <label class="form-label text-muted">{{ form.remark.label }}</label>
+                        {{ form.remark }}
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="mt-3">
+        <button type="submit" class="btn btn-primary">
+            <i class="bi bi-check-circle me-1"></i>{% if action == 'create' %}创建{% else %}保存{% endif %}
+        </button>
+        <a href="{% url 'asset_list' %}" class="btn btn-outline-secondary ms-2">取消</a>
+    </div>
+</form>
+{% endblock %}

+ 71 - 0
templates/assetapp/asset_import.html

@@ -0,0 +1,71 @@
+{% extends "assetapp/base.html" %}
+
+{% block title %}导入Excel{% endblock %}
+
+{% block content %}
+<div class="d-flex align-items-center mb-4">
+    <a href="{% url 'asset_list' %}" class="btn btn-outline-secondary btn-sm me-3">
+        <i class="bi bi-arrow-left"></i> 返回
+    </a>
+    <h4 class="mb-0"><i class="bi bi-upload me-2"></i>导入Excel</h4>
+</div>
+
+<div class="row justify-content-center">
+    <div class="col-md-8">
+        <div class="card card-dark">
+            <div class="card-header"><i class="bi bi-file-earmark-spreadsheet me-2"></i>上传文件</div>
+            <div class="card-body">
+                <div class="alert alert-info">
+                    <i class="bi bi-info-circle me-2"></i>
+                    <strong>使用说明:</strong>
+                    <ol class="mb-0 mt-2">
+                        <li>先 <a href="{% url 'download_template' %}" class="text-info">下载导入模板</a></li>
+                        <li>按模板格式填写资产数据</li>
+                        <li>上传填写好的Excel文件(.xlsx)</li>
+                        <li>资产编号重复的记录会被自动跳过</li>
+                        <li>分类不存在时会自动创建</li>
+                    </ol>
+                </div>
+                
+                <form method="post" enctype="multipart/form-data">
+                    {% csrf_token %}
+                    <div class="mb-3">
+                        <label class="form-label">选择Excel文件</label>
+                        <div class="file-upload-wrapper">
+                            <input type="file" name="excel_file" id="id_excel_file" accept=".xlsx,.xls" class="d-none" onchange="updateFileName(this)">
+                            <button type="button" class="btn btn-outline-info" onclick="document.getElementById('id_excel_file').click()">
+                                <i class="bi bi-folder2-open me-1"></i>浏览文件
+                            </button>
+                            <span id="file-name" class="ms-3 file-name-text">未选择文件</span>
+                        </div>
+                        {% if form.excel_file.errors %}
+                        <div class="text-danger small mt-1">{{ form.excel_file.errors.0 }}</div>
+                        {% endif %}
+                    </div>
+                    <div class="d-flex">
+                        <button type="submit" class="btn btn-primary me-2">
+                            <i class="bi bi-upload me-1"></i>开始导入
+                        </button>
+                        <a href="{% url 'download_template' %}" class="btn btn-outline-info">
+                            <i class="bi bi-file-earmark-arrow-down me-1"></i>下载模板
+                        </a>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+function updateFileName(input) {
+    var nameSpan = document.getElementById('file-name');
+    if (input.files && input.files.length > 0) {
+        nameSpan.textContent = input.files[0].name;
+        nameSpan.style.color = '#4A9EFF';
+    } else {
+        nameSpan.textContent = '未选择文件';
+        nameSpan.style.color = '#8b949e';
+    }
+}
+</script>
+{% endblock %}

+ 154 - 0
templates/assetapp/asset_list.html

@@ -0,0 +1,154 @@
+{% extends "assetapp/base.html" %}
+
+{% block title %}资产列表 - 希姆计算资产管理{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <h4 class="mb-0"><i class="bi bi-list-ul me-2"></i>资产列表</h4>
+    <div>
+        <a href="{% url 'asset_export' %}" class="btn btn-outline-success btn-sm me-2">
+            <i class="bi bi-download me-1"></i>导出Excel
+        </a>
+        <a href="{% url 'asset_import' %}" class="btn btn-outline-info btn-sm me-2">
+            <i class="bi bi-upload me-1"></i>导入Excel
+        </a>
+        <a href="{% url 'asset_create' %}" class="btn btn-primary btn-sm">
+            <i class="bi bi-plus-circle me-1"></i>新增资产
+        </a>
+    </div>
+</div>
+
+<!-- 搜索和筛选 -->
+<div class="card card-dark mb-3">
+    <div class="card-body">
+        <form method="get" class="row g-2 align-items-end">
+            <div class="col-md-4">
+                <label class="form-label text-muted small">搜索</label>
+                <input type="text" name="search" class="form-control form-control-sm" 
+                       placeholder="编号/名称/序列号/IP/品牌/型号/位置/负责人" value="{{ search }}">
+            </div>
+            <div class="col-md-2">
+                <label class="form-label text-muted small">分类</label>
+                <select name="category" class="form-select form-select-sm">
+                    <option value="">全部分类</option>
+                    {% for cat in categories %}
+                    <option value="{{ cat.id }}" {% if current_category == cat.id|stringformat:"s" %}selected{% endif %}>{{ cat.name }}</option>
+                    {% endfor %}
+                </select>
+            </div>
+            <div class="col-md-2">
+                <label class="form-label text-muted small">状态</label>
+                <select name="status" class="form-select form-select-sm">
+                    <option value="">全部状态</option>
+                    {% for key, label in status_map.items %}
+                    <option value="{{ key }}" {% if current_status == key %}selected{% endif %}>{{ label }}</option>
+                    {% endfor %}
+                </select>
+            </div>
+            <div class="col-md-2">
+                <label class="form-label text-muted small">位置</label>
+                <select name="location" class="form-select form-select-sm">
+                    <option value="">全部位置</option>
+                    {% for loc in locations %}
+                    <option value="{{ loc }}" {% if current_location == loc %}selected{% endif %}>{{ loc }}</option>
+                    {% endfor %}
+                </select>
+            </div>
+            <div class="col-md-2">
+                <button type="submit" class="btn btn-primary btn-sm me-1"><i class="bi bi-search"></i> 搜索</button>
+                <a href="{% url 'asset_list' %}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-counterclockwise"></i></a>
+            </div>
+        </form>
+    </div>
+</div>
+
+<!-- 资产表格 -->
+<div class="card card-dark">
+    <div class="card-body p-0">
+        <div class="table-responsive">
+            <table class="table table-dark table-hover table-striped mb-0">
+                <thead>
+                    <tr>
+                        <th>资产编号</th>
+                        <th>设备名称</th>
+                        <th>分类</th>
+                        <th>品牌/型号</th>
+                        <th>位置</th>
+                        <th>BMC地址</th>
+                        <th>IP地址</th>
+                        <th>负责人</th>
+                        <th>状态</th>
+                        <th>操作</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for asset in page_obj %}
+                    <tr>
+                        <td><a href="{% url 'asset_detail' asset.pk %}" class="text-info">{{ asset.asset_number }}</a></td>
+                        <td>{{ asset.name }}</td>
+                        <td><span class="badge bg-secondary">{{ asset.category.name }}</span></td>
+                        <td>{{ asset.brand }} {% if asset.model %}{{ asset.model }}{% endif %}</td>
+                        <td>
+                            {{ asset.location }}
+                            {% if asset.cabinet %}<small class="text-muted"> {{ asset.cabinet }}{% if asset.cabinet_position %}/{{ asset.cabinet_position }}{% endif %}</small>{% endif %}
+                        </td>
+                        <td><code>{{ asset.bmc_address|default:"-" }}</code></td>
+                        <td><code>{{ asset.ip_address|default:"-" }}</code></td>
+                        <td>{{ asset.responsible_person|default:"-" }}</td>
+                        <td>
+                            <span class="badge 
+                                {% if asset.status == 'in_use' %}bg-success
+                                {% elif asset.status == 'idle' %}bg-warning text-dark
+                                {% elif asset.status == 'maintenance' %}bg-info
+                                {% else %}bg-danger{% endif %}">
+                                {{ asset.get_status_display }}
+                            </span>
+                        </td>
+                        <td>
+                            <a href="{% url 'asset_detail' asset.pk %}" class="btn btn-outline-info btn-xs" title="详情">
+                                <i class="bi bi-eye"></i>
+                            </a>
+                            <a href="{% url 'asset_update' asset.pk %}" class="btn btn-outline-warning btn-xs" title="编辑">
+                                <i class="bi bi-pencil"></i>
+                            </a>
+                            <a href="{% url 'asset_delete' asset.pk %}" class="btn btn-outline-danger btn-xs" title="删除">
+                                <i class="bi bi-trash"></i>
+                            </a>
+                        </td>
+                    </tr>
+                    {% empty %}
+                    <tr><td colspan="10" class="text-center text-light opacity-75 py-4">暂无资产数据</td></tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+
+<!-- 分页 -->
+{% if page_obj.has_other_pages %}
+<nav class="mt-3">
+    <ul class="pagination pagination-sm justify-content-center">
+        {% if page_obj.has_previous %}
+        <li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}&search={{ search }}&category={{ current_category }}&status={{ current_status }}&location={{ current_location }}"><i class="bi bi-chevron-left"></i></a></li>
+        {% endif %}
+        
+        {% for num in page_obj.paginator.page_range %}
+            {% if page_obj.number == num %}
+            <li class="page-item active"><span class="page-link">{{ num }}</span></li>
+            {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
+            <li class="page-item"><a class="page-link" href="?page={{ num }}&search={{ search }}&category={{ current_category }}&status={{ current_status }}&location={{ current_location }}">{{ num }}</a></li>
+            {% endif %}
+        {% endfor %}
+        
+        {% if page_obj.has_next %}
+        <li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}&search={{ search }}&category={{ current_category }}&status={{ current_status }}&location={{ current_location }}"><i class="bi bi-chevron-right"></i></a></li>
+        {% endif %}
+    </ul>
+</nav>
+{% endif %}
+
+<div class="text-light opacity-75 small mt-2">
+    共 {{ page_obj.paginator.count }} 条记录,第 {{ page_obj.number }}/{{ page_obj.paginator.num_pages }} 页
+</div>
+{% endblock %}

+ 89 - 0
templates/assetapp/base.html

@@ -0,0 +1,89 @@
+{% load static %}
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{% block title %}希姆计算资产管理{% endblock %}</title>
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
+    <link href="{% static 'css/style.css' %}" rel="stylesheet">
+    {% block extra_css %}{% endblock %}
+</head>
+<body>
+    {% if user.is_authenticated %}
+    <nav class="navbar navbar-expand-lg navbar-dark bg-navy">
+        <div class="container-fluid">
+            <a class="navbar-brand d-flex align-items-center" href="{% url 'dashboard' %}">
+                <i class="bi bi-hdd-rack me-2"></i>
+                <strong>希姆计算资产管理</strong>
+            </a>
+            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
+                <span class="navbar-toggler-icon"></span>
+            </button>
+            <div class="collapse navbar-collapse" id="navbarNav">
+                <ul class="navbar-nav me-auto">
+                    <li class="nav-item">
+                        <a class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}" href="{% url 'dashboard' %}">
+                            <i class="bi bi-speedometer2 me-1"></i>仪表盘
+                        </a>
+                    </li>
+                    <li class="nav-item">
+                        <a class="nav-link {% if 'assets' in request.path and 'import' not in request.path %}active{% endif %}" href="{% url 'asset_list' %}">
+                            <i class="bi bi-list-ul me-1"></i>资产列表
+                        </a>
+                    </li>
+                    <li class="nav-item">
+                        <a class="nav-link {% if request.resolver_match.url_name == 'category_list' %}active{% endif %}" href="{% url 'category_list' %}">
+                            <i class="bi bi-tags me-1"></i>分类管理
+                        </a>
+                    </li>
+                    <li class="nav-item">
+                        <a class="nav-link {% if request.resolver_match.url_name == 'change_log_list' %}active{% endif %}" href="{% url 'change_log_list' %}">
+                            <i class="bi bi-clock-history me-1"></i>变更记录
+                        </a>
+                    </li>
+                    <li class="nav-item dropdown">
+                        <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
+                            <i class="bi bi-file-earmark-spreadsheet me-1"></i>导入导出
+                        </a>
+                        <ul class="dropdown-menu dropdown-menu-dark">
+                            <li><a class="dropdown-item" href="{% url 'asset_import' %}"><i class="bi bi-upload me-2"></i>导入Excel</a></li>
+                            <li><a class="dropdown-item" href="{% url 'asset_export' %}"><i class="bi bi-download me-2"></i>导出Excel</a></li>
+                            <li><hr class="dropdown-divider"></li>
+                            <li><a class="dropdown-item" href="{% url 'download_template' %}"><i class="bi bi-file-earmark-arrow-down me-2"></i>下载模板</a></li>
+                        </ul>
+                    </li>
+                </ul>
+                <div class="d-flex align-items-center">
+                    <span class="text-light me-3">
+                        <i class="bi bi-person-circle me-1"></i>{{ user.username }}
+                    </span>
+                    <a href="{% url 'logout' %}" class="btn btn-outline-light btn-sm">
+                        <i class="bi bi-box-arrow-right me-1"></i>退出
+                    </a>
+                </div>
+            </div>
+        </div>
+    </nav>
+    {% endif %}
+
+    {% if messages %}
+    <div class="container-fluid mt-3 px-4">
+        {% for message in messages %}
+        <div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
+            {{ message }}
+            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+        </div>
+        {% endfor %}
+    </div>
+    {% endif %}
+
+    <main class="container-fluid p-4">
+        {% block content %}{% endblock %}
+    </main>
+
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+    {% block extra_js %}{% endblock %}
+</body>
+</html>

+ 26 - 0
templates/assetapp/category_confirm_delete.html

@@ -0,0 +1,26 @@
+{% extends "assetapp/base.html" %}
+
+{% block title %}删除分类{% endblock %}
+
+{% block content %}
+<div class="row justify-content-center mt-5">
+    <div class="col-md-6">
+        <div class="card card-dark">
+            <div class="card-header bg-danger">
+                <i class="bi bi-exclamation-triangle me-2"></i>删除分类
+            </div>
+            <div class="card-body text-center py-4">
+                <h5>确定要删除分类 "{{ category.name }}" 吗?</h5>
+                <p class="text-muted">{{ category.description }}</p>
+                <form method="post" class="d-inline">
+                    {% csrf_token %}
+                    <button type="submit" class="btn btn-danger me-2">
+                        <i class="bi bi-trash me-1"></i>确认删除
+                    </button>
+                </form>
+                <a href="{% url 'category_list' %}" class="btn btn-outline-secondary">取消</a>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 40 - 0
templates/assetapp/category_form.html

@@ -0,0 +1,40 @@
+{% extends "assetapp/base.html" %}
+
+{% block title %}{% if action == 'create' %}新增分类{% else %}编辑分类{% endif %}{% endblock %}
+
+{% block content %}
+<div class="d-flex align-items-center mb-4">
+    <a href="{% url 'category_list' %}" class="btn btn-outline-secondary btn-sm me-3">
+        <i class="bi bi-arrow-left"></i> 返回
+    </a>
+    <h4 class="mb-0">
+        <i class="bi bi-{% if action == 'create' %}plus-circle{% else %}pencil{% endif %} me-2"></i>
+        {% if action == 'create' %}新增分类{% else %}编辑分类 - {{ category.name }}{% endif %}
+    </h4>
+</div>
+
+<div class="row justify-content-center">
+    <div class="col-md-6">
+        <div class="card card-dark">
+            <div class="card-body">
+                <form method="post">
+                    {% csrf_token %}
+                    <div class="mb-3">
+                        <label class="form-label text-muted">分类名称 <span class="text-danger">*</span></label>
+                        {{ form.name }}
+                        {% if form.name.errors %}<div class="text-danger small mt-1">{{ form.name.errors.0 }}</div>{% endif %}
+                    </div>
+                    <div class="mb-3">
+                        <label class="form-label text-muted">描述</label>
+                        {{ form.description }}
+                    </div>
+                    <button type="submit" class="btn btn-primary">
+                        <i class="bi bi-check-circle me-1"></i>{% if action == 'create' %}创建{% else %}保存{% endif %}
+                    </button>
+                    <a href="{% url 'category_list' %}" class="btn btn-outline-secondary ms-2">取消</a>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 52 - 0
templates/assetapp/category_list.html

@@ -0,0 +1,52 @@
+{% extends "assetapp/base.html" %}
+
+{% block title %}分类管理{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <h4 class="mb-0"><i class="bi bi-tags me-2"></i>分类管理</h4>
+    <a href="{% url 'category_create' %}" class="btn btn-primary btn-sm">
+        <i class="bi bi-plus-circle me-1"></i>新增分类
+    </a>
+</div>
+
+<div class="card card-dark">
+    <div class="card-body p-0">
+        <div class="table-responsive">
+            <table class="table table-dark table-hover mb-0">
+                <thead>
+                    <tr>
+                        <th>ID</th>
+                        <th>分类名称</th>
+                        <th>描述</th>
+                        <th>资产数量</th>
+                        <th>创建时间</th>
+                        <th>操作</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for category in categories %}
+                    <tr>
+                        <td>{{ category.id }}</td>
+                        <td><strong>{{ category.name }}</strong></td>
+                        <td>{{ category.description|default:"-" }}</td>
+                        <td><span class="badge bg-primary">{{ category.asset_count }}</span></td>
+                        <td>{{ category.created_at|date:"Y-m-d H:i" }}</td>
+                        <td>
+                            <a href="{% url 'category_update' category.pk %}" class="btn btn-outline-warning btn-xs" title="编辑">
+                                <i class="bi bi-pencil"></i>
+                            </a>
+                            <a href="{% url 'category_delete' category.pk %}" class="btn btn-outline-danger btn-xs" title="删除">
+                                <i class="bi bi-trash"></i>
+                            </a>
+                        </td>
+                    </tr>
+                    {% empty %}
+                    <tr><td colspan="6" class="text-center text-muted py-4">暂无分类</td></tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 110 - 0
templates/assetapp/changelog.html

@@ -0,0 +1,110 @@
+{% extends "assetapp/base.html" %}
+
+{% block title %}变更记录{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <h4 class="mb-0"><i class="bi bi-clock-history me-2"></i>变更记录</h4>
+</div>
+
+<!-- 筛选 -->
+<div class="card card-dark mb-3">
+    <div class="card-body">
+        <form method="get" class="row g-2 align-items-end">
+            <div class="col-md-4">
+                <label class="form-label text-muted small">资产编号</label>
+                <input type="text" name="asset_number" class="form-control form-control-sm" 
+                       placeholder="输入资产编号搜索" value="{{ asset_number }}">
+            </div>
+            <div class="col-md-3">
+                <label class="form-label text-muted small">操作类型</label>
+                <select name="action" class="form-select form-select-sm">
+                    <option value="">全部</option>
+                    <option value="create" {% if current_action == 'create' %}selected{% endif %}>创建</option>
+                    <option value="update" {% if current_action == 'update' %}selected{% endif %}>更新</option>
+                    <option value="delete" {% if current_action == 'delete' %}selected{% endif %}>删除</option>
+                    <option value="import" {% if current_action == 'import' %}selected{% endif %}>导入</option>
+                    <option value="export" {% if current_action == 'export' %}selected{% endif %}>导出</option>
+                    <option value="status_change" {% if current_action == 'status_change' %}selected{% endif %}>状态变更</option>
+                </select>
+            </div>
+            <div class="col-md-2">
+                <button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-search"></i> 搜索</button>
+                <a href="{% url 'change_log_list' %}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-counterclockwise"></i></a>
+            </div>
+        </form>
+    </div>
+</div>
+
+<!-- 记录表 -->
+<div class="card card-dark">
+    <div class="card-body p-0">
+        <div class="table-responsive">
+            <table class="table table-dark table-hover mb-0">
+                <thead>
+                    <tr>
+                        <th>时间</th>
+                        <th>资产编号</th>
+                        <th>操作</th>
+                        <th>变更字段</th>
+                        <th>旧值</th>
+                        <th>新值</th>
+                        <th>描述</th>
+                        <th>操作人</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for log in page_obj %}
+                    <tr>
+                        <td>{{ log.created_at|date:"Y-m-d H:i:s" }}</td>
+                        <td>
+                            {% if log.asset %}
+                            <a href="{% url 'asset_detail' log.asset.pk %}" class="text-info">{{ log.asset_number }}</a>
+                            {% else %}
+                            {{ log.asset_number }}
+                            {% endif %}
+                        </td>
+                        <td><span class="badge 
+                            {% if log.action == 'create' %}bg-success
+                            {% elif log.action == 'update' %}bg-primary
+                            {% elif log.action == 'delete' %}bg-danger
+                            {% elif log.action == 'import' %}bg-info
+                            {% elif log.action == 'export' %}bg-secondary
+                            {% else %}bg-warning{% endif %}">
+                            {{ log.get_action_display }}</span></td>
+                        <td>{{ log.field_name|default:"-" }}</td>
+                        <td><code class="small">{{ log.old_value|default:"-"|truncatechars:30 }}</code></td>
+                        <td><code class="small">{{ log.new_value|default:"-"|truncatechars:30 }}</code></td>
+                        <td>{{ log.description|default:"-" }}</td>
+                        <td>{{ log.operator|default:"-" }}</td>
+                    </tr>
+                    {% empty %}
+                    <tr><td colspan="8" class="text-center text-muted py-4">暂无变更记录</td></tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+
+<!-- 分页 -->
+{% if page_obj.has_other_pages %}
+<nav class="mt-3">
+    <ul class="pagination pagination-sm justify-content-center">
+        {% if page_obj.has_previous %}
+        <li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}&asset_number={{ asset_number }}&action={{ current_action }}"><i class="bi bi-chevron-left"></i></a></li>
+        {% endif %}
+        {% for num in page_obj.paginator.page_range %}
+            {% if page_obj.number == num %}
+            <li class="page-item active"><span class="page-link">{{ num }}</span></li>
+            {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
+            <li class="page-item"><a class="page-link" href="?page={{ num }}&asset_number={{ asset_number }}&action={{ current_action }}">{{ num }}</a></li>
+            {% endif %}
+        {% endfor %}
+        {% if page_obj.has_next %}
+        <li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}&asset_number={{ asset_number }}&action={{ current_action }}"><i class="bi bi-chevron-right"></i></a></li>
+        {% endif %}
+    </ul>
+</nav>
+{% endif %}
+{% endblock %}

+ 151 - 0
templates/assetapp/dashboard.html

@@ -0,0 +1,151 @@
+{% extends "assetapp/base.html" %}
+{% load asset_tags %}
+
+{% block title %}仪表盘 - 希姆计算资产管理{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <h4 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>仪表盘</h4>
+    <a href="{% url 'asset_create' %}" class="btn btn-primary">
+        <i class="bi bi-plus-circle me-1"></i>新增资产
+    </a>
+</div>
+
+<!-- 统计卡片 -->
+<div class="row g-3 mb-4">
+    <div class="col-md-3">
+        <div class="stat-card stat-total">
+            <div class="stat-icon"><i class="bi bi-hdd-rack"></i></div>
+            <div class="stat-info">
+                <div class="stat-value">{{ total_assets }}</div>
+                <div class="stat-label">资产总数</div>
+            </div>
+        </div>
+    </div>
+    <div class="col-md-3">
+        <div class="stat-card stat-in-use">
+            <div class="stat-icon"><i class="bi bi-cpu"></i></div>
+            <div class="stat-info">
+                <div class="stat-value">{{ status_data.in_use|default:0 }}</div>
+                <div class="stat-label">在用</div>
+            </div>
+        </div>
+    </div>
+    <div class="col-md-3">
+        <div class="stat-card stat-warning">
+            <div class="stat-icon"><i class="bi bi-exclamation-triangle"></i></div>
+            <div class="stat-info">
+                <div class="stat-value">{{ expiring_soon }}</div>
+                <div class="stat-label">即将过保(30天)</div>
+            </div>
+        </div>
+    </div>
+    <div class="col-md-3">
+        <div class="stat-card stat-danger">
+            <div class="stat-icon"><i class="bi bi-shield-exclamation"></i></div>
+            <div class="stat-info">
+                <div class="stat-value">{{ expired }}</div>
+                <div class="stat-label">已过保</div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="row g-3">
+    <!-- 分类统计 -->
+    <div class="col-md-6">
+        <div class="card card-dark">
+            <div class="card-header">
+                <i class="bi bi-pie-chart me-2"></i>分类统计
+            </div>
+            <div class="card-body">
+                {% if category_stats %}
+                {% for item in category_stats %}
+                <div class="d-flex justify-content-between align-items-center py-2 border-bottom border-secondary">
+                    <span class="text-light">{{ item.category__name }}</span>
+                    <div class="d-flex align-items-center">
+                        <div class="progress me-2" style="width: 120px; height: 8px;">
+                            {% widthratio item.count total_assets 100 as pct %}
+                            <div class="progress-bar bg-primary" style="width: {{ pct }}%"></div>
+                        </div>
+                        <span class="badge bg-primary">{{ item.count }}</span>
+                    </div>
+                </div>
+                {% endfor %}
+                {% else %}
+                <p class="text-muted text-center my-4">暂无数据</p>
+                {% endif %}
+            </div>
+        </div>
+    </div>
+
+    <!-- 状态分布 -->
+    <div class="col-md-6">
+        <div class="card card-dark">
+            <div class="card-header">
+                <i class="bi bi-bar-chart me-2"></i>状态分布
+            </div>
+            <div class="card-body">
+                {% for key, label in status_map.items %}
+                <div class="d-flex justify-content-between align-items-center py-2 border-bottom border-secondary">
+                    <span class="text-light">{{ label }}</span>
+                    <span class="badge 
+                        {% if key == 'in_use' %}bg-success
+                        {% elif key == 'idle' %}bg-warning text-dark
+                        {% elif key == 'maintenance' %}bg-info
+                        {% else %}bg-danger{% endif %}">
+                        {{ status_data|get_item:key|default:0 }}
+                    </span>
+                </div>
+                {% endfor %}
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- 最近变更 -->
+<div class="card card-dark mt-3">
+    <div class="card-header d-flex justify-content-between align-items-center">
+        <span><i class="bi bi-clock-history me-2"></i>最近变更</span>
+        <a href="{% url 'change_log_list' %}" class="btn btn-sm btn-outline-light">查看全部</a>
+    </div>
+    <div class="card-body p-0">
+        <div class="table-responsive">
+            <table class="table table-dark table-hover mb-0">
+                <thead>
+                    <tr>
+                        <th>时间</th>
+                        <th>资产编号</th>
+                        <th>操作</th>
+                        <th>描述</th>
+                        <th>操作人</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for log in recent_changes %}
+                    <tr>
+                        <td>{{ log.created_at|date:"m-d H:i" }}</td>
+                        <td><a href="{% url 'asset_list' %}?search={{ log.asset_number }}" class="text-info">{{ log.asset_number }}</a></td>
+                        <td>
+                            <span class="badge 
+                                {% if log.action == 'create' %}bg-success
+                                {% elif log.action == 'update' %}bg-primary
+                                {% elif log.action == 'delete' %}bg-danger
+                                {% elif log.action == 'import' %}bg-info
+                                {% elif log.action == 'export' %}bg-secondary
+                                {% else %}bg-warning{% endif %}">
+                                {{ log.get_action_display }}
+                            </span>
+                        </td>
+                        <td>{{ log.description|default:"-" }}</td>
+                        <td>{{ log.operator|default:"-" }}</td>
+                    </tr>
+                    {% empty %}
+                    <tr><td colspan="5" class="text-center text-light opacity-75 py-3">暂无变更记录</td></tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 54 - 0
templates/assetapp/login.html

@@ -0,0 +1,54 @@
+{% load static %}
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>登录 - 希姆计算资产管理</title>
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
+    <link href="{% static 'css/style.css' %}" rel="stylesheet">
+</head>
+<body class="login-body">
+    <div class="login-container">
+        <div class="login-card">
+            <div class="login-header">
+                <i class="bi bi-hdd-rack" style="font-size: 3rem;"></i>
+                <h3 class="mt-3 mb-1">希姆计算资产管理</h3>
+                <p class="text-muted mb-0">Hardware Asset Management</p>
+            </div>
+            <div class="login-body">
+                {% if messages %}
+                    {% for message in messages %}
+                    <div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
+                        {{ message }}
+                        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+                    </div>
+                    {% endfor %}
+                {% endif %}
+                <form method="post">
+                    {% csrf_token %}
+                    <div class="mb-3">
+                        <label class="form-label text-muted">用户名</label>
+                        <div class="input-group">
+                            <span class="input-group-text"><i class="bi bi-person"></i></span>
+                            <input type="text" name="username" class="form-control" placeholder="请输入用户名" required autofocus>
+                        </div>
+                    </div>
+                    <div class="mb-4">
+                        <label class="form-label text-muted">密码</label>
+                        <div class="input-group">
+                            <span class="input-group-text"><i class="bi bi-lock"></i></span>
+                            <input type="password" name="password" class="form-control" placeholder="请输入密码" required>
+                        </div>
+                    </div>
+                    <button type="submit" class="btn btn-primary w-100 btn-login">
+                        <i class="bi bi-box-arrow-in-right me-2"></i>登 录
+                    </button>
+                </form>
+            </div>
+        </div>
+    </div>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+</body>
+</html>