commit adedaecf29dd0b545f819eb97f583c631b9f97d8 Author: caoqianming Date: Tue Mar 10 13:12:05 2026 +0800 feat: 项目初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b537a07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.venv/ +venv/ +ENV/ +env/ + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +/media +/staticfiles + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Node / Frontend +node_modules/ +dist/ +dist-ssr/ +*.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..55ef6cd --- /dev/null +++ b/README.md @@ -0,0 +1,246 @@ +# 新材料数据库系统 + +## 项目简介 +新材料数据库管理系统是一个基于 Django + Vue3 的前后端分离系统,用于管理工厂、材料信息和数据字典,并提供数据大屏展示功能。 + +## 技术栈 +- 后端:Python 3.9+ + Django 4.2 + Django REST Framework + JWT +- 数据库:PostgreSQL 14+ +- 前端:Vue 3 + Vite + Element Plus + ECharts 5 + +## 项目结构 +``` +mat/ +├── backend/ # 后端项目 +│ ├── config/ # 项目配置 +│ │ ├── __init__.py +│ │ ├── settings.py # 主配置文件 +│ │ ├── urls.py # 主路由 +│ │ ├── wsgi.py +│ │ └── asgi.py +│ ├── apps/ # 应用目录 +│ │ ├── authentication/ # 认证应用 +│ │ │ ├── __init__.py +│ │ │ ├── models.py # 用户模型 +│ │ │ ├── serializers.py +│ │ │ ├── views.py +│ │ │ └── urls.py +│ │ ├── factory/ # 工厂管理 +│ │ │ ├── __init__.py +│ │ │ ├── models.py +│ │ │ ├── serializers.py +│ │ │ ├── views.py +│ │ │ └── urls.py +│ │ ├── material/ # 材料管理 +│ │ │ ├── __init__.py +│ │ │ ├── models.py +│ │ │ ├── serializers.py +│ │ │ ├── views.py +│ │ │ └── urls.py +│ │ ├── dictionary/ # 数据字典 +│ │ │ ├── __init__.py +│ │ │ ├── models.py +│ │ │ ├── serializers.py +│ │ │ ├── views.py +│ │ │ └── urls.py +│ │ └── statistics/ # 统计分析 +│ │ ├── __init__.py +│ │ ├── views.py +│ │ └── urls.py +│ ├── media/ # 媒体文件 +│ ├── manage.py +│ └── requirements.txt +└── frontend/ # 前端项目 + ├── public/ + ├── src/ + │ ├── api/ # API接口 + │ │ ├── auth.js + │ │ ├── factory.js + │ │ ├── material.js + │ │ ├── dictionary.js + │ │ └── statistics.js + │ ├── assets/ # 静态资源 + │ ├── components/ # 公共组件 + │ │ ├── common/ + │ │ └── charts/ # 图表组件 + │ ├── layouts/ # 布局组件 + │ ├── router/ # 路由配置 + │ ├── stores/ # 状态管理 + │ ├── utils/ # 工具函数 + │ ├── views/ # 页面视图 + │ │ ├── auth/ # 认证相关 + │ │ ├── user/ # 用户管理 + │ │ ├── factory/ # 工厂管理 + │ │ ├── material/ # 材料管理 + │ │ ├── dictionary/ # 字典管理 + │ │ └── dashboard/ # 数据大屏 + │ │ ├── Overview.vue + │ │ ├── MaterialLibrary.vue + │ │ └── FactoryLibrary.vue + │ ├── App.vue + │ └── main.js + ├── .env.development + ├── .env.production + ├── index.html + ├── package.json + └── vite.config.js +``` + +## 功能模块 + +### 1. 用户管理 +- 管理员可以创建普通用户并分配工厂 +- 普通用户可以登录系统 + +### 2. 工厂管理 +- 管理员可以创建和管理工厂信息 +- 普通用户可以完善所属工厂信息 + +### 3. 材料管理 +- 管理员可以管理所有材料 +- 普通用户可以录入材料并提交审核 +- 支持材料审核流程(创建中、待审核、已审核) + +### 4. 数据字典管理 +- 管理员可以管理数据字典 +- 支持字典类型、名称和值的管理 + +### 5. 数据大屏(仅管理员可见) +- 数据总览:展示材料总数、种类、品牌数等统计信息 +- 材料库:展示材料星级对比、竞争优势、应用场景等图表 +- 工厂库:展示工厂地区分布、材料分类分布等图表 +- 数据每10秒自动刷新 + +## 数据库设计 + +### 1. 工厂表 (factory) +- id: 主键 +- dealer_name: 经销商名称 +- product_category: 产品分类 +- factory_name: 生产工厂全称 +- factory_short_name: 工厂简称 +- province: 省 +- city: 市 +- district: 区 +- address: 详细地址 +- website: 官网链接 +- created_at: 创建时间 +- updated_at: 更新时间 + +### 2. 材料表 (material) +- id: 主键 +- name: 材料名称 +- major_category: 专业类别 +- material_category: 材料分类 +- material_subcategory: 材料子分类 +- spec: 规格型号 +- standard: 符合标准 +- application_scene: 应用场景 +- application_desc: 应用场景说明 +- replace_type: 替代材料类型 +- advantage: 竞争优势 +- advantage_desc: 优势说明 +- cost_compare: 成本对比百分数 +- cost_desc: 成本说明 +- cases: 案例 +- brochure: 宣传页图片 +- quality_level: 质量提升等级 +- durability_level: 耐久可靠等级 +- eco_level: 环保健康等级 +- carbon_level: 循环低碳等级 +- score_level: 总评分等级 +- connection_method: 连接方式 +- construction_method: 施工工艺 +- limit_condition: 限制条件 +- factory_id: 所属工厂 +- status: 状态(draft/pending/approved) + +### 3. 数据字典 (dictionary) +- id: 主键 +- type: 字典类型 +- name: 字典名称 +- value: 字典值 + +## 部署说明 + +### 后端部署 +1. 安装依赖: +```bash +cd backend +pip install -r requirements.txt +``` + +2. 配置数据库: +修改 `config/settings.py` 中的数据库配置 + +3. 初始化数据库: +```bash +python manage.py makemigrations +python manage.py migrate +``` + +4. 创建超级用户: +```bash +python manage.py createsuperuser +``` + +5. 启动服务: +```bash +python manage.py runserver +``` + +### 前端部署 +1. 安装依赖: +```bash +cd frontend +npm install +``` + +2. 配置环境变量: +修改 `.env.development` 和 `.env.production` 文件 + +3. 启动开发服务器: +```bash +npm run dev +``` + +4. 构建生产版本: +```bash +npm run build +``` + +## API接口文档 + +### 认证接口 +- POST /api/auth/login/ - 用户登录 +- POST /api/auth/logout/ - 用户登出 +- GET /api/auth/user/ - 获取当前用户信息 + +### 工厂接口 +- GET /api/factory/ - 获取工厂列表 +- POST /api/factory/ - 创建工厂 +- GET /api/factory/{id}/ - 获取工厂详情 +- PUT /api/factory/{id}/ - 更新工厂信息 +- DELETE /api/factory/{id}/ - 删除工厂 + +### 材料接口 +- GET /api/material/ - 获取材料列表 +- POST /api/material/ - 创建材料 +- GET /api/material/{id}/ - 获取材料详情 +- PUT /api/material/{id}/ - 更新材料信息 +- DELETE /api/material/{id}/ - 删除材料 +- POST /api/material/{id}/submit/ - 提交审核 +- POST /api/material/{id}/approve/ - 审核通过 +- POST /api/material/{id}/reject/ - 审核拒绝 + +### 统计接口 +- GET /api/statistics/overview/ - 数据总览 +- GET /api/statistics/materials/ - 材料统计 +- GET /api/statistics/factories/ - 工厂统计 + +### 字典接口 +- GET /api/dictionary/ - 获取字典列表 +- POST /api/dictionary/ - 创建字典 +- GET /api/dictionary/{id}/ - 获取字典详情 +- PUT /api/dictionary/{id}/ - 更新字典 +- DELETE /api/dictionary/{id}/ - 删除字典 diff --git a/backend/apps/authentication/__init__.py b/backend/apps/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/authentication/migrations/0001_initial.py b/backend/apps/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..5a745c0 --- /dev/null +++ b/backend/apps/authentication/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated migration for authentication app + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('factory', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='date joined')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('role', models.CharField(choices=[('admin', '管理员'), ('user', '普通账号')], default='user', max_length=20, verbose_name='角色')), + ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='手机号')), + ('factory', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='factory.factory', verbose_name='所属工厂')), + ], + options={ + 'verbose_name': '用户', + 'verbose_name_plural': '用户', + 'db_table': 'auth_user', + 'abstract': False, + }, + ), + ] diff --git a/backend/apps/authentication/migrations/__init__.py b/backend/apps/authentication/migrations/__init__.py new file mode 100644 index 0000000..24b99ae --- /dev/null +++ b/backend/apps/authentication/migrations/__init__.py @@ -0,0 +1 @@ +# This file is required for Python to treat the directory as a package. diff --git a/backend/apps/authentication/models.py b/backend/apps/authentication/models.py new file mode 100644 index 0000000..a829c02 --- /dev/null +++ b/backend/apps/authentication/models.py @@ -0,0 +1,24 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + +class User(AbstractUser): + """ + 自定义用户模型 + """ + ROLE_CHOICES = ( + ('admin', '管理员'), + ('user', '普通账号'), + ) + + role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user', verbose_name='角色') + factory = models.ForeignKey('factory.Factory', on_delete=models.SET_NULL, null=True, blank=True, + related_name='users', verbose_name='所属工厂') + phone = models.CharField(max_length=20, blank=True, null=True, verbose_name='手机号') + + class Meta: + verbose_name = '用户' + verbose_name_plural = '用户' + db_table = 'auth_user' + + def __str__(self): + return f"{self.username} ({self.get_role_display()})" diff --git a/backend/apps/authentication/serializers.py b/backend/apps/authentication/serializers.py new file mode 100644 index 0000000..3549419 --- /dev/null +++ b/backend/apps/authentication/serializers.py @@ -0,0 +1,67 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +User = get_user_model() + + +class UserSerializer(serializers.ModelSerializer): + """ + 用户序列化器 + """ + factory_name = serializers.CharField(source='factory.factory_name', read_only=True, allow_null=True) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'role', + 'factory', 'factory_name', 'phone', 'is_active', 'date_joined'] + read_only_fields = ['id', 'date_joined'] + + +class UserCreateSerializer(serializers.ModelSerializer): + """ + 用户创建序列化器 + """ + password = serializers.CharField(write_only=True, required=True, style={'input_type': 'password'}) + password_confirm = serializers.CharField(write_only=True, required=True, style={'input_type': 'password'}) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'role', + 'factory', 'phone', 'password', 'password_confirm'] + + def validate(self, attrs): + if attrs['password'] != attrs['password_confirm']: + raise serializers.ValidationError({"password": "密码字段不匹配。"}) + return attrs + + def create(self, validated_data): + validated_data.pop('password_confirm') + password = validated_data.pop('password') + user = User.objects.create(**validated_data) + user.set_password(password) + user.save() + return user + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """ + 自定义JWT令牌序列化器 + """ + @classmethod + def get_token(cls, user): + token = super().get_token(user) + + # 添加自定义声明 + token['role'] = user.role + token['factory_id'] = user.factory_id if user.factory else None + + return token + + def validate(self, attrs): + data = super().validate(attrs) + + # 添加用户信息到响应 + data['user'] = UserSerializer(self.user).data + + return data diff --git a/backend/apps/authentication/urls.py b/backend/apps/authentication/urls.py new file mode 100644 index 0000000..ab2a46c --- /dev/null +++ b/backend/apps/authentication/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView +from .views import CustomTokenObtainPairView, UserListView, UserDetailView, current_user + +urlpatterns = [ + path('login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('users/', UserListView.as_view(), name='user-list'), + path('users//', UserDetailView.as_view(), name='user-detail'), + path('user/', current_user, name='current-user'), +] diff --git a/backend/apps/authentication/views.py b/backend/apps/authentication/views.py new file mode 100644 index 0000000..cfd716f --- /dev/null +++ b/backend/apps/authentication/views.py @@ -0,0 +1,65 @@ +from rest_framework import generics, status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView +from .models import User +from .serializers import UserSerializer, UserCreateSerializer, CustomTokenObtainPairSerializer + + +class CustomTokenObtainPairView(TokenObtainPairView): + """ + 自定义JWT令牌获取视图 + """ + serializer_class = CustomTokenObtainPairSerializer + + +class UserListView(generics.ListCreateAPIView): + """ + 用户列表和创建视图 + """ + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + if self.request.method == 'POST': + return UserCreateSerializer + return UserSerializer + + def perform_create(self, serializer): + # 只有管理员可以创建用户 + if self.request.user.role != 'admin': + raise PermissionError("只有管理员可以创建用户") + serializer.save() + + +class UserDetailView(generics.RetrieveUpdateDestroyAPIView): + """ + 用户详情视图 + """ + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [IsAuthenticated] + + def perform_update(self, serializer): + # 普通用户只能修改自己的信息 + if self.request.user.role != 'admin' and self.request.user.id != self.get_object().id: + raise PermissionError("无权修改其他用户信息") + serializer.save() + + def perform_destroy(self, instance): + # 只有管理员可以删除用户 + if self.request.user.role != 'admin': + raise PermissionError("只有管理员可以删除用户") + instance.delete() + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def current_user(request): + """ + 获取当前用户信息 + """ + serializer = UserSerializer(request.user) + return Response(serializer.data) diff --git a/backend/apps/dictionary/__init__.py b/backend/apps/dictionary/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/dictionary/migrations/0001_initial.py b/backend/apps/dictionary/migrations/0001_initial.py new file mode 100644 index 0000000..e3b32cf --- /dev/null +++ b/backend/apps/dictionary/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated migration for dictionary app + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Dictionary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(max_length=100, verbose_name='字典类型')), + ('name', models.CharField(max_length=255, verbose_name='字典名称')), + ('value', models.CharField(max_length=255, verbose_name='字典值')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '数据字典', + 'verbose_name_plural': '数据字典', + 'db_table': 'dictionary', + }, + ), + migrations.AlterUniqueTogether( + name='dictionary', + unique_together={('type', 'value')}, + ), + ] diff --git a/backend/apps/dictionary/migrations/__init__.py b/backend/apps/dictionary/migrations/__init__.py new file mode 100644 index 0000000..24b99ae --- /dev/null +++ b/backend/apps/dictionary/migrations/__init__.py @@ -0,0 +1 @@ +# This file is required for Python to treat the directory as a package. diff --git a/backend/apps/dictionary/models.py b/backend/apps/dictionary/models.py new file mode 100644 index 0000000..c079d9a --- /dev/null +++ b/backend/apps/dictionary/models.py @@ -0,0 +1,20 @@ +from django.db import models + +class Dictionary(models.Model): + """ + 数据字典模型 + """ + type = models.CharField(max_length=100, verbose_name='字典类型') + name = models.CharField(max_length=255, verbose_name='字典名称') + value = models.CharField(max_length=255, verbose_name='字典值') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + verbose_name = '数据字典' + verbose_name_plural = '数据字典' + db_table = 'dictionary' + unique_together = ('type', 'value') + + def __str__(self): + return f"{self.type} - {self.name}" diff --git a/backend/apps/dictionary/serializers.py b/backend/apps/dictionary/serializers.py new file mode 100644 index 0000000..e843ab4 --- /dev/null +++ b/backend/apps/dictionary/serializers.py @@ -0,0 +1,20 @@ +from rest_framework import serializers +from .models import Dictionary + + +class DictionarySerializer(serializers.ModelSerializer): + """ + 数据字典序列化器 + """ + class Meta: + model = Dictionary + fields = ['id', 'type', 'name', 'value', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class DictionaryGroupSerializer(serializers.Serializer): + """ + 数据字典分组序列化器 + """ + type = serializers.CharField() + items = DictionarySerializer(many=True) diff --git a/backend/apps/dictionary/urls.py b/backend/apps/dictionary/urls.py new file mode 100644 index 0000000..096bcbb --- /dev/null +++ b/backend/apps/dictionary/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import DictionaryListView, DictionaryDetailView, dictionary_grouped + +urlpatterns = [ + path('', DictionaryListView.as_view(), name='dictionary-list'), + path('grouped/', dictionary_grouped, name='dictionary-grouped'), + path('/', DictionaryDetailView.as_view(), name='dictionary-detail'), +] diff --git a/backend/apps/dictionary/views.py b/backend/apps/dictionary/views.py new file mode 100644 index 0000000..6b4d171 --- /dev/null +++ b/backend/apps/dictionary/views.py @@ -0,0 +1,82 @@ +from rest_framework import generics, status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.db.models import Q +from .models import Dictionary +from .serializers import DictionarySerializer, DictionaryGroupSerializer + + +class DictionaryListView(generics.ListCreateAPIView): + """ + 数据字典列表和创建视图 + """ + serializer_class = DictionarySerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """ + 支持按类型过滤 + """ + queryset = Dictionary.objects.all() + + # 支持按类型过滤 + dict_type = self.request.query_params.get('type') + if dict_type: + queryset = queryset.filter(type=dict_type) + + # 支持按名称搜索 + name = self.request.query_params.get('name') + if name: + queryset = queryset.filter(name__icontains=name) + + return queryset + + def perform_create(self, serializer): + # 只有管理员可以创建字典 + if self.request.user.role != 'admin': + raise PermissionError("只有管理员可以创建字典") + serializer.save() + + +class DictionaryDetailView(generics.RetrieveUpdateDestroyAPIView): + """ + 数据字典详情视图 + """ + queryset = Dictionary.objects.all() + serializer_class = DictionarySerializer + permission_classes = [IsAuthenticated] + + def perform_update(self, serializer): + # 只有管理员可以更新字典 + if self.request.user.role != 'admin': + raise PermissionError("只有管理员可以更新字典") + serializer.save() + + def perform_destroy(self, instance): + # 只有管理员可以删除字典 + if self.request.user.role != 'admin': + raise PermissionError("只有管理员可以删除字典") + instance.delete() + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def dictionary_grouped(request): + """ + 获取分组的数据字典 + """ + # 获取所有字典类型 + dict_types = Dictionary.objects.values('type').distinct() + + result = [] + for dict_type in dict_types: + type_name = dict_type['type'] + items = Dictionary.objects.filter(type=type_name) + serializer = DictionarySerializer(items, many=True) + result.append({ + 'type': type_name, + 'items': serializer.data + }) + + return Response(result) diff --git a/backend/apps/factory/__init__.py b/backend/apps/factory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/factory/migrations/0001_initial.py b/backend/apps/factory/migrations/0001_initial.py new file mode 100644 index 0000000..7bf726f --- /dev/null +++ b/backend/apps/factory/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated migration for factory app + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Factory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dealer_name', models.CharField(max_length=255, verbose_name='经销商名称')), + ('product_category', models.CharField(blank=True, max_length=255, null=True, verbose_name='产品分类')), + ('factory_name', models.CharField(max_length=255, verbose_name='生产工厂全称')), + ('factory_short_name', models.CharField(max_length=100, verbose_name='工厂简称')), + ('province', models.CharField(max_length=50, verbose_name='省')), + ('city', models.CharField(max_length=50, verbose_name='市')), + ('district', models.CharField(blank=True, max_length=50, null=True, verbose_name='区')), + ('address', models.TextField(blank=True, null=True, verbose_name='详细地址')), + ('website', models.URLField(blank=True, null=True, verbose_name='官网链接')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '工厂', + 'verbose_name_plural': '工厂', + 'db_table': 'factory', + }, + ), + ] diff --git a/backend/apps/factory/migrations/__init__.py b/backend/apps/factory/migrations/__init__.py new file mode 100644 index 0000000..24b99ae --- /dev/null +++ b/backend/apps/factory/migrations/__init__.py @@ -0,0 +1 @@ +# This file is required for Python to treat the directory as a package. diff --git a/backend/apps/factory/models.py b/backend/apps/factory/models.py new file mode 100644 index 0000000..9d3a3a6 --- /dev/null +++ b/backend/apps/factory/models.py @@ -0,0 +1,25 @@ +from django.db import models + +class Factory(models.Model): + """ + 工厂模型 + """ + dealer_name = models.CharField(max_length=255, verbose_name='经销商名称') + product_category = models.CharField(max_length=255, blank=True, null=True, verbose_name='产品分类') + factory_name = models.CharField(max_length=255, verbose_name='生产工厂全称') + factory_short_name = models.CharField(max_length=100, verbose_name='工厂简称') + province = models.CharField(max_length=50, verbose_name='省') + city = models.CharField(max_length=50, verbose_name='市') + district = models.CharField(max_length=50, blank=True, null=True, verbose_name='区') + address = models.TextField(blank=True, null=True, verbose_name='详细地址') + website = models.URLField(blank=True, null=True, verbose_name='官网链接') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + verbose_name = '工厂' + verbose_name_plural = '工厂' + db_table = 'factory' + + def __str__(self): + return self.factory_name diff --git a/backend/apps/factory/serializers.py b/backend/apps/factory/serializers.py new file mode 100644 index 0000000..9bf55e3 --- /dev/null +++ b/backend/apps/factory/serializers.py @@ -0,0 +1,31 @@ +from rest_framework import serializers +from .models import Factory + + +class FactorySerializer(serializers.ModelSerializer): + """ + 工厂序列化器 + """ + material_count = serializers.SerializerMethodField() + + class Meta: + model = Factory + fields = ['id', 'dealer_name', 'product_category', 'factory_name', + 'factory_short_name', 'province', 'city', 'district', + 'address', 'website', 'created_at', 'updated_at', 'material_count'] + read_only_fields = ['id', 'created_at', 'updated_at', 'material_count'] + + def get_material_count(self, obj): + """ + 获取工厂的材料数量 + """ + return obj.materials.count() + + +class FactoryListSerializer(serializers.ModelSerializer): + """ + 工厂列表序列化器(简化版) + """ + class Meta: + model = Factory + fields = ['id', 'factory_name', 'factory_short_name', 'province', 'city'] diff --git a/backend/apps/factory/urls.py b/backend/apps/factory/urls.py new file mode 100644 index 0000000..e326645 --- /dev/null +++ b/backend/apps/factory/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import FactoryListView, FactoryDetailView, factory_list_simple + +urlpatterns = [ + path('', FactoryListView.as_view(), name='factory-list'), + path('simple/', factory_list_simple, name='factory-list-simple'), + path('/', FactoryDetailView.as_view(), name='factory-detail'), +] diff --git a/backend/apps/factory/views.py b/backend/apps/factory/views.py new file mode 100644 index 0000000..d54dbce --- /dev/null +++ b/backend/apps/factory/views.py @@ -0,0 +1,58 @@ +from rest_framework import generics, status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from .models import Factory +from .serializers import FactorySerializer, FactoryListSerializer + + +class FactoryListView(generics.ListCreateAPIView): + """ + 工厂列表和创建视图 + """ + queryset = Factory.objects.all() + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + if self.request.method == 'GET': + return FactoryListSerializer + return FactorySerializer + + def perform_create(self, serializer): + # 只有管理员可以创建工厂 + if self.request.user.role != 'admin': + raise PermissionError("只有管理员可以创建工厂") + serializer.save() + + +class FactoryDetailView(generics.RetrieveUpdateDestroyAPIView): + """ + 工厂详情视图 + """ + queryset = Factory.objects.all() + serializer_class = FactorySerializer + permission_classes = [IsAuthenticated] + + def perform_update(self, serializer): + # 普通用户只能修改自己所属工厂的信息 + if (self.request.user.role != 'admin' and + self.request.user.factory_id != self.get_object().id): + raise PermissionError("无权修改其他工厂信息") + serializer.save() + + def perform_destroy(self, instance): + # 只有管理员可以删除工厂 + if self.request.user.role != 'admin': + raise PermissionError("只有管理员可以删除工厂") + instance.delete() + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def factory_list_simple(request): + """ + 简化的工厂列表,用于下拉选择 + """ + factories = Factory.objects.all() + serializer = FactoryListSerializer(factories, many=True) + return Response(serializer.data) diff --git a/backend/apps/material/__init__.py b/backend/apps/material/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/material/migrations/0001_initial.py b/backend/apps/material/migrations/0001_initial.py new file mode 100644 index 0000000..35651e1 --- /dev/null +++ b/backend/apps/material/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated migration for material app + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('factory', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Material', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='材料名称')), + ('major_category', models.CharField(choices=[('architecture', '建筑'), ('landscape', '景观'), ('equipment', '设备'), ('decoration', '装修')], max_length=20, verbose_name='专业类别')), + ('material_category', models.CharField(max_length=255, verbose_name='材料分类')), + ('material_subcategory', models.CharField(max_length=255, verbose_name='材料子分类')), + ('spec', models.CharField(blank=True, max_length=255, null=True, verbose_name='规格型号')), + ('standard', models.CharField(blank=True, max_length=255, null=True, verbose_name='符合标准')), + ('application_scene', models.CharField(blank=True, choices=[('fu', '府系'), ('jing', '境系'), ('cheng', '城系'), ('zhu', '住系'), ('affordable', '保障房')], max_length=20, null=True, verbose_name='应用场景')), + ('application_desc', models.TextField(blank=True, null=True, verbose_name='应用场景说明')), + ('replace_type', models.CharField(blank=True, choices=[('alternative', '平替'), ('new_development', '新研发')], max_length=20, null=True, verbose_name='替代材料类型')), + ('advantage', models.CharField(blank=True, choices=[('quality', '品质'), ('cost', '成本')], max_length=20, null=True, verbose_name='竞争优势')), + ('advantage_desc', models.TextField(blank=True, null=True, verbose_name='优势说明')), + ('cost_compare', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='成本对比百分数')), + ('cost_desc', models.TextField(blank=True, null=True, verbose_name='成本说明')), + ('cases', models.TextField(blank=True, null=True, verbose_name='案例')), + ('brochure', models.ImageField(blank=True, null=True, upload_to='material_brochures/', verbose_name='宣传页图片')), + ('quality_level', models.IntegerField(blank=True, choices=[(1, '1星'), (2, '2星'), (3, '3星')], null=True, verbose_name='质量提升等级')), + ('durability_level', models.IntegerField(blank=True, choices=[(1, '1星'), (2, '2星'), (3, '3星')], null=True, verbose_name='耐久可靠等级')), + ('eco_level', models.IntegerField(blank=True, choices=[(1, '1星'), (2, '2星'), (3, '3星')], null=True, verbose_name='环保健康等级')), + ('carbon_level', models.IntegerField(blank=True, choices=[(1, '1星'), (2, '2星'), (3, '3星')], null=True, verbose_name='循环低碳等级')), + ('score_level', models.IntegerField(blank=True, choices=[(1, '1星'), (2, '2星'), (3, '3星')], null=True, verbose_name='总评分等级')), + ('connection_method', models.CharField(blank=True, max_length=255, null=True, verbose_name='连接方式')), + ('construction_method', models.CharField(blank=True, max_length=255, null=True, verbose_name='施工工艺')), + ('limit_condition', models.TextField(blank=True, null=True, verbose_name='限制条件')), + ('status', models.CharField(choices=[('draft', '创建中'), ('pending', '待审核'), ('approved', '已审核')], default='draft', max_length=20, verbose_name='状态')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('factory', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='materials', to='factory.factory', verbose_name='所属工厂')), + ], + options={ + 'verbose_name': '材料', + 'verbose_name_plural': '材料', + 'db_table': 'material', + }, + ), + ] diff --git a/backend/apps/material/migrations/__init__.py b/backend/apps/material/migrations/__init__.py new file mode 100644 index 0000000..24b99ae --- /dev/null +++ b/backend/apps/material/migrations/__init__.py @@ -0,0 +1 @@ +# This file is required for Python to treat the directory as a package. diff --git a/backend/apps/material/models.py b/backend/apps/material/models.py new file mode 100644 index 0000000..5d354b5 --- /dev/null +++ b/backend/apps/material/models.py @@ -0,0 +1,78 @@ +from django.db import models + +class Material(models.Model): + """ + 材料模型 + """ + MAJOR_CATEGORY_CHOICES = ( + ('architecture', '建筑'), + ('landscape', '景观'), + ('equipment', '设备'), + ('decoration', '装修'), + ) + + REPLACE_TYPE_CHOICES = ( + ('alternative', '平替'), + ('new_development', '新研发'), + ) + + ADVANTAGE_CHOICES = ( + ('quality', '品质'), + ('cost', '成本'), + ) + + APPLICATION_SCENE_CHOICES = ( + ('fu', '府系'), + ('jing', '境系'), + ('cheng', '城系'), + ('zhu', '住系'), + ('affordable', '保障房'), + ) + + STAR_LEVEL_CHOICES = ( + (1, '1星'), + (2, '2星'), + (3, '3星'), + ) + + STATUS_CHOICES = ( + ('draft', '创建中'), + ('pending', '待审核'), + ('approved', '已审核'), + ) + + name = models.CharField(max_length=255, verbose_name='材料名称') + major_category = models.CharField(max_length=20, choices=MAJOR_CATEGORY_CHOICES, verbose_name='专业类别') + material_category = models.CharField(max_length=255, verbose_name='材料分类') + material_subcategory = models.CharField(max_length=255, verbose_name='材料子分类') + spec = models.CharField(max_length=255, blank=True, null=True, verbose_name='规格型号') + standard = models.CharField(max_length=255, blank=True, null=True, verbose_name='符合标准') + application_scene = models.CharField(max_length=20, choices=APPLICATION_SCENE_CHOICES, blank=True, null=True, verbose_name='应用场景') + application_desc = models.TextField(blank=True, null=True, verbose_name='应用场景说明') + replace_type = models.CharField(max_length=20, choices=REPLACE_TYPE_CHOICES, blank=True, null=True, verbose_name='替代材料类型') + advantage = models.CharField(max_length=20, choices=ADVANTAGE_CHOICES, blank=True, null=True, verbose_name='竞争优势') + advantage_desc = models.TextField(blank=True, null=True, verbose_name='优势说明') + cost_compare = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='成本对比百分数') + cost_desc = models.TextField(blank=True, null=True, verbose_name='成本说明') + cases = models.TextField(blank=True, null=True, verbose_name='案例') + brochure = models.ImageField(upload_to='material_brochures/', blank=True, null=True, verbose_name='宣传页图片') + quality_level = models.IntegerField(choices=STAR_LEVEL_CHOICES, blank=True, null=True, verbose_name='质量提升等级') + durability_level = models.IntegerField(choices=STAR_LEVEL_CHOICES, blank=True, null=True, verbose_name='耐久可靠等级') + eco_level = models.IntegerField(choices=STAR_LEVEL_CHOICES, blank=True, null=True, verbose_name='环保健康等级') + carbon_level = models.IntegerField(choices=STAR_LEVEL_CHOICES, blank=True, null=True, verbose_name='循环低碳等级') + score_level = models.IntegerField(choices=STAR_LEVEL_CHOICES, blank=True, null=True, verbose_name='总评分等级') + connection_method = models.CharField(max_length=255, blank=True, null=True, verbose_name='连接方式') + construction_method = models.CharField(max_length=255, blank=True, null=True, verbose_name='施工工艺') + limit_condition = models.TextField(blank=True, null=True, verbose_name='限制条件') + factory = models.ForeignKey('factory.Factory', on_delete=models.CASCADE, related_name='materials', verbose_name='所属工厂') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name='状态') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + verbose_name = '材料' + verbose_name_plural = '材料' + db_table = 'material' + + def __str__(self): + return self.name diff --git a/backend/apps/material/serializers.py b/backend/apps/material/serializers.py new file mode 100644 index 0000000..cda112c --- /dev/null +++ b/backend/apps/material/serializers.py @@ -0,0 +1,54 @@ +from rest_framework import serializers +from .models import Material + + +class MaterialSerializer(serializers.ModelSerializer): + """ + 材料序列化器 + """ + factory_name = serializers.CharField(source='factory.factory_name', read_only=True) + factory_short_name = serializers.CharField(source='factory.factory_short_name', read_only=True) + major_category_display = serializers.CharField(source='get_major_category_display', read_only=True) + replace_type_display = serializers.CharField(source='get_replace_type_display', read_only=True) + advantage_display = serializers.CharField(source='get_advantage_display', read_only=True) + application_scene_display = serializers.CharField(source='get_application_scene_display', read_only=True) + brochure_url = serializers.SerializerMethodField() + + class Meta: + model = Material + fields = ['id', 'name', 'major_category', 'major_category_display', + 'material_category', 'material_subcategory', 'spec', 'standard', + 'application_scene', 'application_scene_display', 'application_desc', + 'replace_type', 'replace_type_display', 'advantage', 'advantage_display', + 'advantage_desc', 'cost_compare', 'cost_desc', 'cases', 'brochure', + 'brochure_url', 'quality_level', 'durability_level', 'eco_level', + 'carbon_level', 'score_level', 'connection_method', 'construction_method', + 'limit_condition', 'factory', 'factory_name', 'factory_short_name', + 'status', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def get_brochure_url(self, obj): + """ + 获取宣传页图片URL + """ + if obj.brochure: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.brochure.url) + return None + + +class MaterialListSerializer(serializers.ModelSerializer): + """ + 材料列表序列化器(简化版) + """ + factory_name = serializers.CharField(source='factory.factory_name', read_only=True) + factory_short_name = serializers.CharField(source='factory.factory_short_name', read_only=True) + major_category_display = serializers.CharField(source='get_major_category_display', read_only=True) + status_display = serializers.CharField(source='get_status_display', read_only=True) + + class Meta: + model = Material + fields = ['id', 'name', 'major_category', 'major_category_display', + 'material_category', 'material_subcategory', 'factory', + 'factory_name', 'factory_short_name', 'status', 'status_display'] diff --git a/backend/apps/material/urls.py b/backend/apps/material/urls.py new file mode 100644 index 0000000..8a1ba18 --- /dev/null +++ b/backend/apps/material/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import MaterialViewSet + +router = DefaultRouter() +router.register(r'', MaterialViewSet, basename='material') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/backend/apps/material/views.py b/backend/apps/material/views.py new file mode 100644 index 0000000..6ef2cdb --- /dev/null +++ b/backend/apps/material/views.py @@ -0,0 +1,155 @@ +from rest_framework import generics, status +from rest_framework.decorators import api_view, permission_classes, action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from .models import Material +from .serializers import MaterialSerializer, MaterialListSerializer + + +class MaterialViewSet(ModelViewSet): + """ + 材料视图集 + """ + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """ + 根据用户角色过滤材料 + """ + queryset = Material.objects.all() + + # 普通用户只能看到自己工厂的材料 + if self.request.user.role != 'admin': + queryset = queryset.filter(factory=self.request.user.factory) + + # 支持按状态过滤 + status_filter = self.request.query_params.get('status') + if status_filter: + queryset = queryset.filter(status=status_filter) + + # 支持按工厂过滤 + factory_id = self.request.query_params.get('factory_id') + if factory_id: + queryset = queryset.filter(factory_id=factory_id) + + # 支持按专业类别过滤 + major_category = self.request.query_params.get('major_category') + if major_category: + queryset = queryset.filter(major_category=major_category) + + # 支持按材料子类过滤 + material_subcategory = self.request.query_params.get('material_subcategory') + if material_subcategory: + queryset = queryset.filter(material_subcategory=material_subcategory) + + return queryset + + def get_serializer_class(self): + """ + 根据操作类型选择序列化器 + """ + if self.action == 'list': + return MaterialListSerializer + return MaterialSerializer + + def perform_create(self, serializer): + """ + 创建材料时自动设置工厂 + """ + # 普通用户只能为自己工厂创建材料 + if self.request.user.role != 'admin': + serializer.save(factory=self.request.user.factory) + else: + serializer.save() + + def perform_update(self, serializer): + """ + 更新材料时的权限控制 + """ + # 普通用户只能更新自己工厂的材料 + if (self.request.user.role != 'admin' and + self.request.user.factory_id != self.get_object().factory_id): + raise PermissionError("无权修改其他工厂的材料") + serializer.save() + + def perform_destroy(self, instance): + """ + 删除材料时的权限控制 + """ + # 普通用户只能删除自己工厂的材料 + if (self.request.user.role != 'admin' and + self.request.user.factory_id != instance.factory_id): + raise PermissionError("无权删除其他工厂的材料") + instance.delete() + + @action(detail=True, methods=['post']) + def submit(self, request, pk=None): + """ + 提交审核 + """ + material = self.get_object() + + # 普通用户只能提交自己工厂的材料 + if (request.user.role != 'admin' and + request.user.factory_id != material.factory_id): + return Response( + {"detail": "无权提交其他工厂的材料"}, + status=status.HTTP_403_FORBIDDEN + ) + + if material.status != 'draft': + return Response( + {"detail": "只有创建中的材料才能提交审核"}, + status=status.HTTP_400_BAD_REQUEST + ) + + material.status = 'pending' + material.save() + return Response({"status": "已提交审核"}) + + @action(detail=True, methods=['post']) + def approve(self, request, pk=None): + """ + 审核通过 + """ + # 只有管理员可以审核 + if request.user.role != 'admin': + return Response( + {"detail": "只有管理员可以审核材料"}, + status=status.HTTP_403_FORBIDDEN + ) + + material = self.get_object() + if material.status != 'pending': + return Response( + {"detail": "只有待审核的材料才能审核"}, + status=status.HTTP_400_BAD_REQUEST + ) + + material.status = 'approved' + material.save() + return Response({"status": "审核通过"}) + + @action(detail=True, methods=['post']) + def reject(self, request, pk=None): + """ + 审核拒绝 + """ + # 只有管理员可以审核 + if request.user.role != 'admin': + return Response( + {"detail": "只有管理员可以审核材料"}, + status=status.HTTP_403_FORBIDDEN + ) + + material = self.get_object() + if material.status != 'pending': + return Response( + {"detail": "只有待审核的材料才能审核"}, + status=status.HTTP_400_BAD_REQUEST + ) + + material.status = 'draft' + material.save() + return Response({"status": "审核拒绝"}) diff --git a/backend/apps/statistics/__init__.py b/backend/apps/statistics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/statistics/urls.py b/backend/apps/statistics/urls.py new file mode 100644 index 0000000..7e5cb8b --- /dev/null +++ b/backend/apps/statistics/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import overview_statistics, material_statistics, factory_statistics + +urlpatterns = [ + path('overview/', overview_statistics, name='overview-statistics'), + path('materials/', material_statistics, name='material-statistics'), + path('factories/', factory_statistics, name='factory-statistics'), +] diff --git a/backend/apps/statistics/views.py b/backend/apps/statistics/views.py new file mode 100644 index 0000000..7f2fa93 --- /dev/null +++ b/backend/apps/statistics/views.py @@ -0,0 +1,163 @@ +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.db.models import Count, Q +from apps.material.models import Material +from apps.factory.models import Factory + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def overview_statistics(request): + """ + 数据总览统计 + """ + # 只有管理员可以访问 + if request.user.role != 'admin': + return Response({"detail": "无权访问"}, status=403) + + # 材料总数 + total_materials = Material.objects.count() + + # 材料种类(材料子类数量) + total_material_categories = Material.objects.values('material_subcategory').distinct().count() + + # 品牌数(工厂数) + total_brands = Factory.objects.count() + + # 按专业类别的材料数量分布 + major_category_stats = Material.objects.values('major_category').annotate( + count=Count('id') + ).order_by('-count') + + # 按材料子类的材料数量分布 + material_subcategory_stats = Material.objects.values('material_subcategory').annotate( + count=Count('id') + ).order_by('-count')[:10] # 取前10个 + + # 按所属品牌的材料数量分布 + brand_stats = Material.objects.values('factory__factory_name').annotate( + count=Count('id') + ).order_by('-count') + + # 按地区的工厂数量分布 + region_stats = Factory.objects.values('province', 'city').annotate( + count=Count('id') + ).order_by('-count') + + # 应用案例列表(有案例描述的材料) + cases_list = Material.objects.filter( + status='approved', + cases__isnull=False, + cases__gt='' + ).values('id', 'name', 'cases', 'factory__factory_name')[:10] + + return Response({ + 'total_materials': total_materials, + 'total_material_categories': total_material_categories, + 'total_brands': total_brands, + 'major_category_stats': list(major_category_stats), + 'material_subcategory_stats': list(material_subcategory_stats), + 'brand_stats': list(brand_stats), + 'region_stats': list(region_stats), + 'cases_list': list(cases_list), + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def material_statistics(request): + """ + 材料库统计 + """ + # 只有管理员可以访问 + if request.user.role != 'admin': + return Response({"detail": "无权访问"}, status=403) + + # 获取筛选条件 + material_subcategory = request.query_params.get('material_subcategory') + + # 基础查询 + queryset = Material.objects.filter(status='approved') + + # 按材料子类筛选 + if material_subcategory: + queryset = queryset.filter(material_subcategory=material_subcategory) + + # 材料星级对比(按材料子类) + star_stats = {} + for subcategory in queryset.values_list('material_subcategory', flat=True).distinct(): + materials = queryset.filter(material_subcategory=subcategory) + star_stats[subcategory] = { + 'quality_level': list(materials.values('quality_level').annotate(count=Count('id'))), + 'durability_level': list(materials.values('durability_level').annotate(count=Count('id'))), + 'eco_level': list(materials.values('eco_level').annotate(count=Count('id'))), + 'carbon_level': list(materials.values('carbon_level').annotate(count=Count('id'))), + 'score_level': list(materials.values('score_level').annotate(count=Count('id'))), + } + + # 竞争优势与替代材料对比 + advantage_replace_stats = list(queryset.values('advantage', 'replace_type').annotate( + count=Count('id') + )) + + # 应用场景对比 + application_scene_stats = list(queryset.values('application_scene').annotate( + count=Count('id') + )) + + # 材料列表 + materials_list = list(queryset.values( + 'id', 'name', 'material_category', 'material_subcategory', + 'factory__factory_name', 'brochure' + )[:20]) + + return Response({ + 'star_stats': star_stats, + 'advantage_replace_stats': advantage_replace_stats, + 'application_scene_stats': application_scene_stats, + 'materials_list': materials_list, + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def factory_statistics(request): + """ + 工厂库统计 + """ + # 只有管理员可以访问 + if request.user.role != 'admin': + return Response({"detail": "无权访问"}, status=403) + + # 工厂地区分布 + region_stats = list(Factory.objects.values('province', 'city').annotate( + count=Count('id') + ).order_by('-count')) + + # 工厂材料分类分布 + factory_category_stats = [] + for factory in Factory.objects.all(): + material_categories = Material.objects.filter(factory=factory).values( + 'material_category' + ).annotate( + count=Count('id') + ) + + factory_category_stats.append({ + 'factory_id': factory.id, + 'factory_name': factory.factory_name, + 'categories': list(material_categories), + 'total_materials': factory.materials.count() + }) + + # 工厂列表 + factories_list = list(Factory.objects.values( + 'id', 'factory_name', 'factory_short_name', 'province', 'city', 'website' + )) + + return Response({ + 'region_stats': region_stats, + 'factory_category_stats': factory_category_stats, + 'factories_list': factories_list, + }) diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..6d42330 --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,11 @@ +""" +ASGI config for new_materials_db project. +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..38c1ea3 --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,166 @@ +""" +Django settings for new_materials_db project. +""" + +from pathlib import Path +from decouple import config +import os + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = config('SECRET_KEY', default='django-insecure-change-in-production') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = config('DEBUG', default=True, cast=bool) + +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1', cast=lambda v: [s.strip() for s in v.split(',')]) + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework_simplejwt', + 'corsheaders', + 'apps.authentication', + 'apps.factory', + 'apps.material', + 'apps.dictionary', + 'apps.statistics', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'corsheaders.middleware.CorsMiddleware', + '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': [], + '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 +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': config('DB_NAME', default='new_materials_db'), + 'USER': config('DB_USER', default='postgres'), + 'PASSWORD': config('DB_PASSWORD', default='postgres'), + 'HOST': config('DB_HOST', default='localhost'), + 'PORT': config('DB_PORT', default='5432'), + } +} + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'zh-hans' + +TIME_ZONE = 'Asia/Shanghai' + +USE_I18N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'static') + +# Media files +MEDIA_URL = 'media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Custom user model +AUTH_USER_MODEL = 'authentication.User' + +# REST Framework settings +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, +} + +# JWT settings +from datetime import timedelta + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=2), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': False, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': True, + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# CORS settings +CORS_ALLOWED_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", +] + +CORS_ALLOW_CREDENTIALS = True diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..7ba93c5 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,20 @@ +""" +URL configuration for new_materials_db project. +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/auth/', include('apps.authentication.urls')), + path('api/factory/', include('apps.factory.urls')), + path('api/material/', include('apps.material.urls')), + path('api/dictionary/', include('apps.dictionary.urls')), + path('api/statistics/', include('apps.statistics.urls')), +] + +# 开发环境下提供媒体文件服务 +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..b2d7bb7 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,11 @@ +""" +WSGI config for new_materials_db project. +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7453056 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +Django==4.2.7 +djangorestframework==3.14.0 +djangorestframework-simplejwt==5.3.0 +psycopg2-binary==2.9.9 +django-cors-headers==4.3.0 +Pillow==10.1.0 +python-decouple==3.8 diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..19d384d --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,2 @@ +# 开发环境配置 +VITE_API_BASE_URL=http://localhost:8000/api diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..a366e35 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,2 @@ +# 生产环境配置 +VITE_API_BASE_URL=/api diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..37afac2 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,74 @@ +# 新材料数据库管理系统 - 前端 + +## 项目介绍 + +新材料数据库管理系统前端,基于Vue 3 + Element Plus构建。 + +## 技术栈 + +- Vue 3 - 渐进式JavaScript框架 +- Vite - 新一代前端构建工具 +- Element Plus - 基于Vue 3的组件库 +- Vue Router - Vue.js官方路由 +- Pinia - Vue状态管理库 +- Axios - HTTP客户端 +- ECharts - 数据可视化库 + +## 安装依赖 + +```bash +npm install +``` + +## 开发模式 + +```bash +npm run dev +``` + +## 生产构建 + +```bash +npm run build +``` + +## 预览生产构建 + +```bash +npm run preview +``` + +## 项目结构 + +``` +frontend/ +├── public/ # 静态资源 +├── src/ +│ ├── api/ # API接口 +│ ├── assets/ # 资源文件 +│ ├── components/ # 公共组件 +│ ├── layout/ # 布局组件 +│ ├── router/ # 路由配置 +│ ├── stores/ # 状态管理 +│ ├── utils/ # 工具函数 +│ ├── views/ # 页面组件 +│ ├── App.vue # 根组件 +│ └── main.js # 入口文件 +├── index.html # HTML模板 +├── package.json # 项目配置 +└── vite.config.js # Vite配置 +``` + +## 环境变量 + +- 开发环境: `.env.development` +- 生产环境: `.env.production` + +## 功能模块 + +- 用户认证与授权 +- 材料库管理 +- 工厂库管理 +- 数据统计与分析 +- 字典管理 +- 用户管理 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..533ac5a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 新材料数据库管理系统 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..99acc4d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "new-materials-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.3.4", + "vue-router": "^4.2.4", + "pinia": "^2.1.6", + "axios": "^1.5.0", + "element-plus": "^2.3.14", + "echarts": "^5.4.3", + "@element-plus/icons-vue": "^2.1.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.3.4", + "vite": "^4.4.9", + "sass": "^1.66.1" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..ee9fada --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..16e6a07 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..8d81666 --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,102 @@ +import request from '@/utils/request' + +/** + * 用户登录 + * @param {Object} credentials - 登录凭证 { username, password } + * @returns {Promise} - 返回包含token和用户信息的Promise + */ +export function login(credentials) { + return request({ + url: '/auth/login/', + method: 'post', + data: credentials + }) +} + +/** + * 获取当前用户信息 + * @returns {Promise} - 返回用户信息的Promise + */ +export function getCurrentUser() { + return request({ + url: '/auth/user/', + method: 'get' + }) +} + +/** + * 刷新token + * @param {String} refreshToken - 刷新令牌 + * @returns {Promise} - 返回新token的Promise + */ +export function refreshToken(refreshToken) { + return request({ + url: '/auth/token/refresh/', + method: 'post', + data: { refresh: refreshToken } + }) +} + +/** + * 获取用户列表 + * @param {Object} params - 查询参数 + * @returns {Promise} - 返回用户列表的Promise + */ +export function getUserList(params) { + return request({ + url: '/auth/users/', + method: 'get', + params + }) +} + +/** + * 创建用户 + * @param {Object} data - 用户数据 + * @returns {Promise} - 返回创建的用户信息的Promise + */ +export function createUser(data) { + return request({ + url: '/auth/users/', + method: 'post', + data + }) +} + +/** + * 获取用户详情 + * @param {Number} id - 用户ID + * @returns {Promise} - 返回用户详情的Promise + */ +export function getUserDetail(id) { + return request({ + url: `/auth/users/${id}/`, + method: 'get' + }) +} + +/** + * 更新用户信息 + * @param {Number} id - 用户ID + * @param {Object} data - 更新的用户数据 + * @returns {Promise} - 返回更新后的用户信息的Promise + */ +export function updateUser(id, data) { + return request({ + url: `/auth/users/${id}/`, + method: 'put', + data + }) +} + +/** + * 删除用户 + * @param {Number} id - 用户ID + * @returns {Promise} - 返回删除结果的Promise + */ +export function deleteUser(id) { + return request({ + url: `/auth/users/${id}/`, + method: 'delete' + }) +} diff --git a/frontend/src/api/dictionary.js b/frontend/src/api/dictionary.js new file mode 100644 index 0000000..f4282f4 --- /dev/null +++ b/frontend/src/api/dictionary.js @@ -0,0 +1,76 @@ +import request from '@/utils/request' + +/** + * 获取字典列表 + * @param {Object} params - 查询参数 + * @returns {Promise} - 返回字典列表的Promise + */ +export function getDictionaryList(params) { + return request({ + url: '/dictionary/', + method: 'get', + params + }) +} + +/** + * 获取分组字典 + * @returns {Promise} - 返回分组字典的Promise + */ +export function getDictionaryGrouped() { + return request({ + url: '/dictionary/grouped/', + method: 'get' + }) +} + +/** + * 获取字典详情 + * @param {Number} id - 字典ID + * @returns {Promise} - 返回字典详情的Promise + */ +export function getDictionaryDetail(id) { + return request({ + url: `/dictionary/${id}/`, + method: 'get' + }) +} + +/** + * 创建字典 + * @param {Object} data - 字典数据 + * @returns {Promise} - 返回创建的字典信息的Promise + */ +export function createDictionary(data) { + return request({ + url: '/dictionary/', + method: 'post', + data + }) +} + +/** + * 更新字典信息 + * @param {Number} id - 字典ID + * @param {Object} data - 更新的字典数据 + * @returns {Promise} - 返回更新后的字典信息的Promise + */ +export function updateDictionary(id, data) { + return request({ + url: `/dictionary/${id}/`, + method: 'put', + data + }) +} + +/** + * 删除字典 + * @param {Number} id - 字典ID + * @returns {Promise} - 返回删除结果的Promise + */ +export function deleteDictionary(id) { + return request({ + url: `/dictionary/${id}/`, + method: 'delete' + }) +} diff --git a/frontend/src/api/factory.js b/frontend/src/api/factory.js new file mode 100644 index 0000000..240f827 --- /dev/null +++ b/frontend/src/api/factory.js @@ -0,0 +1,76 @@ +import request from '@/utils/request' + +/** + * 获取工厂列表 + * @param {Object} params - 查询参数 + * @returns {Promise} - 返回工厂列表的Promise + */ +export function getFactoryList(params) { + return request({ + url: '/factory/', + method: 'get', + params + }) +} + +/** + * 获取简化工厂列表(用于下拉选择) + * @returns {Promise} - 返回简化工厂列表的Promise + */ +export function getFactoryListSimple() { + return request({ + url: '/factory/simple/', + method: 'get' + }) +} + +/** + * 获取工厂详情 + * @param {Number} id - 工厂ID + * @returns {Promise} - 返回工厂详情的Promise + */ +export function getFactoryDetail(id) { + return request({ + url: `/factory/${id}/`, + method: 'get' + }) +} + +/** + * 创建工厂 + * @param {Object} data - 工厂数据 + * @returns {Promise} - 返回创建的工厂信息的Promise + */ +export function createFactory(data) { + return request({ + url: '/factory/', + method: 'post', + data + }) +} + +/** + * 更新工厂信息 + * @param {Number} id - 工厂ID + * @param {Object} data - 更新的工厂数据 + * @returns {Promise} - 返回更新后的工厂信息的Promise + */ +export function updateFactory(id, data) { + return request({ + url: `/factory/${id}/`, + method: 'put', + data + }) +} + +/** + * 删除工厂 + * @param {Number} id - 工厂ID + * @returns {Promise} - 返回删除结果的Promise + */ +export function deleteFactory(id) { + return request({ + url: `/factory/${id}/`, + method: 'delete' + }) +} diff --git a/frontend/src/api/material.js b/frontend/src/api/material.js new file mode 100644 index 0000000..7d620e7 --- /dev/null +++ b/frontend/src/api/material.js @@ -0,0 +1,101 @@ +import request from '@/utils/request' + +/** + * 获取材料列表 + * @param {Object} params - 查询参数 + * @returns {Promise} - 返回材料列表的Promise + */ +export function getMaterialList(params) { + return request({ + url: '/material/', + method: 'get', + params + }) +} + +/** + * 获取材料详情 + * @param {Number} id - 材料ID + * @returns {Promise} - 返回材料详情的Promise + */ +export function getMaterialDetail(id) { + return request({ + url: `/material/${id}/`, + method: 'get' + }) +} + +/** + * 创建材料 + * @param {Object} data - 材料数据 + * @returns {Promise} - 返回创建的材料信息的Promise + */ +export function createMaterial(data) { + return request({ + url: '/material/', + method: 'post', + data + }) +} + +/** + * 更新材料信息 + * @param {Number} id - 材料ID + * @param {Object} data - 更新的材料数据 + * @returns {Promise} - 返回更新后的材料信息的Promise + */ +export function updateMaterial(id, data) { + return request({ + url: `/material/${id}/`, + method: 'put', + data + }) +} + +/** + * 删除材料 + * @param {Number} id - 材料ID + * @returns {Promise} - 返回删除结果的Promise + */ +export function deleteMaterial(id) { + return request({ + url: `/material/${id}/`, + method: 'delete' + }) +} + +/** + * 提交材料审核 + * @param {Number} id - 材料ID + * @returns {Promise} - 返回提交结果的Promise + */ +export function submitMaterial(id) { + return request({ + url: `/material/${id}/submit/`, + method: 'post' + }) +} + +/** + * 审核通过材料 + * @param {Number} id - 材料ID + * @returns {Promise} - 返回审核结果的Promise + */ +export function approveMaterial(id) { + return request({ + url: `/material/${id}/approve/`, + method: 'post' + }) +} + +/** + * 审核拒绝材料 + * @param {Number} id - 材料ID + * @returns {Promise} - 返回审核结果的Promise + */ +export function rejectMaterial(id) { + return request({ + url: `/material/${id}/reject/`, + method: 'post' + }) +} diff --git a/frontend/src/api/statistics.js b/frontend/src/api/statistics.js new file mode 100644 index 0000000..2a0c8e3 --- /dev/null +++ b/frontend/src/api/statistics.js @@ -0,0 +1,36 @@ +import request from '@/utils/request' + +/** + * 获取数据总览统计 + * @returns {Promise} - 返回数据总览统计的Promise + */ +export function getOverviewStatistics() { + return request({ + url: '/statistics/overview/', + method: 'get' + }) +} + +/** + * 获取材料库统计 + * @param {Object} params - 查询参数 + * @returns {Promise} - 返回材料库统计的Promise + */ +export function getMaterialStatistics(params) { + return request({ + url: '/statistics/materials/', + method: 'get', + params + }) +} + +/** + * 获取工厂库统计 + * @returns {Promise} - 返回工厂库统计的Promise + */ +export function getFactoryStatistics() { + return request({ + url: '/statistics/factories/', + method: 'get' + }) +} diff --git a/frontend/src/assets/styles/main.scss b/frontend/src/assets/styles/main.scss new file mode 100644 index 0000000..c072757 --- /dev/null +++ b/frontend/src/assets/styles/main.scss @@ -0,0 +1,98 @@ +/* 全局样式 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #app { + width: 100%; + height: 100%; + font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; +} + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Element Plus 样式覆盖 */ +.el-card { + border-radius: 8px; + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); + + .el-card__header { + padding: 16px 20px; + border-bottom: 1px solid #ebeef5; + + .card-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 16px; + font-weight: bold; + } + } + + .el-card__body { + padding: 20px; + } +} + +.el-table { + font-size: 14px; + + th { + background-color: #f5f7fa; + color: #606266; + font-weight: bold; + } +} + +.el-pagination { + .el-pagination__sizes { + margin-right: 10px; + } +} + +.el-dialog { + border-radius: 8px; + + .el-dialog__header { + padding: 16px 20px; + border-bottom: 1px solid #ebeef5; + } + + .el-dialog__body { + padding: 20px; + } + + .el-dialog__footer { + padding: 16px 20px; + border-top: 1px solid #ebeef5; + } +} + +.el-form-item__label { + font-weight: 500; +} + +.el-divider__text { + font-weight: bold; + color: #303133; +} diff --git a/frontend/src/components/FactoryForm.vue b/frontend/src/components/FactoryForm.vue new file mode 100644 index 0000000..77a183c --- /dev/null +++ b/frontend/src/components/FactoryForm.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/frontend/src/components/MaterialForm.vue b/frontend/src/components/MaterialForm.vue new file mode 100644 index 0000000..77e82db --- /dev/null +++ b/frontend/src/components/MaterialForm.vue @@ -0,0 +1,440 @@ + + + + + diff --git a/frontend/src/components/UserForm.vue b/frontend/src/components/UserForm.vue new file mode 100644 index 0000000..2976021 --- /dev/null +++ b/frontend/src/components/UserForm.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/frontend/src/layout/MainLayout.vue b/frontend/src/layout/MainLayout.vue new file mode 100644 index 0000000..3d2ddad --- /dev/null +++ b/frontend/src/layout/MainLayout.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..15f4e71 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import App from './App.vue' +import router from './router' +import './assets/styles/main.scss' + +const app = createApp(App) + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..8b9c0c5 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,90 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useUserStore } from '@/stores/user' +import { ElMessage } from 'element-plus' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { requiresAuth: false } + }, + { + path: '/', + component: () => import('@/layout/MainLayout.vue'), + redirect: '/dashboard', + meta: { requiresAuth: true }, + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: '数据总览', requiresAuth: true, requiresAdmin: true } + }, + { + path: 'materials', + name: 'Materials', + component: () => import('@/views/Materials.vue'), + meta: { title: '材料库', requiresAuth: true } + }, + { + path: 'materials/:id', + name: 'MaterialDetail', + component: () => import('@/views/MaterialDetail.vue'), + meta: { title: '材料详情', requiresAuth: true } + }, + { + path: 'factories', + name: 'Factories', + component: () => import('@/views/Factories.vue'), + meta: { title: '工厂库', requiresAuth: true } + }, + { + path: 'factories/:id', + name: 'FactoryDetail', + component: () => import('@/views/FactoryDetail.vue'), + meta: { title: '工厂详情', requiresAuth: true } + }, + { + path: 'users', + name: 'Users', + component: () => import('@/views/Users.vue'), + meta: { title: '用户管理', requiresAuth: true, requiresAdmin: true } + }, + { + path: 'dictionaries', + name: 'Dictionaries', + component: () => import('@/views/Dictionaries.vue'), + meta: { title: '字典管理', requiresAuth: true, requiresAdmin: true } + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const userStore = useUserStore() + + // 检查是否需要登录 + if (to.meta.requiresAuth && !userStore.isLoggedIn) { + ElMessage.warning('请先登录') + next('/login') + return + } + + // 检查是否需要管理员权限 + if (to.meta.requiresAdmin && !userStore.isAdmin) { + ElMessage.warning('需要管理员权限') + next('/dashboard') + return + } + + next() +}) + +export default router diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js new file mode 100644 index 0000000..a9a42db --- /dev/null +++ b/frontend/src/stores/user.js @@ -0,0 +1,72 @@ +import { defineStore } from 'pinia' +import { login as loginApi, getCurrentUser as getCurrentUserApi } from '@/api/auth' +import { ElMessage } from 'element-plus' + +export const useUserStore = defineStore('user', { + state: () => ({ + token: localStorage.getItem('token') || '', + refreshToken: localStorage.getItem('refreshToken') || '', + userInfo: JSON.parse(localStorage.getItem('userInfo') || 'null'), + }), + + getters: { + isLoggedIn: (state) => !!state.token, + isAdmin: (state) => state.userInfo && state.userInfo.role === 'admin', + factoryId: (state) => state.userInfo ? state.userInfo.factory : null, + }, + + actions: { + // 登录 + async login(credentials) { + try { + const response = await loginApi(credentials) + const { access, refresh, user } = response + + this.token = access + this.refreshToken = refresh + this.userInfo = user + + // 保存到本地存储 + localStorage.setItem('token', access) + localStorage.setItem('refreshToken', refresh) + localStorage.setItem('userInfo', JSON.stringify(user)) + + ElMessage.success('登录成功') + return user + } catch (error) { + ElMessage.error(error.message || '登录失败') + throw error + } + }, + + // 登出 + logout() { + this.token = '' + this.refreshToken = '' + this.userInfo = null + + localStorage.removeItem('token') + localStorage.removeItem('refreshToken') + localStorage.removeItem('userInfo') + }, + + // 检查认证状态 + async checkAuth() { + if (this.token) { + try { + const user = await getCurrentUserApi() + this.userInfo = user + localStorage.setItem('userInfo', JSON.stringify(user)) + } catch (error) { + this.logout() + } + } + }, + + // 更新用户信息 + updateUserInfo(userInfo) { + this.userInfo = { ...this.userInfo, ...userInfo } + localStorage.setItem('userInfo', JSON.stringify(this.userInfo)) + } + } +}) diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js new file mode 100644 index 0000000..03616a4 --- /dev/null +++ b/frontend/src/utils/request.js @@ -0,0 +1,73 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import router from '@/router' +import { useUserStore } from '@/stores/user' + +// 创建axios实例 +const service = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL, + timeout: 10000 +}) + +// 请求拦截器 +service.interceptors.request.use( + config => { + const userStore = useUserStore() + if (userStore.token) { + config.headers['Authorization'] = `Bearer ${userStore.token}` + } + return config + }, + error => { + console.error('请求错误:', error) + return Promise.reject(error) + } +) + +// 响应拦截器 +service.interceptors.response.use( + response => { + const res = response.data + + // 如果响应的状态码不是200,则判断为错误 + if (response.status !== 200) { + ElMessage({ + message: res.message || '请求失败', + type: 'error', + duration: 5 * 1000 + }) + return Promise.reject(new Error(res.message || '请求失败')) + } else { + return res + } + }, + error => { + console.error('响应错误:', error) + + if (error.response) { + const { status, data } = error.response + + if (status === 401) { + // 未授权,跳转到登录页 + const userStore = useUserStore() + userStore.logout() + router.push('/login') + ElMessage.error('登录已过期,请重新登录') + } else if (status === 403) { + ElMessage.error('没有权限访问该资源') + } else if (status === 404) { + ElMessage.error('请求的资源不存在') + } else if (status === 500) { + ElMessage.error('服务器错误') + } else { + ElMessage.error(data.detail || '请求失败') + } + } else { + ElMessage.error('网络错误,请检查网络连接') + } + + return Promise.reject(error) + } +) + +export default service diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..bcfaa33 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,404 @@ + + + + + diff --git a/frontend/src/views/Dictionaries.vue b/frontend/src/views/Dictionaries.vue new file mode 100644 index 0000000..c087907 --- /dev/null +++ b/frontend/src/views/Dictionaries.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/frontend/src/views/Factories.vue b/frontend/src/views/Factories.vue new file mode 100644 index 0000000..519ebb0 --- /dev/null +++ b/frontend/src/views/Factories.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/frontend/src/views/FactoryDetail.vue b/frontend/src/views/FactoryDetail.vue new file mode 100644 index 0000000..bcc762f --- /dev/null +++ b/frontend/src/views/FactoryDetail.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..74b45cf --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/views/MaterialDetail.vue b/frontend/src/views/MaterialDetail.vue new file mode 100644 index 0000000..9171f0f --- /dev/null +++ b/frontend/src/views/MaterialDetail.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue new file mode 100644 index 0000000..92a8952 --- /dev/null +++ b/frontend/src/views/Materials.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue new file mode 100644 index 0000000..ece4faf --- /dev/null +++ b/frontend/src/views/Users.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..d99fb5d --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true + } + } + } +})