feat: add email notice and settings page but still have some issue

This commit is contained in:
2025-09-20 13:09:06 +08:00
parent 8f2ab9aeda
commit b715c8bb52
15 changed files with 1595 additions and 3 deletions
+2
View File
@@ -0,0 +1,2 @@
+22
View File
@@ -0,0 +1,22 @@
from django.contrib import admin
from .models import NotificationEmail, NotificationSettings
@admin.register(NotificationEmail)
class NotificationEmailAdmin(admin.ModelAdmin):
list_display = ('email', 'is_enabled', 'description', 'created_at')
list_filter = ('is_enabled', 'created_at')
search_fields = ('email', 'description')
list_editable = ('is_enabled',)
ordering = ('-created_at',)
@admin.register(NotificationSettings)
class NotificationSettingsAdmin(admin.ModelAdmin):
list_display = ('email_notification_enabled', 'updated_at')
def has_add_permission(self, request):
# 只允许存在一个设置实例
return not NotificationSettings.objects.exists()
def has_delete_permission(self, request, obj=None):
# 不允许删除设置
return False
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EmailConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'email_notice'
+297
View File
@@ -0,0 +1,297 @@
from django.utils.deprecation import MiddlewareMixin
from django.http import JsonResponse
from .services import EmailNotificationService
import json
import logging
import threading
from django.core.exceptions import ObjectDoesNotExist
logger = logging.getLogger(__name__)
class EmailNotificationMiddleware(MiddlewareMixin):
"""邮件通知中间件"""
def __init__(self, get_response):
self.get_response = get_response
super().__init__(get_response)
# 用于存储删除前的数据
self._delete_data_cache = {}
def process_request(self, request):
"""在处理请求前,如果是删除操作,先获取要删除的数据"""
try:
# 只处理DELETE操作
if request.method != 'DELETE':
return None
# 只处理API请求
if not request.path.startswith('/api/'):
return None
# 从URL路径中提取ID
path_parts = request.path.split('/')
record_id = None
for part in path_parts:
if part.isdigit():
record_id = part
break
if not record_id:
return None
# 根据路径类型获取对应的数据
if '/api/items/' in request.path:
data = self._get_item_data_before_delete(record_id)
if data:
self._delete_data_cache[f"item_{record_id}"] = data
elif '/api/records/' in request.path or '/api/finance/' in request.path:
data = self._get_finance_data_before_delete(record_id)
if data:
self._delete_data_cache[f"finance_{record_id}"] = data
except Exception as e:
logger.error(f"获取删除前数据失败: {e}")
return None
def _get_item_data_before_delete(self, item_id):
"""获取物品删除前的数据"""
try:
from items.models import Item
item = Item.objects.get(id=item_id)
return {
'id': item.id,
'name': item.name,
'serial_number': item.serial_number,
'status': item.status,
'category': item.category,
'owner': item.owner,
'purchase_date': str(item.purchase_date) if item.purchase_date else '',
'purchase_price': str(item.purchase_price) if item.purchase_price else '',
'description': item.description,
'updated_at': str(item.updated_at) if hasattr(item, 'updated_at') else '',
}
except Exception as e:
logger.error(f"获取物品数据失败: {e}")
return None
def _get_finance_data_before_delete(self, record_id):
"""获取财务记录删除前的数据"""
try:
from finance.models import FinanceRecord
record = FinanceRecord.objects.get(id=record_id)
return {
'id': record.id,
'amount': str(record.amount),
'transaction_type': record.transaction_type,
'description': record.description,
'department': record.department.name if record.department else '',
'category': record.category.name if record.category else '',
'approver': record.approver,
'transaction_date': str(record.transaction_date),
'proof_images': [str(img.image) for img in record.proof_images.all()] if hasattr(record, 'proof_images') else []
}
except Exception as e:
logger.error(f"获取财务记录数据失败: {e}")
return None
def process_response(self, request, response):
"""处理响应,在数据修改操作成功后异步发送邮件通知"""
try:
# 只处理API请求
if not request.path.startswith('/api/'):
return response
# 只处理数据修改操作
if request.method not in ['POST', 'PUT', 'PATCH', 'DELETE']:
return response
# 只处理成功的响应
if not (200 <= response.status_code < 300):
return response
# 获取用户信息
user_info = self._get_user_info(request)
# 异步处理邮件通知,避免阻塞API响应
if '/api/items/' in request.path:
self._handle_item_operation_async(request, response, user_info)
elif '/api/records/' in request.path or '/api/finance/' in request.path:
self._handle_finance_operation_async(request, response, user_info)
except Exception as e:
logger.error(f"邮件通知中间件处理失败: {e}")
return response
def _get_user_info(self, request):
"""获取用户信息"""
try:
if hasattr(request, 'user') and request.user.is_authenticated:
return f"{request.user.username} ({request.user.email})"
else:
return f"匿名用户 (IP: {self._get_client_ip(request)})"
except:
return "未知用户"
def _get_client_ip(self, request):
"""获取客户端IP"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def _handle_item_operation_async(self, request, response, user_info):
"""异步处理物品操作"""
def send_notification():
try:
operation_type = self._get_operation_type(request.method, request.path)
# 对于DELETE操作,使用预先获取的数据
if request.method == 'DELETE':
# 从URL路径中提取ID
path_parts = request.path.split('/')
item_id = None
for part in path_parts:
if part.isdigit():
item_id = part
break
# 尝试从缓存中获取删除前的数据
cached_data = self._delete_data_cache.get(f"item_{item_id}")
if cached_data:
notification_data = {
'id': cached_data.get('id', item_id),
'name': f"[已删除] {cached_data.get('name', '未知物品')}",
'serial_number': cached_data.get('serial_number', ''),
'status': f"[删除前状态: {cached_data.get('status', '未知')}]",
'category': cached_data.get('category', ''),
'owner': cached_data.get('owner', ''),
'purchase_date': cached_data.get('purchase_date', ''),
'purchase_price': cached_data.get('purchase_price', ''),
'description': f"删除前描述: {cached_data.get('description', '')}",
'timestamp': cached_data.get('updated_at', ''),
'operation_path': request.path,
'operation_method': request.method
}
else:
# 尝试从响应中获取数据
if hasattr(response, 'data'):
item_data = response.data
else:
try:
content = response.content.decode('utf-8')
item_data = json.loads(content) if content else {}
except:
item_data = {}
# 确保item_data不为None
if item_data is None:
item_data = {}
# 构建通知数据
notification_data = {
'id': item_data.get('id', ''),
'name': item_data.get('name', ''),
'serial_number': item_data.get('serial_number', ''),
'status': item_data.get('status', ''),
'category': item_data.get('category', ''),
'owner': item_data.get('owner', ''),
'timestamp': item_data.get('updated_at', ''),
'operation_path': request.path,
'operation_method': request.method
}
EmailNotificationService.send_operation_notification(
operation_type, '物品', notification_data, user_info
)
except Exception as e:
logger.error(f"异步处理物品操作通知失败: {e} 如果需要查看删除前的数据,请检查以前的邮件。")
# 创建并启动后台线程
thread = threading.Thread(target=send_notification)
thread.daemon = True # 设置为守护线程
thread.start()
def _handle_finance_operation_async(self, request, response, user_info):
"""异步处理财务操作"""
def send_notification():
try:
operation_type = self._get_operation_type(request.method, request.path)
# 对于DELETE操作,响应通常不包含数据,需要从URL路径中提取ID
if request.method == 'DELETE':
# 从URL路径中提取ID
path_parts = request.path.split('/')
record_id = None
for part in path_parts:
if part.isdigit():
record_id = part
break
notification_data = {
'id': record_id or '未知',
'amount': '已删除记录',
'transaction_type': '已删除',
'description': '财务记录已被删除',
'department': '未知',
'category': '未知',
'approver': '未知',
'timestamp': '删除时间未知',
'operation_path': request.path,
'operation_method': request.method
}
else:
# 尝试从响应中获取数据
if hasattr(response, 'data'):
finance_data = response.data
else:
try:
content = response.content.decode('utf-8')
finance_data = json.loads(content) if content else {}
except:
finance_data = {}
# 确保finance_data不为None
if finance_data is None:
finance_data = {}
# 构建通知数据
notification_data = {
'id': finance_data.get('id', ''),
'amount': finance_data.get('amount', ''),
'transaction_type': finance_data.get('transaction_type', ''),
'description': finance_data.get('description', ''),
'department': finance_data.get('department', ''),
'category': finance_data.get('category', ''),
'approver': finance_data.get('approver', ''),
'timestamp': finance_data.get('transaction_date', ''),
'operation_path': request.path,
'operation_method': request.method
}
EmailNotificationService.send_operation_notification(
operation_type, '财务记录', notification_data, user_info
)
except Exception as e:
logger.error(f"异步处理财务操作通知失败: {e} 如果需要查看删除前的数据,请检查以前的邮件。")
# 创建并启动后台线程
thread = threading.Thread(target=send_notification)
thread.daemon = True # 设置为守护线程
thread.start()
def _get_operation_type(self, method, path):
"""根据HTTP方法和路径判断操作类型"""
if method == 'POST':
return 'CREATE'
elif method in ['PUT', 'PATCH']:
return 'UPDATE'
elif method == 'DELETE':
return 'DELETE'
else:
return 'UNKNOWN'
+66
View File
@@ -0,0 +1,66 @@
from django.db import models
from django.core.validators import EmailValidator
class NotificationEmail(models.Model):
"""通知邮箱模型"""
email = models.EmailField(
unique=True,
validators=[EmailValidator()],
verbose_name='邮箱地址'
)
is_enabled = models.BooleanField(
default=True,
verbose_name='是否启用'
)
description = models.CharField(
max_length=200,
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 = '通知邮箱'
ordering = ['-created_at']
def __str__(self):
status = "启用" if self.is_enabled else "禁用"
return f"{self.email} ({status})"
class NotificationSettings(models.Model):
"""通知设置模型"""
email_notification_enabled = models.BooleanField(
default=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 = '通知设置'
def __str__(self):
status = "开启" if self.email_notification_enabled else "关闭"
return f"邮件通知: {status}"
@classmethod
def get_settings(cls):
"""获取通知设置(单例模式)"""
settings, created = cls.objects.get_or_create(pk=1)
return settings
+232
View File
@@ -0,0 +1,232 @@
from django.core.mail import send_mail
from django.conf import settings
import json
import logging
logger = logging.getLogger(__name__)
class EmailNotificationService:
"""邮件通知服务"""
@staticmethod
def get_notification_settings():
"""获取通知设置"""
try:
from .models import NotificationEmail, NotificationSettings
# 获取全局设置
global_settings = NotificationSettings.get_settings()
# 获取启用的邮箱列表
enabled_emails = NotificationEmail.objects.filter(is_enabled=True).values_list('email', flat=True)
return {
'email_enabled': global_settings.email_notification_enabled,
'notification_emails': list(enabled_emails)
}
except Exception as e:
logger.error(f"获取通知设置失败: {e}")
return {'email_enabled': False, 'notification_emails': []}
@staticmethod
def send_operation_notification(operation_type, model_name, instance_data, user_info=None):
"""
发送操作通知邮件到多个邮箱
Args:
operation_type: 操作类型 ('CREATE', 'UPDATE', 'DELETE')
model_name: 模型名称 (如 'Item', 'FinanceRecord')
instance_data: 实例数据
user_info: 操作用户信息
"""
try:
settings_data = EmailNotificationService.get_notification_settings()
if not settings_data.get('email_enabled') or not settings_data.get('notification_emails'):
return
notification_emails = settings_data.get('notification_emails', [])
if not notification_emails:
logger.warning("没有配置启用的通知邮箱")
return
# 构建邮件内容
subject = f"[爱特工作室管理系统] {model_name}数据变更通知"
operation_map = {
'CREATE': '创建',
'UPDATE': '更新',
'DELETE': '删除'
}
operation_text = operation_map.get(operation_type, operation_type)
message = f"""
系统数据变更通知
操作类型: {operation_text}
数据类型: {model_name}
操作时间: {instance_data.get('timestamp', '未知')}
操作用户: {user_info or '系统'}
变更详情:
{json.dumps(instance_data, ensure_ascii=False, indent=2)}
---
此邮件由爱特工作室物品管理及财务管理系统自动发送
"""
# 发送邮件到所有启用的通知邮箱
for email in notification_emails:
if email.strip(): # 确保邮箱不为空
try:
send_mail(
subject=subject,
message=message,
from_email=settings.EMAIL_HOST_USER,
recipient_list=[email.strip()],
fail_silently=False,
)
logger.info(f"邮件通知发送成功到 {email}: {operation_type} {model_name}")
except Exception as e:
logger.error(f"发送邮件到 {email} 失败: {e}")
except Exception as e:
logger.error(f"发送邮件通知失败: {e}")
@staticmethod
def send_item_operation_notification(operation_type, item_instance, user_info=None):
"""发送物品操作通知"""
try:
instance_data = {
'id': getattr(item_instance, 'id', None),
'name': getattr(item_instance, 'name', ''),
'serial_number': getattr(item_instance, 'serial_number', ''),
'status': getattr(item_instance, 'status', ''),
'category': getattr(item_instance, 'category', ''),
'owner': getattr(item_instance, 'owner', ''),
'timestamp': str(getattr(item_instance, 'updated_at', '')),
}
EmailNotificationService.send_operation_notification(
operation_type, '物品', instance_data, user_info
)
except Exception as e:
logger.error(f"发送物品操作通知失败: {e}")
@staticmethod
def send_finance_operation_notification(operation_type, finance_instance, user_info=None):
"""发送财务记录操作通知"""
try:
instance_data = {
'id': getattr(finance_instance, 'id', None),
'amount': str(getattr(finance_instance, 'amount', '')),
'transaction_type': getattr(finance_instance, 'transaction_type', ''),
'description': getattr(finance_instance, 'description', ''),
'department': str(getattr(finance_instance, 'department', '')),
'category': str(getattr(finance_instance, 'category', '')),
'approver': getattr(finance_instance, 'approver', ''),
'timestamp': str(getattr(finance_instance, 'transaction_date', '')),
}
EmailNotificationService.send_operation_notification(
operation_type, '财务记录', instance_data, user_info
)
except Exception as e:
logger.error(f"发送财务记录操作通知失败: {e}")
@staticmethod
def update_notification_emails(emails_data):
"""
更新通知邮箱列表(使用数据库存储)
Args:
emails_data: 邮箱数据列表,格式:[{'email': 'xxx@xxx.com', 'is_enabled': True, 'description': '描述'}]
"""
try:
from .models import NotificationEmail
# 清空现有邮箱
NotificationEmail.objects.all().delete()
# 添加新的邮箱
for email_info in emails_data:
if isinstance(email_info, str):
# 兼容旧格式(纯邮箱字符串)
NotificationEmail.objects.create(
email=email_info.strip(),
is_enabled=True
)
elif isinstance(email_info, dict):
# 新格式(包含详细信息)
NotificationEmail.objects.create(
email=email_info.get('email', '').strip(),
is_enabled=email_info.get('is_enabled', True),
description=email_info.get('description', '')
)
logger.info(f"通知邮箱配置已更新: {len(emails_data)} 个邮箱")
return True
except Exception as e:
logger.error(f"更新通知邮箱配置失败: {e}")
return False
@staticmethod
def update_global_notification_setting(enabled):
"""
更新全局邮件通知开关
Args:
enabled: 是否启用邮件通知
"""
try:
from .models import NotificationSettings
settings = NotificationSettings.get_settings()
settings.email_notification_enabled = enabled
settings.save()
logger.info(f"全局邮件通知设置已更新: {enabled}")
return True
except Exception as e:
logger.error(f"更新全局邮件通知设置失败: {e}")
return False
@staticmethod
def get_all_notification_emails():
"""获取所有通知邮箱(包括禁用的)"""
try:
from .models import NotificationEmail
emails = NotificationEmail.objects.all().values(
'id', 'email', 'is_enabled', 'description', 'created_at', 'updated_at'
)
return list(emails)
except Exception as e:
logger.error(f"获取通知邮箱列表失败: {e}")
return []
@staticmethod
def toggle_email_status(email_id, is_enabled):
"""
切换指定邮箱的启用状态
Args:
email_id: 邮箱ID
is_enabled: 是否启用
"""
try:
from .models import NotificationEmail
email_obj = NotificationEmail.objects.get(id=email_id)
email_obj.is_enabled = is_enabled
email_obj.save()
logger.info(f"邮箱 {email_obj.email} 状态已更新: {is_enabled}")
return True
except NotificationEmail.DoesNotExist:
logger.error(f"邮箱ID {email_id} 不存在")
return False
except Exception as e:
logger.error(f"更新邮箱状态失败: {e}")
return False
+47
View File
@@ -0,0 +1,47 @@
from django.test import TestCase
from django.core import mail
from .models import NotificationEmail, NotificationSettings
from .services import EmailNotificationService
class EmailNotificationTestCase(TestCase):
def setUp(self):
"""设置测试数据"""
self.email1 = NotificationEmail.objects.create(
email="test1@example.com",
is_enabled=True,
description="测试邮箱1"
)
self.email2 = NotificationEmail.objects.create(
email="test2@example.com",
is_enabled=False,
description="测试邮箱2"
)
def test_get_notification_settings(self):
"""测试获取通知设置"""
settings = EmailNotificationService.get_notification_settings()
self.assertTrue(settings['email_enabled'])
self.assertIn("test1@example.com", settings['notification_emails'])
self.assertNotIn("test2@example.com", settings['notification_emails'])
def test_toggle_email_status(self):
"""测试切换邮箱状态"""
result = EmailNotificationService.toggle_email_status(self.email2.id, True)
self.assertTrue(result)
self.email2.refresh_from_db()
self.assertTrue(self.email2.is_enabled)
def test_update_notification_emails(self):
"""测试更新通知邮箱"""
emails_data = [
{'email': 'new1@example.com', 'is_enabled': True, 'description': '新邮箱1'},
{'email': 'new2@example.com', 'is_enabled': False, 'description': '新邮箱2'}
]
result = EmailNotificationService.update_notification_emails(emails_data)
self.assertTrue(result)
# 检查数据库中的邮箱数量
self.assertEqual(NotificationEmail.objects.count(), 2)
self.assertTrue(NotificationEmail.objects.filter(email='new1@example.com').exists())
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = 'email_notice'
urlpatterns = [
path('api/notification-settings/', views.notification_settings, name='notification_settings'),
path('api/toggle-email-status/', views.toggle_email_status, name='toggle_email_status'),
]
+118
View File
@@ -0,0 +1,118 @@
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .services import EmailNotificationService
import json
import re
@csrf_exempt
@require_http_methods(["POST", "GET"])
def notification_settings(request):
"""通知设置API"""
if request.method == 'GET':
try:
settings_data = EmailNotificationService.get_notification_settings()
all_emails = EmailNotificationService.get_all_notification_emails()
return JsonResponse({
'success': True,
'data': {
'email_enabled': settings_data.get('email_enabled', False),
'notification_emails': settings_data.get('notification_emails', []),
'all_emails': all_emails
}
})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)}, status=500)
elif request.method == 'POST':
try:
data = json.loads(request.body)
emails_to_update = data.get('notification_emails')
email_enabled_status = data.get('email_enabled')
# 标志位,用于判断是否执行了任何更新操作
was_updated = False
# 1. 如果请求中包含 'notification_emails' 键,则处理邮箱列表
if emails_to_update is not None: # 允许 emails_to_update 为空列表 []
email_regex = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$')
valid_emails_data = []
for email_info in emails_to_update:
# 兼容前端可能发送的字符串或对象格式
if isinstance(email_info, str):
if email_info.strip() and email_regex.match(email_info.strip()):
valid_emails_data.append({
'email': email_info.strip(),
'is_enabled': True,
'description': ''
})
elif isinstance(email_info, dict):
email = email_info.get('email', '').strip()
if email and email_regex.match(email):
valid_emails_data.append({
'email': email,
'is_enabled': email_info.get('is_enabled', True),
'description': email_info.get('description', '')
})
# 调用服务层更新邮箱,服务层需要能处理空列表
EmailNotificationService.update_notification_emails(valid_emails_data)
was_updated = True
if email_enabled_status is not None:
EmailNotificationService.update_global_notification_setting(email_enabled_status)
was_updated = True
# 执行更新成功,返回成功
if was_updated:
return JsonResponse({
'success': True,
'message': '通知设置已更新',
# 返回最新的数据状态
'data': {
'email_enabled': EmailNotificationService.get_notification_settings().get('email_enabled', False),
'all_emails': EmailNotificationService.get_all_notification_emails()
}
})
else:
# 如果请求体为空或不包含任何有效键,则返回错误
return JsonResponse({'success': False, 'error': '未提供任何有效的更新数据'}, status=400)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': '无效的JSON数据'}, status=400)
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)}, status=500)
return JsonResponse({'success': False, 'error': '不支持的请求方法'}, status=405)
@csrf_exempt
@require_http_methods(["POST"])
def toggle_email_status(request):
"""切换邮箱启用状态API"""
try:
data = json.loads(request.body)
email_id = data.get('email_id')
is_enabled = data.get('is_enabled', True)
if not email_id:
return JsonResponse({'success': False, 'error': '缺少邮箱ID'}, status=400)
success = EmailNotificationService.toggle_email_status(email_id, is_enabled)
if success:
return JsonResponse({
'success': True,
'message': f'邮箱状态已更新为{"启用" if is_enabled else "禁用"}',
'data': {
'all_emails': EmailNotificationService.get_all_notification_emails()
}
})
else:
return JsonResponse({'success': False, 'error': '更新邮箱状态失败'}, status=500)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': '无效的JSON数据'}, status=400)
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)}, status=500)
+2
View File
@@ -46,6 +46,7 @@ INSTALLED_APPS = [
"corsheaders", "corsheaders",
"items", "items",
"finance", "finance",
"email_notice",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -57,6 +58,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"email_notice.middleware.EmailNotificationMiddleware",
] ]
ROOT_URLCONF = "item_manager.urls" ROOT_URLCONF = "item_manager.urls"
+1
View File
@@ -24,6 +24,7 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("", include("items.urls")), path("", include("items.urls")),
path("", include("finance.urls")), path("", include("finance.urls")),
path("", include("email_notice.urls")),
path("api-auth/", include("rest_framework.urls")), path("api-auth/", include("rest_framework.urls")),
] ]
+33 -2
View File
@@ -29,12 +29,21 @@
财务记录 财务记录
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
<div class="header-actions">
<el-button
type="text"
@click="goToSettings"
class="settings-btn"
>
<el-icon size="20"><Setting /></el-icon>
</el-button>
</div>
</div> </div>
</el-header> </el-header>
</template> </template>
<script> <script>
import { House, Box, Document, Money, Tickets } from '@element-plus/icons-vue' import { House, Box, Document, Money, Tickets, Setting } from '@element-plus/icons-vue'
export default { export default {
name: 'AppHeader', name: 'AppHeader',
@@ -43,7 +52,13 @@ export default {
Box, Box,
Document, Document,
Money, Money,
Tickets Tickets,
Setting
},
methods: {
goToSettings() {
this.$router.push('/settings')
}
} }
} }
</script> </script>
@@ -94,4 +109,20 @@ export default {
height: 60px; height: 60px;
line-height: 60px; line-height: 60px;
} }
.header-actions {
position: absolute;
right: 0;
z-index: 2;
}
.settings-btn {
color: #606266;
font-size: 16px;
padding: 10px;
}
.settings-btn:hover {
color: #409eff;
}
</style> </style>
+6
View File
@@ -7,6 +7,7 @@ import Dashboard from '../views/Dashboard.vue'
import FinanceDashboard from '../views/FinanceDashboard.vue' import FinanceDashboard from '../views/FinanceDashboard.vue'
import FinanceRecordList from '../views/FinanceRecordList.vue' import FinanceRecordList from '../views/FinanceRecordList.vue'
import FinanceRecordDetail from '../views/FinanceRecordDetail.vue' import FinanceRecordDetail from '../views/FinanceRecordDetail.vue'
import Settings from '../views/Settings.vue'
const routes = [ const routes = [
{ {
@@ -50,6 +51,11 @@ const routes = [
name: 'FinanceRecordDetail', name: 'FinanceRecordDetail',
component: FinanceRecordDetail, component: FinanceRecordDetail,
props: true props: true
},
{
path: '/settings',
name: 'Settings',
component: Settings
} }
] ]
+1 -1
View File
@@ -141,7 +141,7 @@
</template> </template>
<script> <script>
import { itemService } from '../services/api' import { itemService } from '@/services/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import moment from 'moment' import moment from 'moment'
+753
View File
@@ -0,0 +1,753 @@
<template>
<div class="settings-page">
<AppHeader />
<div class="settings-container">
<div class="settings-header">
<h2>网站设置</h2>
</div>
<div class="settings-content">
<!-- 主题切换 -->
<el-card class="setting-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon><Sunny /></el-icon>
<span>主题设置</span>
</div>
</template>
<div class="setting-item">
<div class="setting-label">
<span>网站主题</span>
<p class="setting-desc">切换白天/黑夜模式</p>
</div>
<div class="setting-control">
<el-switch
v-model="isDarkMode"
@change="toggleTheme"
active-text="黑夜模式"
inactive-text="白天模式"
active-color="#409eff"
inactive-color="#dcdfe6"
/>
</div>
</div>
</el-card>
<!-- 邮箱通知设置 -->
<el-card class="setting-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon><Message /></el-icon>
<span>通知设置</span>
</div>
</template>
<!-- 全局邮件通知开关 -->
<div class="setting-item">
<div class="setting-label">
<span>邮件通知</span>
<p class="setting-desc">开启后所有数据修改操作将发送邮件通知</p>
</div>
<div class="setting-control">
<el-switch
v-model="emailNotificationEnabled"
@change="saveEmailNotificationSetting"
active-text="开启"
inactive-text="关闭"
active-color="#67c23a"
inactive-color="#dcdfe6"
/>
</div>
</div>
<!-- 邮箱管理 -->
<div class="setting-item" style="flex-direction: column; align-items: flex-start;">
<div class="setting-label" style="margin-bottom: 15px;">
<span>通知邮箱管理</span>
<p class="setting-desc">管理接收通知的邮箱列表可以单独启用/禁用每个邮箱</p>
</div>
<!-- 现有邮箱列表 -->
<div v-if="allEmails.length > 0" class="email-management-section">
<h4>现有邮箱</h4>
<div class="existing-emails">
<div
v-for="emailItem in allEmails"
:key="emailItem.id"
class="email-management-item"
>
<div class="email-info">
<span class="email-address">{{ emailItem.email }}</span>
<span v-if="emailItem.description" class="email-description">{{ emailItem.description }}</span>
</div>
<div class="email-actions">
<el-switch
:model-value="emailItem.is_enabled"
@change="(value) => toggleEmailStatus(emailItem.id, value)"
size="small"
active-color="#67c23a"
inactive-color="#f56c6c"
/>
<el-button
type="danger"
:icon="Delete"
circle
size="small"
@click="removeExistingEmail(emailItem.id)"
/>
</div>
</div>
</div>
</div>
<!-- 添加新邮箱 -->
<div class="email-management-section">
<h4>添加新邮箱</h4>
<div class="new-email-form">
<div class="form-row">
<el-input
v-model="newEmail.email"
placeholder="请输入邮箱地址"
style="flex: 1; margin-right: 10px;"
/>
<el-input
v-model="newEmail.description"
placeholder="描述(可选)"
style="flex: 1; margin-right: 10px;"
/>
<el-switch
v-model="newEmail.is_enabled"
active-text="启用"
inactive-text="禁用"
size="small"
style="margin-right: 10px;"
/>
<el-button
type="primary"
:icon="Plus"
@click="addNewEmail"
>
添加
</el-button>
</div>
</div>
</div>
</div>
</el-card>
<!-- 网站说明 -->
<el-card class="setting-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon><InfoFilled /></el-icon>
<span>网站说明</span>
</div>
</template>
<div class="content-section">
<h3>系统介绍</h3>
<p>爱特工作室物品管理及财务管理系统是为爱特工作室量身定制的综合管理平台主要功能包括</p>
<ul>
<li><strong>物品管理</strong>管理工作室的各类物品包括电子设备办公用品等</li>
<li><strong>借用记录</strong>跟踪物品的借用情况确保物品流转透明化</li>
<li><strong>财务管理</strong>记录工作室的收支情况支持多部门资金管理</li>
<li><strong>数据统计</strong>提供直观的数据可视化界面</li>
</ul>
<h3>使用说明</h3>
<ol>
<li>物品管理可以添加编辑删除物品信息查看物品状态</li>
<li>借用流程选择物品 填写借用信息 确认借用 归还确认</li>
<li>财务记录添加收支记录支持上传凭证图片</li>
<li>权限管理不同用户具有不同的操作权限</li>
</ol>
<h3>免责声明</h3>
<div class="disclaimer">
<p><strong>重要提醒</strong></p>
<ul>
<li>本系统仅供爱特工作室内部使用请勿泄露系统访问信息</li>
<li>用户需对自己的操作行为负责误操作造成的数据丢失由操作者承担责任</li>
<li>系统会记录所有用户操作日志请规范使用</li>
<li>如发现系统异常或安全问题请及时联系管理员</li>
<li>系统数据会定期备份但建议重要数据另行保存</li>
</ul>
</div>
<h3>联系方式</h3>
<p>如有问题或建议请联系系统管理员</p>
<ul>
<li>邮箱admin@itstudio.com</li>
<li>QQ群123456789</li>
<li>技术支持Web部站长</li>
</ul>
<h3>版本信息</h3>
<p>
当前版本v1.0.0<br>
更新时间2024年9月<br>
开发团队爱特工作室
</p>
</div>
</el-card>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Sunny, Message, InfoFilled, Plus, Delete } from '@element-plus/icons-vue'
import AppHeader from '@/components/AppHeader.vue'
import { API_BASE_URL_WITHOUT_API } from '@/services/api'
export default {
name: 'Settings',
components: {
AppHeader,
Sunny,
Message,
InfoFilled,
Plus,
Delete
},
setup() {
const isDarkMode = ref(false)
// notificationEmails 不再需要,因为我们现在使用 allEmails 来管理所有邮箱
const emailNotificationEnabled = ref(false)
const allEmails = ref([])
const newEmail = ref({
email: '',
description: '',
is_enabled: true
})
// 加载设置
const loadSettings = () => {
// 从localStorage加载主题设置
const savedTheme = localStorage.getItem('theme')
isDarkMode.value = savedTheme === 'dark'
applyTheme()
// 从localStorage加载邮件通知设置
const emailEnabled = localStorage.getItem('emailNotificationEnabled')
emailNotificationEnabled.value = emailEnabled === 'true'
// 从后端加载最新设置
loadSettingsFromServer()
}
// 从服务器加载设置
const loadSettingsFromServer = async () => {
try {
const response = await fetch(`${API_BASE_URL_WITHOUT_API}/api/notification-settings/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
})
if (response.ok) {
const result = await response.json()
if (result.success && result.data) {
if (result.data.email_enabled !== undefined) {
emailNotificationEnabled.value = result.data.email_enabled
localStorage.setItem('emailNotificationEnabled', result.data.email_enabled.toString())
}
// 加载所有邮箱设置
if (result.data.all_emails && Array.isArray(result.data.all_emails)) {
allEmails.value = result.data.all_emails
}
}
}
} catch (error) {
console.error('加载服务器设置失败:', error)
ElMessage.error('从服务器加载设置失败,请刷新页面重试')
}
}
// 应用主题
const applyTheme = () => {
const htmlElement = document.documentElement
if (isDarkMode.value) {
htmlElement.classList.add('dark')
htmlElement.style.setProperty('--el-bg-color', '#1a1a1a')
htmlElement.style.setProperty('--el-text-color-primary', '#e5e7eb')
htmlElement.style.setProperty('--el-text-color-regular', '#d1d5db')
} else {
htmlElement.classList.remove('dark')
htmlElement.style.removeProperty('--el-bg-color')
htmlElement.style.removeProperty('--el-text-color-primary')
htmlElement.style.removeProperty('--el-text-color-regular')
}
}
// 切换主题
const toggleTheme = () => {
localStorage.setItem('theme', isDarkMode.value ? 'dark' : 'light')
applyTheme()
ElMessage.success(`已切换到${isDarkMode.value ? '黑夜' : '白天'}模式`)
}
// 保存邮件通知设置 (这个函数现在只用来切换总开关)
const saveEmailNotificationSetting = async () => {
try {
localStorage.setItem('emailNotificationEnabled', emailNotificationEnabled.value.toString())
// 后端接口期望收到完整的邮箱列表,所以我们把现有列表一起发过去
const emailsToUpdate = allEmails.value.map(item => ({
email: item.email,
description: item.description,
is_enabled: item.is_enabled
}))
const response = await fetch(`${API_BASE_URL_WITHOUT_API}/api/notification-settings/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
notification_emails: emailsToUpdate,
email_enabled: emailNotificationEnabled.value
})
})
if (response.ok) {
const result = await response.json()
if (result.success) {
ElMessage.success(`邮件通知已${emailNotificationEnabled.value ? '开启' : '关闭'}并同步到服务器`)
// 重新从服务器加载,确保状态一致
loadSettingsFromServer()
} else {
ElMessage.error(`保存失败: ${result.error}`)
}
} else {
throw new Error('服务器响应错误')
}
} catch (error) {
console.error('保存通知设置失败:', error)
ElMessage.warning('设置已保存到本地,但同步到服务器失败')
}
}
// [已修改] 切换单个邮箱启用状态
const toggleEmailStatus = async (emailId, isEnabled) => {
try {
// [关键修改] 请求发送到后端的 toggle_email_status 视图对应的 URL
// 你需要在 urls.py 中为 toggle_email_status 视图配置一个 URL,例如 'api/toggle-email-status/'
const response = await fetch(`${API_BASE_URL_WITHOUT_API}/api/toggle-email-status/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email_id: emailId,
is_enabled: isEnabled
})
})
if (response.ok) {
const result = await response.json()
if (result.success) {
ElMessage.success(`邮箱已${isEnabled ? '启用' : '禁用'}`)
// 操作成功后,从服务器重新加载所有邮箱,以确保数据同步
loadSettingsFromServer()
} else {
ElMessage.error(`操作失败: ${result.error}`)
// 操作失败时,也重新加载,以恢复到服务器上的真实状态
loadSettingsFromServer()
}
} else {
throw new Error('服务器响应错误')
}
} catch (error) {
console.error('更新邮箱状态失败:', error)
ElMessage.error('同步服务器失败,请刷新页面')
loadSettingsFromServer()
}
}
// [已修改] 添加新邮箱
const addNewEmail = async () => {
if (!newEmail.value.email) {
ElMessage.error('邮箱地址不能为空')
return
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(newEmail.value.email.trim())) {
ElMessage.error('请输入有效的邮箱地址')
return
}
try {
// [关键修改] 构造一个包含所有现有邮箱和新邮箱的完整列表
const updatedEmailList = allEmails.value.map(e => ({
email: e.email,
description: e.description,
is_enabled: e.is_enabled
}))
updatedEmailList.push({
email: newEmail.value.email.trim(),
description: newEmail.value.description,
is_enabled: newEmail.value.is_enabled
})
// [关键修改] 将这个完整的列表发送给后端
const response = await fetch(`${API_BASE_URL_WITHOUT_API}/api/notification-settings/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
notification_emails: updatedEmailList,
email_enabled: emailNotificationEnabled.value // 别忘了带上总开关的状态
})
})
if (response.ok) {
const result = await response.json()
if (result.success) {
ElMessage.success('新邮箱已添加')
// 清空输入框
newEmail.value.email = ''
newEmail.value.description = ''
newEmail.value.is_enabled = true
// [关键修改] 从服务器重新加载所有设置,以获取包含新邮箱 ID 的完整列表
loadSettingsFromServer()
} else {
ElMessage.error(`添加失败: ${result.error}`)
}
} else {
throw new Error('服务器响应错误')
}
} catch (error) {
console.error('添加新邮箱失败:', error)
ElMessage.error('添加新邮箱失败,请稍后重试')
}
}
// [已修改] 删除现有邮箱
const removeExistingEmail = async (emailId) => {
try {
// [关键修改] 构造一个不包含要删除邮箱的新列表
const updatedEmailList = allEmails.value
.filter(item => item.id !== emailId)
.map(e => ({
email: e.email,
description: e.description,
is_enabled: e.is_enabled
}))
// [关键修改] 将这个新列表发送给后端进行批量更新
const response = await fetch(`${API_BASE_URL_WITHOUT_API}/api/notification-settings/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
notification_emails: updatedEmailList,
email_enabled: emailNotificationEnabled.value // 别忘了带上总开关的状态
})
})
if (response.ok) {
const result = await response.json()
if (result.success) {
ElMessage.success('邮箱已删除')
// [关键修改] 从服务器重新加载,确保列表同步
loadSettingsFromServer()
} else {
ElMessage.error(`删除失败: ${result.error}`)
}
} else {
throw new Error('服务器响应错误')
}
} catch (error) {
console.error('删除邮箱失败:', error)
ElMessage.error('删除邮箱失败,请稍后重试')
}
}
onMounted(() => {
loadSettings()
})
return {
isDarkMode,
emailNotificationEnabled,
allEmails,
newEmail,
toggleTheme,
saveEmailNotificationSetting,
toggleEmailStatus,
removeExistingEmail,
addNewEmail,
// 删除不再需要的旧方法
// saveNotificationEmails,
// addEmail,
// removeEmail
}
}
}
</script>
<style scoped>
.settings-page {
min-height: 100vh;
background-color: #f5f7fa;
}
.settings-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.settings-header {
margin-bottom: 20px;
}
.settings-header h2 {
color: #303133;
font-size: 24px;
font-weight: 600;
margin: 0;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.setting-card {
border-radius: 8px;
border: 1px solid #ebeef5;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #303133;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid #f0f2f5;
}
.setting-item:last-child {
border-bottom: none;
}
.setting-label {
flex: 1;
}
.setting-label span {
font-size: 16px;
font-weight: 500;
color: #303133;
}
.setting-desc {
font-size: 14px;
color: #909399;
margin: 4px 0 0 0;
}
.setting-control {
display: flex;
align-items: center;
}
.email-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.email-item {
display: flex;
align-items: center;
gap: 10px;
}
.content-section {
padding: 0;
line-height: 1.6;
}
.content-section h3 {
color: #409eff;
font-size: 18px;
margin: 20px 0 10px 0;
border-left: 4px solid #409eff;
padding-left: 12px;
}
.content-section p {
margin: 10px 0;
color: #606266;
font-size: 14px;
}
.content-section ul,
.content-section ol {
margin: 10px 0;
padding-left: 20px;
color: #606266;
}
.content-section li {
margin: 5px 0;
font-size: 14px;
}
.disclaimer {
background-color: #fef0f0;
border: 1px solid #fbc4c4;
border-radius: 4px;
padding: 15px;
margin: 15px 0;
}
.disclaimer p {
color: #f56c6c;
font-weight: 600;
margin-bottom: 8px;
}
.disclaimer ul {
margin: 0;
}
.disclaimer li {
color: #606266;
}
/* 邮箱管理样式 */
.email-management-section {
width: 100%;
margin-bottom: 20px;
}
.email-management-section h4 {
color: #409eff;
font-size: 16px;
margin: 0 0 10px 0;
font-weight: 600;
}
.existing-emails {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.email-management-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #f8f9fa;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.email-info {
display: flex;
flex-direction: column;
flex: 1;
}
.email-address {
font-weight: 500;
color: #303133;
font-size: 14px;
}
.email-description {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.email-actions {
display: flex;
align-items: center;
gap: 10px;
}
.new-email-form {
width: 100%;
}
.form-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
/* 暗色主题下的邮箱管理样式 */
.dark .email-management-section h4 {
color: #60a5fa;
}
.dark .email-management-item {
background-color: #2d2d2d;
border-color: #434343;
}
.dark .email-address {
color: #e5e7eb;
}
.dark .email-description {
color: #9ca3af;
}
/* 暗色主题 */
.dark .settings-page {
background-color: #141414;
}
.dark .settings-header h2 {
color: #e5e7eb;
}
.dark .setting-card {
background-color: #1f1f1f;
border-color: #434343;
}
.dark .card-header {
color: #e5e7eb;
}
.dark .setting-label span {
color: #e5e7eb;
}
.dark .setting-desc {
color: #9ca3af;
}
.dark .content-section h3 {
color: #60a5fa;
}
.dark .content-section p,
.dark .content-section li {
color: #d1d5db;
}
</style>