初始提交:希姆计算硬件资产管理系统
功能: - Django + MySQL + 深色主题 - 资产增删改查(含资产编号、BMC地址、设备位置、备注) - Excel导入导出(分类自动创建) - 设备分类管理 - 资产变更记录追踪 - 质保到期提醒 - 用户认证系统 - Docker部署支持
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.so
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sqlite3
|
||||||
|
db.sqlite3
|
||||||
|
|
||||||
|
# Django
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
media/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker data
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Static files (collected)
|
||||||
|
staticfiles/
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装Python依赖
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 复制项目
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 收集静态文件
|
||||||
|
RUN python manage.py collectstatic --noinput 2>/dev/null || true
|
||||||
|
|
||||||
|
# 创建启动脚本
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -0,0 +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']
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AssetappConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'assetapp'
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from django import forms
|
||||||
|
from .models import Asset, Category
|
||||||
|
|
||||||
|
|
||||||
|
class AssetForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Asset
|
||||||
|
fields = [
|
||||||
|
'asset_number', 'name', 'category', 'brand', 'model', 'serial_number',
|
||||||
|
'location', 'cabinet', 'cabinet_position', 'bmc_address', 'ip_address',
|
||||||
|
'purchase_date', 'warranty_expire', 'supplier',
|
||||||
|
'responsible_person', 'status', 'remark',
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'purchase_date': forms.DateInput(attrs={'type': 'date'}),
|
||||||
|
'warranty_expire': forms.DateInput(attrs={'type': 'date'}),
|
||||||
|
'remark': forms.Textarea(attrs={'rows': 3}),
|
||||||
|
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'category': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AssetImportForm(forms.Form):
|
||||||
|
excel_file = forms.FileField(
|
||||||
|
label='Excel文件',
|
||||||
|
help_text='支持 .xlsx 格式',
|
||||||
|
widget=forms.FileInput(attrs={'accept': '.xlsx,.xls'})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Category
|
||||||
|
fields = ['name', 'description']
|
||||||
@@ -0,0 +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 登录'))
|
||||||
@@ -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地址'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
class Category(models.Model):
|
||||||
|
"""设备分类"""
|
||||||
|
name = models.CharField('分类名称', max_length=50, unique=True)
|
||||||
|
description = models.CharField('描述', max_length=200, blank=True, default='')
|
||||||
|
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '设备分类'
|
||||||
|
verbose_name_plural = verbose_name
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Asset(models.Model):
|
||||||
|
"""硬件资产"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('in_use', '在用'),
|
||||||
|
('idle', '闲置'),
|
||||||
|
('maintenance', '维修中'),
|
||||||
|
('scrapped', '已报废'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 基本信息
|
||||||
|
asset_number = models.CharField('资产编号', max_length=50, unique=True, db_index=True)
|
||||||
|
name = models.CharField('设备名称', max_length=100)
|
||||||
|
category = models.ForeignKey(Category, on_delete=models.PROTECT, verbose_name='设备分类', related_name='assets')
|
||||||
|
|
||||||
|
# 硬件信息
|
||||||
|
brand = models.CharField('品牌', max_length=50, blank=True, default='')
|
||||||
|
model = models.CharField('型号', max_length=100, blank=True, default='')
|
||||||
|
serial_number = models.CharField('序列号', max_length=100, blank=True, default='', db_index=True)
|
||||||
|
|
||||||
|
# 位置信息
|
||||||
|
location = models.CharField('设备位置', max_length=200, blank=True, default='', help_text='楼层/房间/区域')
|
||||||
|
cabinet = models.CharField('机柜', max_length=50, blank=True, default='')
|
||||||
|
cabinet_position = models.CharField('机柜位置', max_length=50, blank=True, default='', help_text='U位')
|
||||||
|
|
||||||
|
# 网络信息
|
||||||
|
bmc_address = models.GenericIPAddressField('BMC地址', blank=True, null=True)
|
||||||
|
ip_address = models.GenericIPAddressField('IP地址', blank=True, null=True)
|
||||||
|
|
||||||
|
# 采购与质保
|
||||||
|
purchase_date = models.DateField('采购日期', blank=True, null=True)
|
||||||
|
warranty_expire = models.DateField('质保到期', blank=True, null=True)
|
||||||
|
supplier = models.CharField('供应商', max_length=100, blank=True, default='')
|
||||||
|
|
||||||
|
# 管理信息
|
||||||
|
responsible_person = models.CharField('负责人', max_length=50, blank=True, default='')
|
||||||
|
status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='in_use')
|
||||||
|
remark = models.TextField('备注', blank=True, default='')
|
||||||
|
|
||||||
|
# 系统字段
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='创建人')
|
||||||
|
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '硬件资产'
|
||||||
|
verbose_name_plural = verbose_name
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.asset_number} - {self.name}'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
if self.warranty_expire:
|
||||||
|
return self.warranty_expire < date.today()
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expiring_soon(self):
|
||||||
|
if self.warranty_expire:
|
||||||
|
from datetime import timedelta
|
||||||
|
return date.today() <= self.warranty_expire <= date.today() + timedelta(days=30)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class AssetChangeLog(models.Model):
|
||||||
|
"""资产变更记录"""
|
||||||
|
|
||||||
|
ACTION_CHOICES = [
|
||||||
|
('create', '创建'),
|
||||||
|
('update', '更新'),
|
||||||
|
('delete', '删除'),
|
||||||
|
('import', '导入'),
|
||||||
|
('export', '导出'),
|
||||||
|
('status_change', '状态变更'),
|
||||||
|
]
|
||||||
|
|
||||||
|
asset = models.ForeignKey(Asset, on_delete=models.SET_NULL, null=True, blank=True,
|
||||||
|
verbose_name='资产', related_name='change_logs')
|
||||||
|
asset_number = models.CharField('资产编号', max_length=50, db_index=True)
|
||||||
|
action = models.CharField('操作类型', max_length=20, choices=ACTION_CHOICES)
|
||||||
|
field_name = models.CharField('变更字段', max_length=50, blank=True, default='')
|
||||||
|
old_value = models.TextField('旧值', blank=True, default='')
|
||||||
|
new_value = models.TextField('新值', blank=True, default='')
|
||||||
|
description = models.TextField('描述', blank=True, default='')
|
||||||
|
operator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='操作人')
|
||||||
|
created_at = models.DateTimeField('操作时间', auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '变更记录'
|
||||||
|
verbose_name_plural = verbose_name
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.asset_number} - {self.get_action_display()}'
|
||||||
@@ -0,0 +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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
@@ -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'),
|
||||||
|
]
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.auth import authenticate, login, logout
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.conf import settings
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from io import BytesIO
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from .models import Asset, Category, AssetChangeLog
|
||||||
|
from .forms import AssetForm, AssetImportForm, CategoryForm
|
||||||
|
from .excel_utils import (
|
||||||
|
export_assets_to_excel, generate_import_template,
|
||||||
|
import_assets_from_excel, STATUS_MAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 认证 ────────────────────────────────────────
|
||||||
|
|
||||||
|
def login_view(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return redirect('dashboard')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.POST.get('username', '')
|
||||||
|
password = request.POST.get('password', '')
|
||||||
|
user = authenticate(request, username=username, password=password)
|
||||||
|
if user is not None:
|
||||||
|
login(request, user)
|
||||||
|
next_url = request.GET.get('next', '/')
|
||||||
|
return redirect(next_url)
|
||||||
|
else:
|
||||||
|
messages.error(request, '用户名或密码错误')
|
||||||
|
|
||||||
|
return render(request, 'assetapp/login.html')
|
||||||
|
|
||||||
|
|
||||||
|
def logout_view(request):
|
||||||
|
logout(request)
|
||||||
|
return redirect('login')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 仪表盘 ──────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def dashboard(request):
|
||||||
|
total_assets = Asset.objects.count()
|
||||||
|
status_stats = Asset.objects.values('status').annotate(count=Count('id'))
|
||||||
|
status_data = {item['status']: item['count'] for item in status_stats}
|
||||||
|
|
||||||
|
category_stats = Asset.objects.values('category__name').annotate(
|
||||||
|
count=Count('id')
|
||||||
|
).order_by('-count')
|
||||||
|
|
||||||
|
# 即将过保(30天内)
|
||||||
|
today = date.today()
|
||||||
|
expiring_soon = Asset.objects.filter(
|
||||||
|
warranty_expire__lte=today + timedelta(days=30),
|
||||||
|
warranty_expire__gte=today,
|
||||||
|
status='in_use',
|
||||||
|
).count()
|
||||||
|
expired = Asset.objects.filter(
|
||||||
|
warranty_expire__lt=today,
|
||||||
|
status='in_use',
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# 最近变更
|
||||||
|
recent_changes = AssetChangeLog.objects.select_related('operator')[:10]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'total_assets': total_assets,
|
||||||
|
'status_data': status_data,
|
||||||
|
'status_map': STATUS_MAP,
|
||||||
|
'category_stats': category_stats,
|
||||||
|
'expiring_soon': expiring_soon,
|
||||||
|
'expired': expired,
|
||||||
|
'recent_changes': recent_changes,
|
||||||
|
}
|
||||||
|
return render(request, 'assetapp/dashboard.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 资产列表 ─────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def asset_list(request):
|
||||||
|
queryset = Asset.objects.select_related('category')
|
||||||
|
|
||||||
|
# 搜索
|
||||||
|
search = request.GET.get('search', '').strip()
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(asset_number__icontains=search) |
|
||||||
|
Q(name__icontains=search) |
|
||||||
|
Q(serial_number__icontains=search) |
|
||||||
|
Q(ip_address__icontains=search) |
|
||||||
|
Q(brand__icontains=search) |
|
||||||
|
Q(model__icontains=search) |
|
||||||
|
Q(location__icontains=search) |
|
||||||
|
Q(responsible_person__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 筛选
|
||||||
|
category_id = request.GET.get('category')
|
||||||
|
if category_id:
|
||||||
|
queryset = queryset.filter(category_id=category_id)
|
||||||
|
|
||||||
|
status = request.GET.get('status')
|
||||||
|
if status:
|
||||||
|
queryset = queryset.filter(status=status)
|
||||||
|
|
||||||
|
location = request.GET.get('location')
|
||||||
|
if location:
|
||||||
|
queryset = queryset.filter(location__icontains=location)
|
||||||
|
|
||||||
|
# 排序
|
||||||
|
sort = request.GET.get('sort', '-created_at')
|
||||||
|
valid_sorts = ['asset_number', '-asset_number', 'name', '-name',
|
||||||
|
'created_at', '-created_at', 'updated_at', '-updated_at',
|
||||||
|
'purchase_date', '-purchase_date']
|
||||||
|
if sort not in valid_sorts:
|
||||||
|
sort = '-created_at'
|
||||||
|
queryset = queryset.order_by(sort)
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
paginator = Paginator(queryset, settings.ASSETS_PER_PAGE)
|
||||||
|
page_number = request.GET.get('page', 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
categories = Category.objects.all()
|
||||||
|
locations = Asset.objects.values_list('location', flat=True).exclude(location='').distinct()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'search': search,
|
||||||
|
'categories': categories,
|
||||||
|
'current_category': category_id,
|
||||||
|
'current_status': status,
|
||||||
|
'current_location': location,
|
||||||
|
'locations': locations,
|
||||||
|
'status_map': STATUS_MAP,
|
||||||
|
'sort': sort,
|
||||||
|
}
|
||||||
|
return render(request, 'assetapp/asset_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 资产详情 ─────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def asset_detail(request, pk):
|
||||||
|
asset = get_object_or_404(Asset.objects.select_related('category', 'created_by'), pk=pk)
|
||||||
|
change_logs = asset.change_logs.select_related('operator')[:20]
|
||||||
|
context = {
|
||||||
|
'asset': asset,
|
||||||
|
'change_logs': change_logs,
|
||||||
|
'status_map': STATUS_MAP,
|
||||||
|
}
|
||||||
|
return render(request, 'assetapp/asset_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 资产创建 ─────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def asset_create(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = AssetForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
asset = form.save(commit=False)
|
||||||
|
asset.created_by = request.user
|
||||||
|
asset.save()
|
||||||
|
|
||||||
|
AssetChangeLog.objects.create(
|
||||||
|
asset=asset,
|
||||||
|
asset_number=asset.asset_number,
|
||||||
|
action='create',
|
||||||
|
description='创建资产',
|
||||||
|
operator=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(request, f'资产 {asset.asset_number} 创建成功!')
|
||||||
|
return redirect('asset_detail', pk=asset.pk)
|
||||||
|
else:
|
||||||
|
form = AssetForm()
|
||||||
|
|
||||||
|
context = {'form': form, 'action': 'create'}
|
||||||
|
return render(request, 'assetapp/asset_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 资产编辑 ─────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def asset_update(request, pk):
|
||||||
|
asset = get_object_or_404(Asset, pk=pk)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = AssetForm(request.POST, instance=asset)
|
||||||
|
if form.is_valid():
|
||||||
|
# 记录变更
|
||||||
|
changes = []
|
||||||
|
for field in form.changed_data:
|
||||||
|
old_val = str(form.initial.get(field, ''))
|
||||||
|
new_val = str(form.cleaned_data.get(field, ''))
|
||||||
|
changes.append(f'{field}: {old_val} → {new_val}')
|
||||||
|
|
||||||
|
AssetChangeLog.objects.create(
|
||||||
|
asset=asset,
|
||||||
|
asset_number=asset.asset_number,
|
||||||
|
action='update',
|
||||||
|
field_name=field,
|
||||||
|
old_value=str(form.initial.get(field, '')),
|
||||||
|
new_value=str(form.cleaned_data.get(field, '')),
|
||||||
|
operator=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
asset = form.save()
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
messages.success(request, f'已更新 {len(changes)} 个字段')
|
||||||
|
return redirect('asset_detail', pk=asset.pk)
|
||||||
|
else:
|
||||||
|
form = AssetForm(instance=asset)
|
||||||
|
|
||||||
|
context = {'form': form, 'action': 'update', 'asset': asset}
|
||||||
|
return render(request, 'assetapp/asset_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 资产删除 ─────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def asset_delete(request, pk):
|
||||||
|
asset = get_object_or_404(Asset, pk=pk)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
AssetChangeLog.objects.create(
|
||||||
|
asset_number=asset.asset_number,
|
||||||
|
action='delete',
|
||||||
|
description=f'删除资产: {asset.name}',
|
||||||
|
operator=request.user,
|
||||||
|
)
|
||||||
|
asset.delete()
|
||||||
|
messages.success(request, f'资产 {asset.asset_number} 已删除')
|
||||||
|
return redirect('asset_list')
|
||||||
|
|
||||||
|
context = {'asset': asset}
|
||||||
|
return render(request, 'assetapp/asset_confirm_delete.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Excel导出 ────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def asset_export(request):
|
||||||
|
queryset = Asset.objects.select_related('category')
|
||||||
|
|
||||||
|
# 支持筛选导出
|
||||||
|
category_id = request.GET.get('category')
|
||||||
|
if category_id:
|
||||||
|
queryset = queryset.filter(category_id=category_id)
|
||||||
|
|
||||||
|
status = request.GET.get('status')
|
||||||
|
if status:
|
||||||
|
queryset = queryset.filter(status=status)
|
||||||
|
|
||||||
|
wb = export_assets_to_excel(queryset)
|
||||||
|
|
||||||
|
output = BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
AssetChangeLog.objects.create(
|
||||||
|
asset_number='-',
|
||||||
|
action='export',
|
||||||
|
description=f'导出 {queryset.count()} 条资产记录',
|
||||||
|
operator=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
today_str = date.today().strftime('%Y%m%d')
|
||||||
|
response = HttpResponse(
|
||||||
|
output.read(),
|
||||||
|
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
response['Content-Disposition'] = f'attachment; filename=硬件资产_{today_str}.xlsx'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Excel导入 ────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def asset_import(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = AssetImportForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
wb = load_workbook(request.FILES['excel_file'])
|
||||||
|
ws = wb.active
|
||||||
|
|
||||||
|
category_map = {c.name: c for c in Category.objects.all()}
|
||||||
|
results = import_assets_from_excel(ws, category_map, operator=request.user)
|
||||||
|
|
||||||
|
if results['success'] > 0:
|
||||||
|
messages.success(request, f"成功导入 {results['success']} 条资产")
|
||||||
|
|
||||||
|
if results['skipped'] > 0:
|
||||||
|
messages.warning(request, f"跳过 {results['skipped']} 条(资产编号重复)")
|
||||||
|
|
||||||
|
if results['errors']:
|
||||||
|
for error in results['errors'][:10]:
|
||||||
|
messages.error(request, error)
|
||||||
|
if len(results['errors']) > 10:
|
||||||
|
messages.error(request, f"...还有 {len(results['errors']) - 10} 条错误")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'导入失败: {str(e)}')
|
||||||
|
|
||||||
|
return redirect('asset_list')
|
||||||
|
else:
|
||||||
|
form = AssetImportForm()
|
||||||
|
|
||||||
|
return render(request, 'assetapp/asset_import.html', {'form': form})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 下载导入模板 ──────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def download_template(request):
|
||||||
|
wb = generate_import_template()
|
||||||
|
output = BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
response = HttpResponse(
|
||||||
|
output.read(),
|
||||||
|
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
response['Content-Disposition'] = 'attachment; filename=资产导入模板.xlsx'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 变更记录 ─────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def change_log_list(request):
|
||||||
|
logs = AssetChangeLog.objects.select_related('operator', 'asset')
|
||||||
|
|
||||||
|
asset_number = request.GET.get('asset_number', '').strip()
|
||||||
|
if asset_number:
|
||||||
|
logs = logs.filter(asset_number__icontains=asset_number)
|
||||||
|
|
||||||
|
action = request.GET.get('action')
|
||||||
|
if action:
|
||||||
|
logs = logs.filter(action=action)
|
||||||
|
|
||||||
|
paginator = Paginator(logs, 30)
|
||||||
|
page_number = request.GET.get('page', 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'asset_number': asset_number,
|
||||||
|
'current_action': action,
|
||||||
|
}
|
||||||
|
return render(request, 'assetapp/changelog.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 分类管理 ─────────────────────────────────────
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def category_list(request):
|
||||||
|
categories = Category.objects.annotate(asset_count=Count('assets')).order_by('name')
|
||||||
|
return render(request, 'assetapp/category_list.html', {'categories': categories})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def category_create(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = CategoryForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, f'分类 "{form.instance.name}" 创建成功')
|
||||||
|
return redirect('category_list')
|
||||||
|
else:
|
||||||
|
form = CategoryForm()
|
||||||
|
|
||||||
|
return render(request, 'assetapp/category_form.html', {'form': form, 'action': 'create'})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def category_update(request, pk):
|
||||||
|
category = get_object_or_404(Category, pk=pk)
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = CategoryForm(request.POST, instance=category)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, f'分类 "{category.name}" 已更新')
|
||||||
|
return redirect('category_list')
|
||||||
|
else:
|
||||||
|
form = CategoryForm(instance=category)
|
||||||
|
|
||||||
|
return render(request, 'assetapp/category_form.html', {'form': form, 'action': 'update', 'category': category})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def category_delete(request, pk):
|
||||||
|
category = get_object_or_404(Category, pk=pk)
|
||||||
|
asset_count = category.assets.count()
|
||||||
|
if asset_count > 0:
|
||||||
|
messages.error(request, f'分类 "{category.name}" 下还有 {asset_count} 个资产,无法删除')
|
||||||
|
return redirect('category_list')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
category.delete()
|
||||||
|
messages.success(request, f'分类 "{category.name}" 已删除')
|
||||||
|
return redirect('category_list')
|
||||||
|
|
||||||
|
return render(request, 'assetapp/category_confirm_delete.html', {'category': category})
|
||||||
@@ -0,0 +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()
|
||||||
@@ -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
|
||||||
@@ -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}),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Django>=4.2,<5.0
|
||||||
|
mysqlclient>=2.2
|
||||||
|
openpyxl>=3.1
|
||||||
|
gunicorn>=21.2
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user