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

功能:
- Django + MySQL + 深色主题
- 资产增删改查(含资产编号、BMC地址、设备位置、备注)
- Excel导入导出(分类自动创建)
- 设备分类管理
- 资产变更记录追踪
- 质保到期提醒
- 用户认证系统
- Docker部署支持
This commit is contained in:
cnbugs
2026-04-25 08:04:51 +08:00
commit a40a0137cf
41 changed files with 2833 additions and 0 deletions
+41
View File
@@ -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
View File
@@ -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"]
View File
+24
View File
@@ -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
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AssetappConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'assetapp'
+224
View File
@@ -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
View File
@@ -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']
View File
@@ -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
View File
@@ -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'],
},
),
]
@@ -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地址'),
),
]
View File
+116
View File
@@ -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()}'
View File
+9
View File
@@ -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
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+32
View File
@@ -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
View File
@@ -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})
View File
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
Executable
+25
View File
@@ -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
Executable
+22
View File
@@ -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
View File
@@ -0,0 +1,4 @@
Django>=4.2,<5.0
mysqlclient>=2.2
openpyxl>=3.1
gunicorn>=21.2
+453
View File
@@ -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;
}
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>