2 Commits

Author SHA1 Message Date
cnbugs a07b390995 fix: 修复资产编辑无法保存的bug
- 恢复Asset模型STATUS_CHOICES定义,状态字段使用下拉选择而非文本输入
- 修复status默认值从中文'在用'改为英文key'in_use',与数据库存储一致
- 编辑表单新增资产面值(asset_value)字段
- excel_utils适配字段类型变更(DecimalField→CharField, DateField→CharField等)
2026-05-26 16:48:01 +08:00
cnbugs 2eac2d867e fix: 修复导入ID重复覆盖逻辑、Excel列顺序对齐及UI优化 2026-04-29 13:48:29 +08:00
7 changed files with 175 additions and 134 deletions
+3 -3
View File
@@ -9,7 +9,7 @@
- **分类管理** — 自定义设备分类(服务器、网络设备、存储设备等)
- **变更追踪** — 资产信息修改自动记录变更历史
- **Excel 导入导出** — 批量导入(分类不存在自动创建)、筛选导出、模板下载
- **多维度搜索** — 按编号/名称/部门/使用人/位置搜索,按分类/状态/位置筛选
- **多维度搜索** — 按编号/名称/部门/维护人/位置搜索,按分类/状态/位置筛选
- **质保提醒** — 已过保/即将过保状态标识
- **用户认证** — 登录/退出,管理员权限控制
- **深色主题** — 护眼深色 UI,适合长时间使用
@@ -40,7 +40,7 @@
| 设备位置 | 机房/机柜/U位 |
| 负责人 | 设备负责人 |
| 使用部门 | 使用部门 |
| 使用人 | 实际使用人 |
| 维护人 | 实际使用人 |
| 采购日期 | 采购时间 |
| 质保到期 | 质保截止日期 |
| 备注 | 补充说明 |
@@ -122,7 +122,7 @@ DATABASES = {
## 📊 Excel 导入格式
| 资产编号 | 设备名称 | 分类 | 品牌 | 型号 | 资产面值 | 序列号 | 状态 | IP地址 | BMC地址 | 设备位置 | 负责人 | 使用部门 | 使用人 | 采购日期 | 质保到期 | 备注 |
| 资产编号 | 设备名称 | 分类 | 品牌 | 型号 | 资产面值 | 序列号 | 状态 | IP地址 | BMC地址 | 设备位置 | 负责人 | 使用部门 | 维护人 | 采购日期 | 质保到期 | 备注 |
|----------|---------|------|------|------|---------|--------|------|--------|---------|---------|--------|---------|--------|---------|---------|------|
- 分类不存在时自动创建
+66 -68
View File
@@ -6,9 +6,12 @@ from django.utils import timezone
from .models import Category
# Excel列定义
# Excel列定义(顺序与资产列表一致)
EXPORT_COLUMNS = [
('id', 'ID', 8),
('location', '机房', 20),
('cabinet', '机柜', 10),
('cabinet_position', '机柜位置', 10),
('asset_number', '资产编号', 18),
('name', '设备名称', 20),
('category', '设备分类', 12),
@@ -16,21 +19,18 @@ EXPORT_COLUMNS = [
('model', '型号', 20),
('asset_value', '资产面值', 12),
('serial_number', '序列号', 25),
('location', '机房', 20),
('cabinet', '机柜', 10),
('cabinet_position', '机柜位置', 10),
('bmc_address', 'BMC地址', 16),
('ip_address', 'IP地址', 16),
('gpu_type', '显卡类型', 15),
('gpu_count', '卡数', 6),
('responsible_person', '负责人', 10),
('department', '使用部门', 15),
('user', '维护人', 10),
('business_type', '业务类型', 15),
('status', '状态', 8),
('purchase_date', '采购日期', 12),
('warranty_expire', '质保到期', 12),
('supplier', '供应商', 15),
('responsible_person', '负责人', 10),
('department', '使用部门', 15),
('user', '使用人', 10),
('business_type', '业务类型', 15),
('status', '状态', 8),
('remark', '备注', 30),
]
@@ -106,12 +106,13 @@ def generate_import_template():
cell.border = THIN_BORDER
ws.column_dimensions[get_column_letter(col_idx)].width = width
# 示例数据行
# 示例数据行(顺序与EXPORT_COLUMNS一致)
example_data = [
'1', 'IT-2024-0001', '测试服务器', '服务器', 'Dell', 'PowerEdge R740',
'50000.00', 'ABC123456', '3楼机房A区', 'A01', 'U10-U15', '192.168.1.200',
'192.168.1.100', 'NVIDIA A100', '8', '2024-01-15', '2027-01-15', '戴尔科技',
'张三', '研发部', '李四', 'AI训练', '在用', '测试备注'
'1', '3楼机房A区', 'A01', 'U10-U15', 'IT-2024-0001', '测试服务器',
'服务器', 'Dell', 'PowerEdge R740', '50000.00', 'ABC123456',
'192.168.1.200', '192.168.1.100', 'NVIDIA A100', '8', '张三',
'研发部', '李四', 'AI训练', '在用', '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)
@@ -172,41 +173,26 @@ def import_assets_from_excel(ws, category_map, operator=None):
# 处理状态
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
# 处理日期 - 直接存字符串
purchase_date = data.get('purchase_date', '').strip() or ''
warranty_expire = data.get('warranty_expire', '').strip() or ''
# 处理IP
bmc_address = data.get('bmc_address') or None
ip_address = data.get('ip_address') or None
bmc_address = data.get('bmc_address', '').strip() or ''
ip_address = data.get('ip_address', '').strip() or ''
# 处理资产面值
from decimal import Decimal, InvalidOperation
asset_value = None
if data.get('asset_value'):
try:
asset_value = Decimal(str(data['asset_value']).replace(',', ''))
except (InvalidOperation, ValueError):
pass
# 资产面值 - 直接存字符串
asset_value = data.get('asset_value', '').strip() or ''
# 处理ID - 如果提供了ID且已存在,则更新该记录
import_id = data.get('id', '').strip()
asset = None
is_update = False
if import_id:
if import_id and import_id.isdigit():
try:
asset = Asset.objects.get(id=int(import_id))
id_val = int(import_id) if str(import_id).strip().isdigit() else None
if id_val:
asset = Asset.objects.get(id=id_val)
is_update = True
except (Asset.DoesNotExist, ValueError):
asset = None
@@ -227,7 +213,7 @@ def import_assets_from_excel(ws, category_map, operator=None):
asset.ip_address = ip_address
asset.gpu_type = data.get('gpu_type', '')
gpu_count_str = data.get('gpu_count', '').strip()
asset.gpu_count = int(gpu_count_str) if gpu_count_str else None
asset.gpu_count = gpu_count_str if gpu_count_str else ''
asset.purchase_date = purchase_date
asset.warranty_expire = warranty_expire
asset.supplier = data.get('supplier', '')
@@ -249,33 +235,45 @@ def import_assets_from_excel(ws, category_map, operator=None):
else:
# 创建新记录
gpu_count_str = data.get('gpu_count', '').strip()
gpu_count = int(gpu_count_str) if gpu_count_str else None
asset = Asset.objects.create(
asset_number=asset_number,
name=data.get('name', ''),
category=category,
brand=data.get('brand', ''),
model=data.get('model', ''),
asset_value=asset_value,
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,
gpu_type=data.get('gpu_type', ''),
gpu_count=gpu_count,
purchase_date=purchase_date,
warranty_expire=warranty_expire,
supplier=data.get('supplier', ''),
responsible_person=data.get('responsible_person', ''),
department=data.get('department', ''),
user=data.get('user', ''),
business_type=data.get('business_type', ''),
status=status,
remark=data.get('remark', ''),
created_by=operator,
)
gpu_count = gpu_count_str if gpu_count_str else ''
# 创建参数
create_kwargs = {
'asset_number': asset_number,
'name': data.get('name', ''),
'category': category,
'brand': data.get('brand', ''),
'model': data.get('model', ''),
'asset_value': asset_value,
'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,
'gpu_type': data.get('gpu_type', ''),
'gpu_count': gpu_count,
'purchase_date': purchase_date,
'warranty_expire': warranty_expire,
'supplier': data.get('supplier', ''),
'responsible_person': data.get('responsible_person', ''),
'department': data.get('department', ''),
'user': data.get('user', ''),
'business_type': data.get('business_type', ''),
'status': status,
'remark': data.get('remark', ''),
'created_by': operator,
}
# 如果Excel提供了ID,使用该ID创建
if import_id and import_id.isdigit():
try:
if str(import_id).strip().isdigit():
create_kwargs['id'] = int(import_id)
except ValueError:
pass
asset = Asset.objects.create(**create_kwargs)
AssetChangeLog.objects.create(
asset=asset,
+49 -40
View File
@@ -21,6 +21,41 @@ class Category(models.Model):
class Asset(models.Model):
"""硬件资产"""
# 基本信息
asset_number = models.CharField('资产编号', max_length=50, 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=200, blank=True, default='')
model = models.CharField('型号', max_length=200, blank=True, default='')
asset_value = models.CharField('资产面值', max_length=50, blank=True, default='')
serial_number = models.CharField('序列号', max_length=200, blank=True, default='', db_index=True)
# 位置信息
location = models.CharField('机房', max_length=200, blank=True, default='', help_text='楼层/房间/区域')
cabinet = models.CharField('机柜', max_length=200, blank=True, default='')
cabinet_position = models.CharField('机柜位置', max_length=200, blank=True, default='', help_text='U位')
# 网络信息
bmc_address = models.CharField('BMC地址', max_length=200, blank=True, default='')
ip_address = models.CharField('IP地址', max_length=200, blank=True, default='')
# 显卡信息
has_gpu = models.CharField('是否带卡', max_length=50, blank=True, default='')
gpu_type = models.CharField('显卡类型', max_length=200, blank=True, default='')
gpu_count = models.CharField('卡数', max_length=50, blank=True, default='')
# 采购与质保
purchase_date = models.CharField('采购日期', max_length=50, blank=True, default='')
warranty_expire = models.CharField('质保到期', max_length=50, blank=True, default='')
supplier = models.CharField('供应商', max_length=200, blank=True, default='')
# 管理信息
responsible_person = models.CharField('负责人', max_length=200, blank=True, default='')
department = models.CharField('使用部门', max_length=200, blank=True, default='')
user = models.CharField('维护人', max_length=200, blank=True, default='')
business_type = models.CharField('业务类型', max_length=200, blank=True, default='')
STATUS_CHOICES = [
('in_use', '在用'),
('idle', '闲置'),
@@ -28,43 +63,8 @@ class Asset(models.Model):
('scrapped', '已报废'),
]
# 基本信息
asset_number = models.CharField('资产编号', max_length=50, 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='')
asset_value = models.DecimalField('资产面值', max_digits=12, decimal_places=2, blank=True, null=True)
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)
# 显卡信息
has_gpu = models.CharField('是否带卡', max_length=20, blank=True, default='')
gpu_type = models.CharField('显卡类型', max_length=100, blank=True, default='')
gpu_count = models.IntegerField('卡数', 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='')
department = models.CharField('使用部门', max_length=100, blank=True, default='')
user = models.CharField('使用人', max_length=50, blank=True, default='')
business_type = models.CharField('业务类型', max_length=100, blank=True, default='')
status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='in_use')
remark = models.TextField('备注', blank=True, default='')
status = models.CharField('状态', max_length=50, choices=STATUS_CHOICES, default='in_use')
remark = models.CharField('备注', max_length=500, blank=True, default='')
# 系统字段
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='创建人')
@@ -82,14 +82,23 @@ class Asset(models.Model):
@property
def is_expired(self):
if self.warranty_expire:
return self.warranty_expire < date.today()
try:
from datetime import datetime
expire_date = datetime.strptime(self.warranty_expire, '%Y-%m-%d').date()
return expire_date < date.today()
except (ValueError, TypeError):
return False
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)
try:
from datetime import datetime, timedelta
expire_date = datetime.strptime(self.warranty_expire, '%Y-%m-%d').date()
return date.today() <= expire_date <= date.today() + timedelta(days=30)
except (ValueError, TypeError):
return False
return False
+29 -8
View File
@@ -88,7 +88,7 @@ def dashboard(request):
def asset_list(request):
queryset = Asset.objects.select_related('category')
# 搜索
# 搜索(模糊搜索所有字段)
search = request.GET.get('search', '').strip()
if search:
queryset = queryset.filter(
@@ -96,12 +96,18 @@ def asset_list(request):
Q(name__icontains=search) |
Q(serial_number__icontains=search) |
Q(ip_address__icontains=search) |
Q(bmc_address__icontains=search) |
Q(brand__icontains=search) |
Q(model__icontains=search) |
Q(location__icontains=search) |
Q(cabinet__icontains=search) |
Q(cabinet_position__icontains=search) |
Q(responsible_person__icontains=search) |
Q(department__icontains=search) |
Q(user__icontains=search)
Q(user__icontains=search) |
Q(business_type__icontains=search) |
Q(gpu_type__icontains=search) |
Q(gpu_count__icontains=search)
)
# 筛选
@@ -113,9 +119,13 @@ def asset_list(request):
if status:
queryset = queryset.filter(status=status)
location = request.GET.get('location')
if location:
queryset = queryset.filter(location__icontains=location)
cabinet = request.GET.get('cabinet')
if cabinet:
queryset = queryset.filter(cabinet__icontains=cabinet)
business_type = request.GET.get('business_type')
if business_type:
queryset = queryset.filter(business_type=business_type)
# 排序
sort = request.GET.get('sort', 'id')
@@ -132,7 +142,16 @@ def asset_list(request):
page_obj = paginator.get_page(page_number)
categories = Category.objects.all()
locations = Asset.objects.values_list('location', flat=True).exclude(location='').distinct()
cabinets = sorted(set(
Asset.objects.values_list('cabinet', flat=True)
.exclude(cabinet='')
.exclude(cabinet=None)
))
business_types = sorted(set(
Asset.objects.values_list('business_type', flat=True)
.exclude(business_type='')
.exclude(business_type=None)
))
context = {
'page_obj': page_obj,
@@ -140,8 +159,10 @@ def asset_list(request):
'categories': categories,
'current_category': category_id,
'current_status': status,
'current_location': location,
'locations': locations,
'current_cabinet': cabinet,
'current_business_type': business_type,
'cabinets': cabinets,
'business_types': business_types,
'status_map': STATUS_MAP,
'sort': sort,
}
+1 -1
View File
@@ -61,7 +61,7 @@
<tr><td class="text-muted">卡数</td><td>{{ asset.gpu_count|default:"-" }}</td></tr>
<tr><td class="text-muted">负责人</td><td>{{ asset.responsible_person|default:"-" }}</td></tr>
<tr><td class="text-muted">使用部门</td><td>{{ asset.department|default:"-" }}</td></tr>
<tr><td class="text-muted">使用</td><td>{{ asset.user|default:"-" }}</td></tr>
<tr><td class="text-muted">维护</td><td>{{ asset.user|default:"-" }}</td></tr>
<tr><td class="text-muted">业务类型</td><td>{{ asset.business_type|default:"-" }}</td></tr>
<tr><td class="text-muted">状态</td>
<td><span class="badge
+6 -2
View File
@@ -37,14 +37,18 @@
{{ form.category }}
</div>
<div class="row">
<div class="col-md-6 mb-3">
<div class="col-md-4 mb-3">
<label class="form-label text-muted">{{ form.brand.label }}</label>
{{ form.brand }}
</div>
<div class="col-md-6 mb-3">
<div class="col-md-4 mb-3">
<label class="form-label text-muted">{{ form.model.label }}</label>
{{ form.model }}
</div>
<div class="col-md-4 mb-3">
<label class="form-label text-muted">{{ form.asset_value.label }}</label>
{{ form.asset_value }}
</div>
</div>
<div class="mb-3">
<label class="form-label text-muted">{{ form.serial_number.label }}</label>
+21 -12
View File
@@ -22,10 +22,10 @@
<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">
<div class="col-md-3">
<label class="form-label text-muted small">搜索</label>
<input type="text" name="search" class="form-control form-control-sm"
placeholder="编号/名称/序列号/IP/品牌/型号/位置/负责人/部门/使用人" value="{{ search }}">
placeholder="编号/名称/序列号/IP/BMC/品牌/型号/机柜/负责人/部门/维护人/业务类型..." value="{{ search }}">
</div>
<div class="col-md-2">
<label class="form-label text-muted small">分类</label>
@@ -46,16 +46,25 @@
</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>
<label class="form-label text-muted small">机柜</label>
<select name="cabinet" class="form-select form-select-sm">
<option value="">全部机柜</option>
{% for cab in cabinets %}
<option value="{{ cab }}" {% if current_cabinet == cab %}selected{% endif %}>{{ cab }}</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>
<label class="form-label text-muted small">业务类型</label>
<select name="business_type" class="form-select form-select-sm">
<option value="">全部业务</option>
{% for bt in business_types %}
<option value="{{ bt }}" {% if current_business_type == bt %}selected{% endif %}>{{ bt }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-1">
<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>
@@ -84,7 +93,7 @@
<th>卡数</th>
<th style="width:60px">负责人</th>
<th>使用部门</th>
<th style="width:60px">使用</th>
<th style="width:60px">维护</th>
<th>业务类型</th>
<th>状态</th>
<th>操作</th>
@@ -145,19 +154,19 @@
<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 }}{% if search %}&search={{ search }}{% endif %}{% if current_category %}&category={{ current_category }}{% endif %}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_location %}&location={{ current_location }}{% endif %}"><i class="bi bi-chevron-left"></i></a></li>
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search %}&search={{ search }}{% endif %}{% if current_category %}&category={{ current_category }}{% endif %}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_cabinet %}&cabinet={{ current_cabinet }}{% endif %}{% if current_business_type %}&business_type={{ current_business_type }}{% endif %}"><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 }}{% if search %}&search={{ search }}{% endif %}{% if current_category %}&category={{ current_category }}{% endif %}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_location %}&location={{ current_location }}{% endif %}">{{ num }}</a></li>
<li class="page-item"><a class="page-link" href="?page={{ num }}{% if search %}&search={{ search }}{% endif %}{% if current_category %}&category={{ current_category }}{% endif %}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_cabinet %}&cabinet={{ current_cabinet }}{% endif %}{% if current_business_type %}&business_type={{ current_business_type }}{% endif %}">{{ 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 }}{% if search %}&search={{ search }}{% endif %}{% if current_category %}&category={{ current_category }}{% endif %}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_location %}&location={{ current_location }}{% endif %}"><i class="bi bi-chevron-right"></i></a></li>
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search %}&search={{ search }}{% endif %}{% if current_category %}&category={{ current_category }}{% endif %}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_cabinet %}&cabinet={{ current_cabinet }}{% endif %}{% if current_business_type %}&business_type={{ current_business_type }}{% endif %}"><i class="bi bi-chevron-right"></i></a></li>
{% endif %}
</ul>
</nav>