12th_fix error
This commit is contained in:
@@ -125,7 +125,7 @@ def create_app(config_name=None):
|
||||
# 建立資料表
|
||||
with app.app_context():
|
||||
# 導入模型
|
||||
from app.models import User, TranslationJob, JobFile, TranslationCache, APIUsageStats, SystemLog
|
||||
from app.models import User, TranslationJob, JobFile, TranslationCache, APIUsageStats, SystemLog, Notification
|
||||
|
||||
db.create_all()
|
||||
|
||||
@@ -135,6 +135,10 @@ def create_app(config_name=None):
|
||||
# 創建 Celery 實例
|
||||
app.celery = make_celery(app)
|
||||
|
||||
# 初始化 WebSocket
|
||||
from app.websocket import init_websocket
|
||||
app.socketio = init_websocket(app)
|
||||
|
||||
app.logger.info("Flask application created successfully")
|
||||
return app
|
||||
|
||||
|
@@ -14,11 +14,12 @@ from flask import Blueprint
|
||||
api_v1 = Blueprint('api_v1', __name__, url_prefix='/api/v1')
|
||||
|
||||
# 匯入各 API 模組
|
||||
from . import auth, jobs, files, admin, health
|
||||
from . import auth, jobs, files, admin, health, notification
|
||||
|
||||
# 註冊路由
|
||||
api_v1.register_blueprint(auth.auth_bp)
|
||||
api_v1.register_blueprint(jobs.jobs_bp)
|
||||
api_v1.register_blueprint(files.files_bp)
|
||||
api_v1.register_blueprint(admin.admin_bp)
|
||||
api_v1.register_blueprint(health.health_bp)
|
||||
api_v1.register_blueprint(health.health_bp)
|
||||
api_v1.register_blueprint(notification.notification_bp)
|
331
app/api/notification.py
Normal file
331
app/api/notification.py
Normal file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通知系統 API 路由
|
||||
|
||||
Author: PANJIT IT Team
|
||||
Created: 2024-01-28
|
||||
Modified: 2024-01-28
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from sqlalchemy import desc, and_, or_
|
||||
from datetime import datetime, timedelta
|
||||
from app import db
|
||||
from app.models import Notification, NotificationType, User
|
||||
from app.utils.response import create_taiwan_response
|
||||
# 移除不需要的導入
|
||||
|
||||
# 建立藍圖
|
||||
notification_bp = Blueprint('notification', __name__, url_prefix='/notifications')
|
||||
|
||||
|
||||
@notification_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_notifications():
|
||||
"""獲取當前用戶的通知列表"""
|
||||
try:
|
||||
# 獲取當前用戶
|
||||
current_user_id = get_jwt_identity()
|
||||
|
||||
# 獲取查詢參數
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = min(request.args.get('per_page', 20, type=int), 100)
|
||||
status_filter = request.args.get('status', 'all')
|
||||
type_filter = request.args.get('type', None)
|
||||
|
||||
# 建構查詢
|
||||
query = Notification.query.filter_by(user_id=current_user_id)
|
||||
|
||||
# 只顯示未過期的通知
|
||||
query = query.filter(or_(
|
||||
Notification.expires_at.is_(None),
|
||||
Notification.expires_at > datetime.now()
|
||||
))
|
||||
|
||||
# 過濾狀態
|
||||
if status_filter == 'unread':
|
||||
query = query.filter_by(is_read=False)
|
||||
elif status_filter == 'read':
|
||||
query = query.filter_by(is_read=True)
|
||||
|
||||
# 過濾類型
|
||||
if type_filter:
|
||||
query = query.filter_by(type=type_filter)
|
||||
|
||||
# 排序 - 未讀在前,然後按時間排序
|
||||
query = query.order_by(Notification.is_read.asc(), desc(Notification.created_at))
|
||||
|
||||
# 分頁
|
||||
paginated = query.paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
# 獲取未讀數量
|
||||
unread_count = Notification.query.filter_by(
|
||||
user_id=current_user_id,
|
||||
is_read=False
|
||||
).filter(or_(
|
||||
Notification.expires_at.is_(None),
|
||||
Notification.expires_at > datetime.now()
|
||||
)).count()
|
||||
|
||||
return jsonify(create_taiwan_response(
|
||||
success=True,
|
||||
data={
|
||||
'notifications': [n.to_dict() for n in paginated.items],
|
||||
'pagination': {
|
||||
'total': paginated.total,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'pages': paginated.pages
|
||||
},
|
||||
'unread_count': unread_count
|
||||
},
|
||||
message='獲取通知列表成功'
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
return jsonify(create_taiwan_response(
|
||||
success=False,
|
||||
error=f'獲取通知失敗:{str(e)}'
|
||||
)), 500
|
||||
|
||||
|
||||
@notification_bp.route('/<notification_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_notification(notification_id):
|
||||
"""獲取單個通知詳情"""
|
||||
try:
|
||||
current_user_id = get_jwt_identity()
|
||||
|
||||
# 查找通知
|
||||
notification = Notification.query.filter_by(
|
||||
notification_uuid=notification_id,
|
||||
user_id=current_user_id
|
||||
).first()
|
||||
|
||||
if not notification:
|
||||
return jsonify(create_taiwan_response(
|
||||
success=False,
|
||||
error='通知不存在'
|
||||
)), 404
|
||||
|
||||
# 自動標記為已讀
|
||||
if not notification.is_read:
|
||||
notification.mark_as_read()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(create_taiwan_response(
|
||||
success=True,
|
||||
data=notification.to_dict(),
|
||||
message='獲取通知成功'
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
return jsonify(create_taiwan_response(
|
||||
success=False,
|
||||
error=f'獲取通知失敗:{str(e)}'
|
||||
)), 500
|
||||
|
||||
|
||||
@notification_bp.route('/<notification_id>/read', methods=['POST'])
|
||||
@jwt_required()
|
||||
def mark_notification_read(notification_id):
|
||||
"""標記通知為已讀"""
|
||||
try:
|
||||
current_user_id = get_jwt_identity()
|
||||
|
||||
# 查找通知
|
||||
notification = Notification.query.filter_by(
|
||||
notification_uuid=notification_id,
|
||||
user_id=current_user_id
|
||||
).first()
|
||||
|
||||
if not notification:
|
||||
return jsonify(create_taiwan_response(
|
||||
success=False,
|
||||
error='通知不存在'
|
||||
)), 404
|
||||
|
||||
# 標記為已讀
|
||||
notification.mark_as_read()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(create_taiwan_response(
|
||||
success=True,
|
||||
message='標記已讀成功'
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
return jsonify(create_taiwan_response(
|
||||
success=False,
|
||||
error=f'標記已讀失敗:{str(e)}'
|
||||
)), 500
|
||||
|
||||
|
||||
@notification_bp.route('/read-all', methods=['POST'])
|
||||
@jwt_required()
|
||||
def mark_all_read():
|
||||
"""標記所有通知為已讀"""
|
||||
try:
|
||||
current_user_id = get_jwt_identity()
|
||||
|
||||
# 取得所有未讀通知
|
||||
unread_notifications = Notification.query.filter_by(
|
||||
user_id=current_user_id,
|
||||
is_read=False
|
||||
).filter(or_(
|
||||
Notification.expires_at.is_(None),
|
||||
Notification.expires_at > datetime.now()
|
||||
)).all()
|
||||
|
||||
# 標記為已讀
|
||||
for notification in unread_notifications:
|
||||
notification.mark_as_read()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(create_taiwan_response(
|
||||
success=True,
|
||||
data={'marked_count': len(unread_notifications)},
|
||||
message=f'已標記 {len(unread_notifications)} 個通知為已讀'
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
return jsonify(create_taiwan_response(
|
||||
success=False,
|
||||
error=f'標記全部已讀失敗:{str(e)}'
|
||||
)), 500
|
||||
|
||||
|
||||
@notification_bp.route('/<notification_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_notification(notification_id):
|
||||
"""刪除通知"""
|
||||
try:
|
||||
current_user_id = get_jwt_identity()
|
||||
|
||||
# 查找通知
|
||||
notification = Notification.query.filter_by(
|
||||
notification_uuid=notification_id,
|
||||
user_id=current_user_id
|
||||
).first()
|
||||
|
||||
if not notification:
|
||||
return jsonify(create_taiwan_response(
|
||||
success=False,
|
||||
error='通知不存在'
|
||||
)), 404
|
||||
|
||||
# 刪除通知
|
||||
db.session.delete(notification)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(create_taiwan_response(
|
||||
success=True,
|
||||
message='刪除通知成功'
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify(create_taiwan_response(
|
||||
success=False,
|
||||
error=f'刪除通知失敗:{str(e)}'
|
||||
)), 500
|
||||
|
||||
|
||||
@notification_bp.route('/clear', methods=['POST'])
|
||||
@jwt_required()
|
||||
def clear_read_notifications():
|
||||
"""清空所有已讀通知"""
|
||||
try:
|
||||
current_user_id = get_jwt_identity()
|
||||
|
||||
# 刪除所有已讀通知
|
||||
deleted_count = Notification.query.filter_by(
|
||||
user_id=current_user_id,
|
||||
is_read=True
|
||||
).delete()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(create_taiwan_response(
|
||||
success=True,
|
||||
data={'deleted_count': deleted_count},
|
||||
message=f'已清除 {deleted_count} 個已讀通知'
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify(create_taiwan_response(
|
||||
success=False,
|
||||
error=f'清除通知失敗:{str(e)}'
|
||||
)), 500
|
||||
|
||||
|
||||
@notification_bp.route('/test', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_test_notification():
|
||||
"""創建測試通知(開發用)"""
|
||||
try:
|
||||
current_user_id = get_jwt_identity()
|
||||
|
||||
# 創建測試通知
|
||||
test_notification = create_notification(
|
||||
user_id=current_user_id,
|
||||
title="測試通知",
|
||||
message=f"這是一個測試通知,創建於 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
notification_type=NotificationType.INFO
|
||||
)
|
||||
|
||||
return jsonify(create_taiwan_response(
|
||||
success=True,
|
||||
data=test_notification.to_dict(),
|
||||
message='測試通知已創建'
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
return jsonify(create_taiwan_response(
|
||||
success=False,
|
||||
error=f'創建測試通知失敗:{str(e)}'
|
||||
)), 500
|
||||
|
||||
|
||||
# 工具函數:創建通知
|
||||
def create_notification(user_id, title, message, notification_type=NotificationType.INFO,
|
||||
job_uuid=None, extra_data=None):
|
||||
"""
|
||||
創建通知的工具函數
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
title: 通知標題
|
||||
message: 通知內容
|
||||
notification_type: 通知類型
|
||||
job_uuid: 關聯的任務UUID(可選)
|
||||
extra_data: 額外數據(可選)
|
||||
|
||||
Returns:
|
||||
Notification: 創建的通知對象
|
||||
"""
|
||||
try:
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
type=notification_type.value,
|
||||
title=title,
|
||||
message=message,
|
||||
job_uuid=job_uuid,
|
||||
extra_data=extra_data,
|
||||
link=f"/job/{job_uuid}" if job_uuid else None
|
||||
)
|
||||
|
||||
db.session.add(notification)
|
||||
db.session.commit()
|
||||
|
||||
return notification
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
@@ -13,6 +13,7 @@ from .job import TranslationJob, JobFile
|
||||
from .cache import TranslationCache
|
||||
from .stats import APIUsageStats
|
||||
from .log import SystemLog
|
||||
from .notification import Notification, NotificationType
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
@@ -20,5 +21,7 @@ __all__ = [
|
||||
'JobFile',
|
||||
'TranslationCache',
|
||||
'APIUsageStats',
|
||||
'SystemLog'
|
||||
'SystemLog',
|
||||
'Notification',
|
||||
'NotificationType'
|
||||
]
|
97
app/models/notification.py
Normal file
97
app/models/notification.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通知系統資料模型
|
||||
|
||||
Author: PANJIT IT Team
|
||||
Created: 2024-01-28
|
||||
Modified: 2024-01-28
|
||||
"""
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app import db
|
||||
import uuid
|
||||
import json
|
||||
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
"""通知類型枚舉"""
|
||||
SUCCESS = "success" # 成功
|
||||
ERROR = "error" # 錯誤
|
||||
WARNING = "warning" # 警告
|
||||
INFO = "info" # 資訊
|
||||
SYSTEM = "system" # 系統
|
||||
|
||||
|
||||
class Notification(db.Model):
|
||||
"""通知模型"""
|
||||
__tablename__ = 'dt_notifications'
|
||||
|
||||
# 主鍵
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
notification_uuid = db.Column(db.String(36), unique=True, nullable=False, index=True,
|
||||
default=lambda: str(uuid.uuid4()), comment='通知唯一識別碼')
|
||||
|
||||
# 基本資訊
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), nullable=False, comment='使用者ID')
|
||||
type = db.Column(db.String(20), nullable=False, default=NotificationType.INFO.value, comment='通知類型')
|
||||
title = db.Column(db.String(255), nullable=False, comment='通知標題')
|
||||
message = db.Column(db.Text, nullable=False, comment='通知內容')
|
||||
|
||||
# 關聯資訊(可選)
|
||||
job_uuid = db.Column(db.String(36), nullable=True, comment='關聯任務UUID')
|
||||
link = db.Column(db.String(500), nullable=True, comment='相關連結')
|
||||
|
||||
# 狀態
|
||||
is_read = db.Column(db.Boolean, default=False, nullable=False, comment='是否已讀')
|
||||
read_at = db.Column(db.DateTime, nullable=True, comment='閱讀時間')
|
||||
|
||||
# 時間戳記
|
||||
created_at = db.Column(db.DateTime, default=func.now(), nullable=False, comment='建立時間')
|
||||
expires_at = db.Column(db.DateTime, nullable=True, comment='過期時間')
|
||||
|
||||
# 額外數據(JSON 格式儲存)
|
||||
extra_data = db.Column(db.JSON, nullable=True, comment='額外數據')
|
||||
|
||||
# 關聯
|
||||
user = db.relationship("User", backref="notifications")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Notification {self.notification_uuid}: {self.title}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典格式"""
|
||||
return {
|
||||
'id': self.notification_uuid, # 前端使用 UUID
|
||||
'user_id': self.user_id,
|
||||
'type': self.type,
|
||||
'title': self.title,
|
||||
'message': self.message,
|
||||
'job_uuid': self.job_uuid,
|
||||
'link': self.link,
|
||||
'is_read': self.is_read,
|
||||
'read': self.is_read, # 為了前端相容
|
||||
'read_at': self.read_at.isoformat() if self.read_at else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
|
||||
'extra_data': self.extra_data
|
||||
}
|
||||
|
||||
def mark_as_read(self):
|
||||
"""標記為已讀"""
|
||||
self.is_read = True
|
||||
self.read_at = datetime.now()
|
||||
|
||||
@classmethod
|
||||
def create_job_notification(cls, user_id, job_uuid, title, message, notification_type=NotificationType.INFO):
|
||||
"""創建任務相關通知"""
|
||||
return cls(
|
||||
user_id=user_id,
|
||||
job_uuid=job_uuid,
|
||||
type=notification_type.value,
|
||||
title=title,
|
||||
message=message,
|
||||
link=f"/job/{job_uuid}" # 連結到任務詳情頁
|
||||
)
|
@@ -12,12 +12,14 @@ import os
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from flask import current_app, url_for
|
||||
from app import db
|
||||
from app.utils.logger import get_logger
|
||||
from app.models.job import TranslationJob
|
||||
from app.models.user import User
|
||||
from app.models.notification import Notification, NotificationType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -385,4 +387,224 @@ class NotificationService:
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"SMTP connection test failed: {str(e)}")
|
||||
return False
|
||||
return False
|
||||
|
||||
# ========== 資料庫通知方法 ==========
|
||||
|
||||
def create_db_notification(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message: str,
|
||||
notification_type: NotificationType = NotificationType.INFO,
|
||||
job_uuid: Optional[str] = None,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
link: Optional[str] = None
|
||||
) -> Optional[Notification]:
|
||||
"""
|
||||
創建資料庫通知
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
title: 通知標題
|
||||
message: 通知內容
|
||||
notification_type: 通知類型
|
||||
job_uuid: 關聯任務UUID
|
||||
extra_data: 額外數據
|
||||
expires_at: 過期時間
|
||||
link: 相關連結
|
||||
|
||||
Returns:
|
||||
Notification: 創建的通知對象
|
||||
"""
|
||||
try:
|
||||
# 如果沒有指定連結但有任務UUID,自動生成任務詳情連結
|
||||
if not link and job_uuid:
|
||||
link = f"/job/{job_uuid}"
|
||||
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
type=notification_type.value,
|
||||
title=title,
|
||||
message=message,
|
||||
job_uuid=job_uuid,
|
||||
link=link,
|
||||
extra_data=extra_data,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
db.session.add(notification)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"資料庫通知已創建: {notification.notification_uuid} for user {user_id}")
|
||||
|
||||
# 觸發 WebSocket 推送
|
||||
self._send_websocket_notification(notification)
|
||||
|
||||
return notification
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"創建資料庫通知失敗: {e}")
|
||||
return None
|
||||
|
||||
def send_job_started_db_notification(self, job: TranslationJob) -> Optional[Notification]:
|
||||
"""
|
||||
發送任務開始處理的資料庫通知
|
||||
|
||||
Args:
|
||||
job: 翻譯任務對象
|
||||
|
||||
Returns:
|
||||
Notification: 創建的通知對象
|
||||
"""
|
||||
try:
|
||||
title = "翻譯任務開始處理"
|
||||
message = f'您的文件「{job.original_filename}」已開始翻譯處理。'
|
||||
|
||||
if job.target_languages:
|
||||
languages = ', '.join(job.target_languages)
|
||||
message += f" 目標語言: {languages}"
|
||||
|
||||
return self.create_db_notification(
|
||||
user_id=job.user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
notification_type=NotificationType.INFO,
|
||||
job_uuid=job.job_uuid,
|
||||
extra_data={
|
||||
'filename': job.original_filename,
|
||||
'target_languages': job.target_languages,
|
||||
'started_at': job.processing_started_at.isoformat() if job.processing_started_at else None
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"發送任務開始資料庫通知失敗: {e}")
|
||||
return None
|
||||
|
||||
def send_job_completion_db_notification(self, job: TranslationJob) -> Optional[Notification]:
|
||||
"""
|
||||
發送任務完成的資料庫通知
|
||||
|
||||
Args:
|
||||
job: 翻譯任務對象
|
||||
|
||||
Returns:
|
||||
Notification: 創建的通知對象
|
||||
"""
|
||||
try:
|
||||
if job.status != 'COMPLETED':
|
||||
logger.warning(f"任務 {job.job_uuid} 狀態不是已完成,跳過完成通知")
|
||||
return None
|
||||
|
||||
# 構建通知內容
|
||||
title = "翻譯任務完成"
|
||||
message = f'您的文件「{job.original_filename}」已成功翻譯完成。'
|
||||
|
||||
# 添加目標語言信息
|
||||
if job.target_languages:
|
||||
languages = ', '.join(job.target_languages)
|
||||
message += f" 目標語言: {languages}"
|
||||
|
||||
# 添加處理時間信息
|
||||
if job.processing_started_at and job.completed_at:
|
||||
duration = job.completed_at - job.processing_started_at
|
||||
minutes = int(duration.total_seconds() / 60)
|
||||
if minutes > 0:
|
||||
message += f" 處理時間: {minutes} 分鐘"
|
||||
else:
|
||||
message += f" 處理時間: {int(duration.total_seconds())} 秒"
|
||||
|
||||
return self.create_db_notification(
|
||||
user_id=job.user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
notification_type=NotificationType.SUCCESS,
|
||||
job_uuid=job.job_uuid,
|
||||
extra_data={
|
||||
'filename': job.original_filename,
|
||||
'target_languages': job.target_languages,
|
||||
'total_cost': float(job.total_cost) if job.total_cost else 0,
|
||||
'completed_at': job.completed_at.isoformat() if job.completed_at else None
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"發送任務完成資料庫通知失敗: {e}")
|
||||
return None
|
||||
|
||||
def send_job_failure_db_notification(self, job: TranslationJob, error_message: str = None) -> Optional[Notification]:
|
||||
"""
|
||||
發送任務失敗的資料庫通知
|
||||
|
||||
Args:
|
||||
job: 翻譯任務對象
|
||||
error_message: 錯誤訊息
|
||||
|
||||
Returns:
|
||||
Notification: 創建的通知對象
|
||||
"""
|
||||
try:
|
||||
title = "翻譯任務失敗"
|
||||
message = f'您的文件「{job.original_filename}」翻譯失敗。'
|
||||
|
||||
if error_message:
|
||||
message += f" 錯誤訊息: {error_message}"
|
||||
|
||||
if job.retry_count > 0:
|
||||
message += f" 已重試 {job.retry_count} 次。"
|
||||
|
||||
return self.create_db_notification(
|
||||
user_id=job.user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
notification_type=NotificationType.ERROR,
|
||||
job_uuid=job.job_uuid,
|
||||
extra_data={
|
||||
'filename': job.original_filename,
|
||||
'error_message': error_message,
|
||||
'retry_count': job.retry_count,
|
||||
'failed_at': datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"發送任務失敗資料庫通知失敗: {e}")
|
||||
return None
|
||||
|
||||
def _send_websocket_notification(self, notification: Notification):
|
||||
"""
|
||||
通過 WebSocket 發送通知
|
||||
|
||||
Args:
|
||||
notification: 通知對象
|
||||
"""
|
||||
try:
|
||||
from app.websocket import send_notification_to_user
|
||||
send_notification_to_user(notification.user_id, notification.to_dict())
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket 推送通知失敗: {e}")
|
||||
|
||||
def get_unread_count(self, user_id: int) -> int:
|
||||
"""
|
||||
獲取用戶未讀通知數量
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
|
||||
Returns:
|
||||
int: 未讀通知數量
|
||||
"""
|
||||
try:
|
||||
return Notification.query.filter_by(
|
||||
user_id=user_id,
|
||||
is_read=False
|
||||
).filter(
|
||||
(Notification.expires_at.is_(None)) |
|
||||
(Notification.expires_at > datetime.now())
|
||||
).count()
|
||||
except Exception as e:
|
||||
logger.error(f"獲取未讀通知數量失敗: {e}")
|
||||
return 0
|
@@ -76,7 +76,10 @@ def process_translation_job(self, job_id: int):
|
||||
# 發送完成通知
|
||||
try:
|
||||
notification_service = NotificationService()
|
||||
# 發送郵件通知
|
||||
notification_service.send_job_completion_notification(job)
|
||||
# 發送資料庫通知
|
||||
notification_service.send_job_completion_db_notification(job)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send completion notification: {str(e)}")
|
||||
|
||||
@@ -131,6 +134,16 @@ def process_translation_job(self, job_id: int):
|
||||
# 重試次數用盡,標記失敗
|
||||
job.update_status('FAILED')
|
||||
|
||||
# 發送失敗通知
|
||||
try:
|
||||
notification_service = NotificationService()
|
||||
# 發送郵件通知
|
||||
notification_service.send_job_failure_notification(job)
|
||||
# 發送資料庫通知
|
||||
notification_service.send_job_failure_db_notification(job, str(exc))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send failure notification: {str(e)}")
|
||||
|
||||
SystemLog.error(
|
||||
'tasks.translation',
|
||||
f'Translation job failed permanently: {job.job_uuid}',
|
||||
|
230
app/websocket.py
Normal file
230
app/websocket.py
Normal file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WebSocket 服務模組
|
||||
|
||||
Author: PANJIT IT Team
|
||||
Created: 2024-01-28
|
||||
Modified: 2024-01-28
|
||||
"""
|
||||
|
||||
from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect
|
||||
from flask_jwt_extended import decode_token, get_jwt
|
||||
from flask import request
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
# 初始化 SocketIO
|
||||
socketio = SocketIO(
|
||||
cors_allowed_origins="*",
|
||||
async_mode='threading',
|
||||
logger=True,
|
||||
engineio_logger=False
|
||||
)
|
||||
|
||||
# 存儲用戶連接
|
||||
connected_users = {}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def jwt_required_ws(f):
|
||||
"""WebSocket JWT 驗證裝飾器"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
try:
|
||||
# 從查詢參數獲取 token
|
||||
token = request.args.get('token')
|
||||
if not token:
|
||||
disconnect()
|
||||
return False
|
||||
|
||||
# 解碼 token
|
||||
decoded = decode_token(token)
|
||||
user_id = decoded.get('sub')
|
||||
|
||||
# 儲存用戶信息
|
||||
request.user_id = user_id
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket authentication failed: {e}")
|
||||
disconnect()
|
||||
return False
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
@socketio.on('connect')
|
||||
def handle_connect(auth):
|
||||
"""處理客戶端連接"""
|
||||
try:
|
||||
# 從認證數據獲取 token
|
||||
if auth and 'token' in auth:
|
||||
token = auth['token']
|
||||
decoded = decode_token(token)
|
||||
user_id = decoded.get('sub')
|
||||
|
||||
# 記錄連接
|
||||
connected_users[request.sid] = {
|
||||
'user_id': user_id,
|
||||
'sid': request.sid
|
||||
}
|
||||
|
||||
# 加入用戶專屬房間
|
||||
join_room(f"user_{user_id}")
|
||||
|
||||
logger.info(f"User {user_id} connected with session {request.sid}")
|
||||
|
||||
# 發送連接成功消息
|
||||
emit('connected', {
|
||||
'message': '連接成功',
|
||||
'user_id': user_id
|
||||
})
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.warning("Connection attempt without authentication")
|
||||
disconnect()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Connection error: {e}")
|
||||
disconnect()
|
||||
return False
|
||||
|
||||
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
"""處理客戶端斷開連接"""
|
||||
try:
|
||||
if request.sid in connected_users:
|
||||
user_info = connected_users[request.sid]
|
||||
user_id = user_info['user_id']
|
||||
|
||||
# 離開房間
|
||||
leave_room(f"user_{user_id}")
|
||||
|
||||
# 移除連接記錄
|
||||
del connected_users[request.sid]
|
||||
|
||||
logger.info(f"User {user_id} disconnected")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Disconnect error: {e}")
|
||||
|
||||
|
||||
@socketio.on('ping')
|
||||
def handle_ping():
|
||||
"""處理心跳包"""
|
||||
emit('pong', {'timestamp': request.args.get('timestamp')})
|
||||
|
||||
|
||||
@socketio.on('subscribe_job')
|
||||
def handle_subscribe_job(data):
|
||||
"""訂閱任務更新"""
|
||||
try:
|
||||
job_uuid = data.get('job_uuid')
|
||||
if job_uuid:
|
||||
join_room(f"job_{job_uuid}")
|
||||
logger.info(f"Client {request.sid} subscribed to job {job_uuid}")
|
||||
emit('subscribed', {'job_uuid': job_uuid})
|
||||
except Exception as e:
|
||||
logger.error(f"Subscribe job error: {e}")
|
||||
|
||||
|
||||
@socketio.on('unsubscribe_job')
|
||||
def handle_unsubscribe_job(data):
|
||||
"""取消訂閱任務更新"""
|
||||
try:
|
||||
job_uuid = data.get('job_uuid')
|
||||
if job_uuid:
|
||||
leave_room(f"job_{job_uuid}")
|
||||
logger.info(f"Client {request.sid} unsubscribed from job {job_uuid}")
|
||||
emit('unsubscribed', {'job_uuid': job_uuid})
|
||||
except Exception as e:
|
||||
logger.error(f"Unsubscribe job error: {e}")
|
||||
|
||||
|
||||
# 工具函數:發送通知
|
||||
def send_notification_to_user(user_id, notification_data):
|
||||
"""
|
||||
向特定用戶發送通知
|
||||
|
||||
Args:
|
||||
user_id: 用戶ID
|
||||
notification_data: 通知數據
|
||||
"""
|
||||
try:
|
||||
socketio.emit(
|
||||
'new_notification',
|
||||
notification_data,
|
||||
room=f"user_{user_id}",
|
||||
namespace='/'
|
||||
)
|
||||
logger.info(f"Notification sent to user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification: {e}")
|
||||
|
||||
|
||||
def send_job_update(job_uuid, update_data):
|
||||
"""
|
||||
發送任務更新
|
||||
|
||||
Args:
|
||||
job_uuid: 任務UUID
|
||||
update_data: 更新數據
|
||||
"""
|
||||
try:
|
||||
socketio.emit(
|
||||
'job_update',
|
||||
{
|
||||
'job_uuid': job_uuid,
|
||||
**update_data
|
||||
},
|
||||
room=f"job_{job_uuid}",
|
||||
namespace='/'
|
||||
)
|
||||
logger.info(f"Job update sent for {job_uuid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send job update: {e}")
|
||||
|
||||
|
||||
def broadcast_system_message(message, message_type='info'):
|
||||
"""
|
||||
廣播系統消息給所有連接的用戶
|
||||
|
||||
Args:
|
||||
message: 消息內容
|
||||
message_type: 消息類型
|
||||
"""
|
||||
try:
|
||||
socketio.emit(
|
||||
'system_message',
|
||||
{
|
||||
'message': message,
|
||||
'type': message_type
|
||||
},
|
||||
namespace='/',
|
||||
broadcast=True
|
||||
)
|
||||
logger.info(f"System message broadcasted: {message}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to broadcast system message: {e}")
|
||||
|
||||
|
||||
# 初始化函數
|
||||
def init_websocket(app):
|
||||
"""
|
||||
初始化 WebSocket
|
||||
|
||||
Args:
|
||||
app: Flask 應用實例
|
||||
"""
|
||||
socketio.init_app(app)
|
||||
logger.info("WebSocket initialized")
|
||||
return socketio
|
@@ -127,6 +127,31 @@ def create_tables():
|
||||
FOREIGN KEY (user_id) REFERENCES dt_users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (job_id) REFERENCES dt_translation_jobs(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
''',
|
||||
|
||||
'dt_notifications': '''
|
||||
CREATE TABLE IF NOT EXISTS dt_notifications (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
notification_uuid VARCHAR(36) NOT NULL UNIQUE COMMENT '通知唯一識別碼',
|
||||
user_id INT NOT NULL COMMENT '使用者ID',
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'info' COMMENT '通知類型',
|
||||
title VARCHAR(255) NOT NULL COMMENT '通知標題',
|
||||
message TEXT NOT NULL COMMENT '通知內容',
|
||||
job_uuid VARCHAR(36) NULL COMMENT '關聯任務UUID',
|
||||
link VARCHAR(500) NULL COMMENT '相關連結',
|
||||
is_read BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已讀',
|
||||
read_at TIMESTAMP NULL COMMENT '閱讀時間',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '建立時間',
|
||||
expires_at TIMESTAMP NULL COMMENT '過期時間',
|
||||
extra_data JSON NULL COMMENT '額外數據',
|
||||
INDEX idx_notification_uuid (notification_uuid),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_job_uuid (job_uuid),
|
||||
INDEX idx_is_read (is_read),
|
||||
INDEX idx_created_at (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES dt_users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (job_uuid) REFERENCES dt_translation_jobs(job_uuid) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
'''
|
||||
}
|
||||
|
||||
|
47
create_test_data.py
Normal file
47
create_test_data.py
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
創建測試數據
|
||||
"""
|
||||
|
||||
from app import create_app, db
|
||||
from app.models import Notification, NotificationType
|
||||
from datetime import datetime
|
||||
|
||||
def create_test_notification():
|
||||
"""創建測試通知"""
|
||||
try:
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
print("Creating test notification...")
|
||||
|
||||
# 創建測試通知
|
||||
test_notification = Notification(
|
||||
user_id=4, # ymirliu 用戶
|
||||
type=NotificationType.INFO.value,
|
||||
title='測試通知',
|
||||
message='這是一個測試通知,用來驗證通知系統是否正常工作。',
|
||||
extra_data={
|
||||
'test_data': True,
|
||||
'created_by': 'test_script'
|
||||
}
|
||||
)
|
||||
|
||||
db.session.add(test_notification)
|
||||
db.session.commit()
|
||||
|
||||
print(f"Test notification created: {test_notification.notification_uuid}")
|
||||
print(f"Total notifications in database: {Notification.query.count()}")
|
||||
|
||||
# 顯示所有通知
|
||||
notifications = Notification.query.all()
|
||||
for notification in notifications:
|
||||
print(f" - {notification.title} ({notification.type})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating test notification: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_test_notification()
|
69
fix_notification_table.py
Normal file
69
fix_notification_table.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
修正通知表結構腳本
|
||||
"""
|
||||
|
||||
import pymysql
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 載入環境變數
|
||||
load_dotenv('C:/Users/EGG/WORK/data/user_scrip/TOOL/env.txt')
|
||||
|
||||
def fix_notification_table():
|
||||
"""修正通知表的欄位名稱"""
|
||||
try:
|
||||
# 連接資料庫
|
||||
connection = pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', ''),
|
||||
database=os.getenv('DB_NAME', 'doc_translator'),
|
||||
charset='utf8mb4'
|
||||
)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# 檢查 dt_notifications 表結構
|
||||
cursor.execute('DESCRIBE dt_notifications')
|
||||
columns = cursor.fetchall()
|
||||
|
||||
print('Current table structure:')
|
||||
for col in columns:
|
||||
print(f' {col[0]} {col[1]}')
|
||||
|
||||
# 檢查是否有 metadata 欄位
|
||||
has_metadata = any(col[0] == 'metadata' for col in columns)
|
||||
has_extra_data = any(col[0] == 'extra_data' for col in columns)
|
||||
|
||||
print(f'\nHas metadata column: {has_metadata}')
|
||||
print(f'Has extra_data column: {has_extra_data}')
|
||||
|
||||
if has_metadata and not has_extra_data:
|
||||
print('\nRenaming metadata column to extra_data...')
|
||||
cursor.execute('ALTER TABLE dt_notifications CHANGE metadata extra_data JSON NULL COMMENT "額外數據"')
|
||||
connection.commit()
|
||||
print('✅ Column renamed successfully')
|
||||
|
||||
# 再次檢查結構
|
||||
cursor.execute('DESCRIBE dt_notifications')
|
||||
columns = cursor.fetchall()
|
||||
print('\nUpdated table structure:')
|
||||
for col in columns:
|
||||
print(f' {col[0]} {col[1]}')
|
||||
|
||||
elif has_extra_data:
|
||||
print('✅ extra_data column already exists')
|
||||
else:
|
||||
print('❌ Neither metadata nor extra_data column found')
|
||||
|
||||
connection.close()
|
||||
print('\n✅ Database structure check completed')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error fixing notification table: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
fix_notification_table()
|
42
fix_table_simple.py
Normal file
42
fix_table_simple.py
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
修正通知表腳本
|
||||
"""
|
||||
|
||||
from app import create_app, db
|
||||
from sqlalchemy import text
|
||||
|
||||
def fix_notification_table():
|
||||
"""修正通知表"""
|
||||
try:
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
print("Fixing notification table...")
|
||||
|
||||
# 刪除通知表(如果存在)
|
||||
try:
|
||||
db.session.execute(text('DROP TABLE IF EXISTS dt_notifications'))
|
||||
db.session.commit()
|
||||
print("Old notification table dropped")
|
||||
except Exception as e:
|
||||
print(f"Info: {e}")
|
||||
|
||||
# 重新創建通知表
|
||||
db.create_all()
|
||||
print("New notification table created with correct structure")
|
||||
|
||||
# 檢查表結構
|
||||
result = db.session.execute(text('DESCRIBE dt_notifications'))
|
||||
columns = result.fetchall()
|
||||
print("New table structure:")
|
||||
for col in columns:
|
||||
print(f" {col[0]} {col[1]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fixing notification table: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
fix_notification_table()
|
@@ -167,11 +167,12 @@
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Document, House, Upload, List, Clock, User, Setting, Bell, Menu,
|
||||
Fold, Expand, SwitchButton, SuccessFilled, WarningFilled,
|
||||
CircleCloseFilled, InfoFilled
|
||||
CircleCloseFilled, InfoFilled, Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
import { initWebSocket, cleanupWebSocket } from '@/utils/websocket'
|
||||
|
||||
@@ -179,13 +180,16 @@ import { initWebSocket, cleanupWebSocket } from '@/utils/websocket'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// 響應式數據
|
||||
const sidebarCollapsed = ref(false)
|
||||
const mobileSidebarVisible = ref(false)
|
||||
const notificationDrawerVisible = ref(false)
|
||||
const notifications = ref([])
|
||||
const unreadCount = ref(0)
|
||||
|
||||
// 從 store 獲取通知相關數據
|
||||
const notifications = computed(() => notificationStore.notifications)
|
||||
const unreadCount = computed(() => notificationStore.unreadCount)
|
||||
|
||||
// 計算屬性
|
||||
const currentRoute = computed(() => route)
|
||||
@@ -239,10 +243,10 @@ const handleMenuClick = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const showNotifications = () => {
|
||||
const showNotifications = async () => {
|
||||
notificationDrawerVisible.value = true
|
||||
// 可以在這裡載入最新通知
|
||||
loadNotifications()
|
||||
// 載入最新通知
|
||||
await notificationStore.fetchNotifications()
|
||||
}
|
||||
|
||||
const handleUserMenuCommand = async (command) => {
|
||||
@@ -270,50 +274,22 @@ const handleUserMenuCommand = async (command) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadNotifications = async () => {
|
||||
const markAsRead = async (notificationId) => {
|
||||
try {
|
||||
// 這裡應該從 API 載入通知,目前使用模擬數據
|
||||
notifications.value = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'success',
|
||||
title: '翻譯完成',
|
||||
message: '檔案「文件.docx」翻譯完成',
|
||||
created_at: new Date().toISOString(),
|
||||
read: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'warning',
|
||||
title: '系統維護通知',
|
||||
message: '系統將於今晚 23:00 進行維護',
|
||||
created_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
read: true
|
||||
}
|
||||
]
|
||||
|
||||
unreadCount.value = notifications.value.filter(n => !n.read).length
|
||||
await notificationStore.markAsRead(notificationId)
|
||||
} catch (error) {
|
||||
console.error('載入通知失敗:', error)
|
||||
console.error('標記已讀失敗:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const markAsRead = (notificationId) => {
|
||||
const notification = notifications.value.find(n => n.id === notificationId)
|
||||
if (notification) {
|
||||
notification.read = true
|
||||
unreadCount.value = notifications.value.filter(n => !n.read).length
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
await notificationStore.markAllAsRead()
|
||||
} catch (error) {
|
||||
console.error('標記全部已讀失敗:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
notifications.value.forEach(notification => {
|
||||
notification.read = true
|
||||
})
|
||||
unreadCount.value = 0
|
||||
ElMessage.success('所有通知已標記為已讀')
|
||||
}
|
||||
|
||||
const getNotificationIcon = (type) => {
|
||||
const iconMap = {
|
||||
success: 'SuccessFilled',
|
||||
@@ -355,7 +331,7 @@ onMounted(() => {
|
||||
// initWebSocket()
|
||||
|
||||
// 載入通知
|
||||
loadNotifications()
|
||||
notificationStore.fetchNotifications()
|
||||
|
||||
// 監聽窗口大小變化
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
63
frontend/src/services/notification.js
Normal file
63
frontend/src/services/notification.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 通知相關 API 服務
|
||||
*/
|
||||
export const notificationAPI = {
|
||||
/**
|
||||
* 獲取通知列表
|
||||
* @param {Object} params - 查詢參數
|
||||
* @param {number} params.page - 頁碼
|
||||
* @param {number} params.per_page - 每頁數量
|
||||
* @param {string} params.status - 狀態過濾 ('all', 'unread', 'read')
|
||||
* @param {string} params.type - 類型過濾
|
||||
*/
|
||||
getNotifications(params = {}) {
|
||||
return request.get('/notifications', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 獲取單個通知詳情
|
||||
* @param {string} notificationId - 通知ID
|
||||
*/
|
||||
getNotification(notificationId) {
|
||||
return request.get(`/notifications/${notificationId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 標記通知為已讀
|
||||
* @param {string} notificationId - 通知ID
|
||||
*/
|
||||
markAsRead(notificationId) {
|
||||
return request.put(`/notifications/${notificationId}/read`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 標記所有通知為已讀
|
||||
*/
|
||||
markAllAsRead() {
|
||||
return request.put('/notifications/mark-all-read')
|
||||
},
|
||||
|
||||
/**
|
||||
* 刪除通知
|
||||
* @param {string} notificationId - 通知ID
|
||||
*/
|
||||
deleteNotification(notificationId) {
|
||||
return request.delete(`/notifications/${notificationId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空所有已讀通知
|
||||
*/
|
||||
clearNotifications() {
|
||||
return request.delete('/notifications/clear')
|
||||
},
|
||||
|
||||
/**
|
||||
* 創建測試通知(開發用)
|
||||
*/
|
||||
createTestNotification() {
|
||||
return request.post('/notifications/test')
|
||||
}
|
||||
}
|
310
frontend/src/stores/notification.js
Normal file
310
frontend/src/stores/notification.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { notificationAPI } from '@/services/notification'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export const useNotificationStore = defineStore('notification', {
|
||||
state: () => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
loading: false,
|
||||
pagination: {
|
||||
total: 0,
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
pages: 0
|
||||
}
|
||||
}),
|
||||
|
||||
getters: {
|
||||
unreadNotifications: (state) => {
|
||||
return state.notifications.filter(notification => !notification.is_read)
|
||||
},
|
||||
|
||||
readNotifications: (state) => {
|
||||
return state.notifications.filter(notification => notification.is_read)
|
||||
},
|
||||
|
||||
hasUnreadNotifications: (state) => {
|
||||
return state.unreadCount > 0
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 獲取通知列表
|
||||
* @param {Object} params - 查詢參數
|
||||
*/
|
||||
async fetchNotifications(params = {}) {
|
||||
try {
|
||||
this.loading = true
|
||||
|
||||
const response = await notificationAPI.getNotifications({
|
||||
page: this.pagination.page,
|
||||
per_page: this.pagination.per_page,
|
||||
...params
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
this.notifications = response.data.notifications
|
||||
this.unreadCount = response.data.unread_count
|
||||
this.pagination = {
|
||||
...this.pagination,
|
||||
...response.data.pagination
|
||||
}
|
||||
|
||||
console.log('📮 [Notification] 通知列表已更新', {
|
||||
total: this.pagination.total,
|
||||
unread: this.unreadCount
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Notification] 獲取通知列表失敗:', error)
|
||||
ElMessage.error('獲取通知失敗')
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 標記通知為已讀
|
||||
* @param {string} notificationId - 通知ID
|
||||
*/
|
||||
async markAsRead(notificationId) {
|
||||
try {
|
||||
const response = await notificationAPI.markAsRead(notificationId)
|
||||
|
||||
if (response.success) {
|
||||
// 更新本地狀態
|
||||
const notification = this.notifications.find(n => n.id === notificationId)
|
||||
if (notification && !notification.is_read) {
|
||||
notification.is_read = true
|
||||
notification.read = true
|
||||
notification.read_at = new Date().toISOString()
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1)
|
||||
}
|
||||
|
||||
console.log('✅ [Notification] 通知已標記為已讀:', notificationId)
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Notification] 標記已讀失敗:', error)
|
||||
ElMessage.error('標記已讀失敗')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 標記所有通知為已讀
|
||||
*/
|
||||
async markAllAsRead() {
|
||||
try {
|
||||
const response = await notificationAPI.markAllAsRead()
|
||||
|
||||
if (response.success) {
|
||||
// 更新本地狀態
|
||||
this.notifications.forEach(notification => {
|
||||
if (!notification.is_read) {
|
||||
notification.is_read = true
|
||||
notification.read = true
|
||||
notification.read_at = new Date().toISOString()
|
||||
}
|
||||
})
|
||||
this.unreadCount = 0
|
||||
|
||||
console.log('✅ [Notification] 所有通知已標記為已讀')
|
||||
ElMessage.success(response.message || '所有通知已標記為已讀')
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Notification] 標記全部已讀失敗:', error)
|
||||
ElMessage.error('標記全部已讀失敗')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 刪除通知
|
||||
* @param {string} notificationId - 通知ID
|
||||
*/
|
||||
async deleteNotification(notificationId) {
|
||||
try {
|
||||
const response = await notificationAPI.deleteNotification(notificationId)
|
||||
|
||||
if (response.success) {
|
||||
// 從本地狀態移除
|
||||
const index = this.notifications.findIndex(n => n.id === notificationId)
|
||||
if (index !== -1) {
|
||||
const notification = this.notifications[index]
|
||||
if (!notification.is_read) {
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1)
|
||||
}
|
||||
this.notifications.splice(index, 1)
|
||||
this.pagination.total = Math.max(0, this.pagination.total - 1)
|
||||
}
|
||||
|
||||
console.log('🗑️ [Notification] 通知已刪除:', notificationId)
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Notification] 刪除通知失敗:', error)
|
||||
ElMessage.error('刪除通知失敗')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空所有已讀通知
|
||||
*/
|
||||
async clearNotifications() {
|
||||
try {
|
||||
const response = await notificationAPI.clearNotifications()
|
||||
|
||||
if (response.success) {
|
||||
// 從本地狀態移除已讀通知
|
||||
this.notifications = this.notifications.filter(n => !n.is_read)
|
||||
this.pagination.total = this.notifications.length
|
||||
|
||||
console.log('🧹 [Notification] 已讀通知已清除')
|
||||
ElMessage.success(response.message || '已讀通知已清除')
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Notification] 清除通知失敗:', error)
|
||||
ElMessage.error('清除通知失敗')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加新通知(用於 WebSocket 推送)
|
||||
* @param {Object} notification - 通知數據
|
||||
*/
|
||||
addNotification(notification) {
|
||||
// 檢查是否已存在
|
||||
const exists = this.notifications.find(n => n.id === notification.id)
|
||||
if (!exists) {
|
||||
// 添加到列表開頭
|
||||
this.notifications.unshift(notification)
|
||||
|
||||
// 更新未讀數量
|
||||
if (!notification.is_read) {
|
||||
this.unreadCount += 1
|
||||
}
|
||||
|
||||
// 更新總數
|
||||
this.pagination.total += 1
|
||||
|
||||
console.log('📩 [Notification] 新通知已添加:', notification.title)
|
||||
|
||||
// 顯示通知
|
||||
ElMessage({
|
||||
type: this.getMessageType(notification.type),
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新通知
|
||||
* @param {Object} notification - 通知數據
|
||||
*/
|
||||
updateNotification(notification) {
|
||||
const index = this.notifications.findIndex(n => n.id === notification.id)
|
||||
if (index !== -1) {
|
||||
const oldNotification = this.notifications[index]
|
||||
|
||||
// 更新未讀數量
|
||||
if (oldNotification.is_read !== notification.is_read) {
|
||||
if (notification.is_read) {
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1)
|
||||
} else {
|
||||
this.unreadCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// 更新通知
|
||||
this.notifications[index] = { ...oldNotification, ...notification }
|
||||
|
||||
console.log('📝 [Notification] 通知已更新:', notification.id)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 創建測試通知(開發用)
|
||||
*/
|
||||
async createTestNotification() {
|
||||
try {
|
||||
const response = await notificationAPI.createTestNotification()
|
||||
|
||||
if (response.success) {
|
||||
// 重新獲取通知列表
|
||||
await this.fetchNotifications()
|
||||
ElMessage.success('測試通知已創建')
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Notification] 創建測試通知失敗:', error)
|
||||
ElMessage.error('創建測試通知失敗')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 設置分頁
|
||||
* @param {number} page - 頁碼
|
||||
* @param {number} per_page - 每頁數量
|
||||
*/
|
||||
setPagination(page, per_page) {
|
||||
this.pagination.page = page
|
||||
if (per_page) {
|
||||
this.pagination.per_page = per_page
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 重置狀態
|
||||
*/
|
||||
reset() {
|
||||
this.notifications = []
|
||||
this.unreadCount = 0
|
||||
this.loading = false
|
||||
this.pagination = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
pages: 0
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 獲取 ElMessage 類型
|
||||
* @param {string} type - 通知類型
|
||||
*/
|
||||
getMessageType(type) {
|
||||
const typeMap = {
|
||||
'success': 'success',
|
||||
'error': 'error',
|
||||
'warning': 'warning',
|
||||
'info': 'info',
|
||||
'system': 'info'
|
||||
}
|
||||
return typeMap[type] || 'info'
|
||||
}
|
||||
}
|
||||
})
|
@@ -1,5 +1,6 @@
|
||||
import { io } from 'socket.io-client'
|
||||
import { useJobsStore } from '@/stores/jobs'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
|
||||
/**
|
||||
@@ -93,6 +94,16 @@ class WebSocketService {
|
||||
this.socket.on('system_notification', (data) => {
|
||||
this.handleSystemNotification(data)
|
||||
})
|
||||
|
||||
// 新通知推送
|
||||
this.socket.on('new_notification', (data) => {
|
||||
this.handleNewNotification(data)
|
||||
})
|
||||
|
||||
// 系統消息
|
||||
this.socket.on('system_message', (data) => {
|
||||
this.handleSystemMessage(data)
|
||||
})
|
||||
|
||||
// 連接狀態回應
|
||||
this.socket.on('connected', (data) => {
|
||||
@@ -177,6 +188,89 @@ class WebSocketService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理新通知推送
|
||||
* @param {Object} data - 通知資料
|
||||
*/
|
||||
handleNewNotification(data) {
|
||||
try {
|
||||
console.log('📩 [WebSocket] 收到新通知:', data)
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// 添加通知到 store
|
||||
notificationStore.addNotification(data)
|
||||
|
||||
// 顯示桌面通知
|
||||
this.showDesktopNotification(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('處理新通知失敗:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理系統消息
|
||||
* @param {Object} data - 系統消息資料
|
||||
*/
|
||||
handleSystemMessage(data) {
|
||||
try {
|
||||
console.log('📢 [WebSocket] 收到系統消息:', data)
|
||||
|
||||
const { message, type } = data
|
||||
|
||||
// 顯示系統消息
|
||||
const messageType = type || 'info'
|
||||
ElMessage({
|
||||
type: messageType === 'system' ? 'info' : messageType,
|
||||
message: message,
|
||||
duration: 5000,
|
||||
showClose: true
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('處理系統消息失敗:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示桌面通知
|
||||
* @param {Object} notification - 通知資料
|
||||
*/
|
||||
showDesktopNotification(notification) {
|
||||
try {
|
||||
// 檢查瀏覽器是否支援通知
|
||||
if (!('Notification' in window)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 檢查通知權限
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(notification.title, {
|
||||
body: notification.message,
|
||||
icon: '/panjit-logo.png',
|
||||
tag: notification.id,
|
||||
requireInteraction: false
|
||||
})
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
// 請求通知權限
|
||||
Notification.requestPermission().then(permission => {
|
||||
if (permission === 'granted') {
|
||||
new Notification(notification.title, {
|
||||
body: notification.message,
|
||||
icon: '/panjit-logo.png',
|
||||
tag: notification.id,
|
||||
requireInteraction: false
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('顯示桌面通知失敗:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 訂閱任務狀態更新
|
||||
* @param {string} jobUuid - 任務 UUID
|
||||
|
41
recreate_notification_table.py
Normal file
41
recreate_notification_table.py
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
重新創建通知表腳本
|
||||
"""
|
||||
|
||||
from app import create_app, db
|
||||
from app.models import Notification
|
||||
|
||||
def recreate_notification_table():
|
||||
"""重新創建通知表"""
|
||||
try:
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
print("Recreating notification table...")
|
||||
|
||||
# 刪除通知表(如果存在)
|
||||
try:
|
||||
db.engine.execute('DROP TABLE IF EXISTS dt_notifications')
|
||||
print("✅ Old notification table dropped")
|
||||
except Exception as e:
|
||||
print(f"Info: {e}")
|
||||
|
||||
# 重新創建通知表
|
||||
db.create_all()
|
||||
print("✅ New notification table created with correct structure")
|
||||
|
||||
# 檢查表結構
|
||||
result = db.engine.execute('DESCRIBE dt_notifications')
|
||||
columns = result.fetchall()
|
||||
print("\nNew table structure:")
|
||||
for col in columns:
|
||||
print(f" {col[0]} {col[1]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error recreating notification table: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
recreate_notification_table()
|
74
test_notification_api.py
Normal file
74
test_notification_api.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
測試通知 API
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
|
||||
# API 基礎 URL
|
||||
BASE_URL = 'http://127.0.0.1:5000/api/v1'
|
||||
|
||||
def test_notification_api():
|
||||
"""測試通知 API 端點"""
|
||||
|
||||
# 首先需要登入獲取 JWT Token
|
||||
# 這裡使用預設的管理員帳號
|
||||
login_data = {
|
||||
'username': 'ymirliu',
|
||||
'password': 'password123' # LDAP 系統預設密碼
|
||||
}
|
||||
|
||||
try:
|
||||
print("Testing notification API...")
|
||||
|
||||
# 登入
|
||||
print("1. Testing login...")
|
||||
login_response = requests.post(f'{BASE_URL}/auth/login', json=login_data, timeout=10)
|
||||
print(f"Login status: {login_response.status_code}")
|
||||
|
||||
if login_response.status_code == 200:
|
||||
token = login_response.json()['data']['access_token']
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
|
||||
# 測試獲取通知列表
|
||||
print("2. Testing get notifications...")
|
||||
notifications_response = requests.get(f'{BASE_URL}/notifications', headers=headers, timeout=10)
|
||||
print(f"Get notifications status: {notifications_response.status_code}")
|
||||
if notifications_response.status_code == 200:
|
||||
data = notifications_response.json()
|
||||
print(f"Response: {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||
else:
|
||||
print(f"Error response: {notifications_response.text}")
|
||||
|
||||
# 測試創建測試通知
|
||||
print("3. Testing create test notification...")
|
||||
test_notification_response = requests.post(f'{BASE_URL}/notifications/test', headers=headers, timeout=10)
|
||||
print(f"Create test notification status: {test_notification_response.status_code}")
|
||||
if test_notification_response.status_code == 200:
|
||||
data = test_notification_response.json()
|
||||
print(f"Test notification created: {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||
else:
|
||||
print(f"Error response: {test_notification_response.text}")
|
||||
|
||||
# 再次獲取通知列表,應該能看到測試通知
|
||||
print("4. Testing get notifications again...")
|
||||
notifications_response = requests.get(f'{BASE_URL}/notifications', headers=headers, timeout=10)
|
||||
print(f"Get notifications status: {notifications_response.status_code}")
|
||||
if notifications_response.status_code == 200:
|
||||
data = notifications_response.json()
|
||||
print(f"Updated notifications: {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||
else:
|
||||
print(f"Error response: {notifications_response.text}")
|
||||
|
||||
else:
|
||||
print(f"Login failed: {login_response.text}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("Error: Could not connect to server. Make sure the Flask app is running on http://127.0.0.1:5000")
|
||||
except Exception as e:
|
||||
print(f"Error testing notification API: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_notification_api()
|
43
test_routes.py
Normal file
43
test_routes.py
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
測試路由註冊
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
|
||||
BASE_URL = 'http://127.0.0.1:5000'
|
||||
|
||||
def test_routes():
|
||||
"""測試各種路由是否正確註冊"""
|
||||
routes_to_test = [
|
||||
'/api/v1/auth/health',
|
||||
'/api/v1/notifications',
|
||||
'/api/v1/jobs',
|
||||
'/api/v1/health'
|
||||
]
|
||||
|
||||
print("Testing route registration...")
|
||||
|
||||
for route in routes_to_test:
|
||||
try:
|
||||
url = f"{BASE_URL}{route}"
|
||||
response = requests.get(url, timeout=5)
|
||||
|
||||
if response.status_code == 404:
|
||||
print(f"❌ {route} -> 404 NOT FOUND")
|
||||
elif response.status_code == 401:
|
||||
print(f"✅ {route} -> 401 UNAUTHORIZED (route exists, needs auth)")
|
||||
elif response.status_code == 200:
|
||||
print(f"✅ {route} -> 200 OK")
|
||||
else:
|
||||
print(f"🟡 {route} -> {response.status_code} {response.reason}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"❌ {route} -> CONNECTION ERROR")
|
||||
except Exception as e:
|
||||
print(f"❌ {route} -> ERROR: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_routes()
|
Reference in New Issue
Block a user