From 0bc8c4c81ce023820563a6f8fb71ccfa5999bcc5 Mon Sep 17 00:00:00 2001 From: beabigegg Date: Fri, 12 Sep 2025 08:56:44 +0800 Subject: [PATCH] backup --- .claude/settings.local.json | 56 ++ .dockerignore | 38 + .env | 51 + .env.example | 51 + .gitignore | 150 +++ DEPLOYMENT.md | 384 ++++++++ Dockerfile | 86 ++ README.md | 334 +++++++ USERMANUAL.md | 316 +++++++ api.txt | 2 + app.py | 159 ++++ app/__init__.py | 218 +++++ app/api/__init__.py | 25 + app/api/admin.py | 1071 +++++++++++++++++++++ app/api/auth.py | 325 +++++++ app/api/files.py | 691 ++++++++++++++ app/api/health.py | 224 +++++ app/api/jobs.py | 548 +++++++++++ app/api/notification.py | 331 +++++++ app/config.py | 157 ++++ app/models/__init__.py | 27 + app/models/cache.py | 138 +++ app/models/job.py | 296 ++++++ app/models/log.py | 211 +++++ app/models/notification.py | 97 ++ app/models/stats.py | 233 +++++ app/models/user.py | 118 +++ app/services/__init__.py | 19 + app/services/celery_service.py | 137 +++ app/services/dify_client.py | 302 ++++++ app/services/document_processor.py | 864 +++++++++++++++++ app/services/notification_service.py | 645 +++++++++++++ app/services/translation_service.py | 1227 +++++++++++++++++++++++++ app/tasks/__init__.py | 16 + app/tasks/translation.py | 350 +++++++ app/utils/__init__.py | 34 + app/utils/decorators.py | 238 +++++ app/utils/exceptions.py | 52 ++ app/utils/helpers.py | 280 ++++++ app/utils/ldap_auth.py | 232 +++++ app/utils/logger.py | 126 +++ app/utils/response.py | 84 ++ app/utils/timezone.py | 104 +++ app/utils/validators.py | 203 ++++ app/websocket.py | 230 +++++ celery_app.py | 34 + docker-compose.yml | 85 ++ frontend/.env.example | 18 + frontend/.eslintrc.cjs | 58 ++ frontend/.prettierrc | 14 + frontend/auto-imports.d.ts | 89 ++ frontend/index.html | 46 + frontend/package.json | 44 + frontend/public/panjit-logo.png | Bin 0 -> 7006 bytes frontend/src/App.vue | 95 ++ frontend/src/layouts/MainLayout.vue | 407 ++++++++ frontend/src/main.js | 49 + frontend/src/router/index.js | 174 ++++ frontend/src/services/admin.js | 139 +++ frontend/src/services/auth.js | 44 + frontend/src/services/jobs.js | 113 +++ frontend/src/services/notification.js | 63 ++ frontend/src/stores/admin.js | 311 +++++++ frontend/src/stores/auth.js | 193 ++++ frontend/src/stores/jobs.js | 411 +++++++++ frontend/src/stores/notification.js | 310 +++++++ frontend/src/style/components.scss | 325 +++++++ frontend/src/style/layouts.scss | 461 ++++++++++ frontend/src/style/main.scss | 187 ++++ frontend/src/style/mixins.scss | 272 ++++++ frontend/src/style/variables.scss | 106 +++ frontend/src/utils/request.js | 203 ++++ frontend/src/utils/websocket.js | 412 +++++++++ frontend/src/views/AdminJobsView.vue | 538 +++++++++++ frontend/src/views/AdminView.vue | 1035 +++++++++++++++++++++ frontend/src/views/HistoryView.vue | 840 +++++++++++++++++ frontend/src/views/HomeView.vue | 561 +++++++++++ frontend/src/views/JobDetailView.vue | 917 ++++++++++++++++++ frontend/src/views/JobListView.vue | 894 ++++++++++++++++++ frontend/src/views/LoginView.vue | 350 +++++++ frontend/src/views/NotFoundView.vue | 278 ++++++ frontend/src/views/ProfileView.vue | 562 +++++++++++ frontend/src/views/UploadView.vue | 865 +++++++++++++++++ frontend/vite.config.js | 72 ++ requirements.txt | 49 + update_db.py | 42 + 86 files changed, 23146 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 USERMANUAL.md create mode 100644 api.txt create mode 100644 app.py create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/admin.py create mode 100644 app/api/auth.py create mode 100644 app/api/files.py create mode 100644 app/api/health.py create mode 100644 app/api/jobs.py create mode 100644 app/api/notification.py create mode 100644 app/config.py create mode 100644 app/models/__init__.py create mode 100644 app/models/cache.py create mode 100644 app/models/job.py create mode 100644 app/models/log.py create mode 100644 app/models/notification.py create mode 100644 app/models/stats.py create mode 100644 app/models/user.py create mode 100644 app/services/__init__.py create mode 100644 app/services/celery_service.py create mode 100644 app/services/dify_client.py create mode 100644 app/services/document_processor.py create mode 100644 app/services/notification_service.py create mode 100644 app/services/translation_service.py create mode 100644 app/tasks/__init__.py create mode 100644 app/tasks/translation.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/decorators.py create mode 100644 app/utils/exceptions.py create mode 100644 app/utils/helpers.py create mode 100644 app/utils/ldap_auth.py create mode 100644 app/utils/logger.py create mode 100644 app/utils/response.py create mode 100644 app/utils/timezone.py create mode 100644 app/utils/validators.py create mode 100644 app/websocket.py create mode 100644 celery_app.py create mode 100644 docker-compose.yml create mode 100644 frontend/.env.example create mode 100644 frontend/.eslintrc.cjs create mode 100644 frontend/.prettierrc create mode 100644 frontend/auto-imports.d.ts create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/panjit-logo.png create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/layouts/MainLayout.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/services/admin.js create mode 100644 frontend/src/services/auth.js create mode 100644 frontend/src/services/jobs.js create mode 100644 frontend/src/services/notification.js create mode 100644 frontend/src/stores/admin.js create mode 100644 frontend/src/stores/auth.js create mode 100644 frontend/src/stores/jobs.js create mode 100644 frontend/src/stores/notification.js create mode 100644 frontend/src/style/components.scss create mode 100644 frontend/src/style/layouts.scss create mode 100644 frontend/src/style/main.scss create mode 100644 frontend/src/style/mixins.scss create mode 100644 frontend/src/style/variables.scss create mode 100644 frontend/src/utils/request.js create mode 100644 frontend/src/utils/websocket.js create mode 100644 frontend/src/views/AdminJobsView.vue create mode 100644 frontend/src/views/AdminView.vue create mode 100644 frontend/src/views/HistoryView.vue create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/src/views/JobDetailView.vue create mode 100644 frontend/src/views/JobListView.vue create mode 100644 frontend/src/views/LoginView.vue create mode 100644 frontend/src/views/NotFoundView.vue create mode 100644 frontend/src/views/ProfileView.vue create mode 100644 frontend/src/views/UploadView.vue create mode 100644 frontend/vite.config.js create mode 100644 requirements.txt create mode 100644 update_db.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9134613 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,56 @@ +{ + "permissions": { + "allow": [ + "Bash(dir:*)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend\\utils/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend\\utils/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend\\utils/**)", + "WebSearch", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend\\utils/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend\\utils/**)", + "Bash(npm install)", + "Bash(npm run lint)", + "Bash(npm run build:*)", + "Bash(python test:*)", + "Bash(python:*)", + "Bash(pip install:*)", + "Bash(grep:*)", + "Bash(start:*)", + "Bash(curl:*)", + "Bash(tasklist:*)", + "Bash(taskkill:*)", + "Bash(wmic process where:*)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TODOLIST\\backend\\routes/**)", + "Bash(move auth.py auth_old.py)", + "Bash(move auth_jwt.py auth.py)", + "Bash(git rm:*)", + "mcp__puppeteer__puppeteer_connect_active_tab", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\AI_meeting_assistant - V2.1/**)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\AI_meeting_assistant - V2.1\\services/**)", + "Bash(timeout:*)", + "Bash(del:*)", + "Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL/**)", + "Bash(docker logs:*)", + "Bash(docker stop:*)", + "Bash(docker rm:*)", + "Bash(docker build:*)", + "Bash(docker run:*)", + "Bash(docker exec:*)", + "Bash(docker restart:*)", + "Bash(docker cp:*)", + "Bash(docker-compose:*)", + "Bash(find:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..57abb2a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Node modules +node_modules/ +frontend/node_modules/ + +# Python cache +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so + +# Virtual environments +venv/ +env/ + +# Logs +*.log +logs/ + +# OS files +.DS_Store +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# Development files +*.tmp +*.temp +.cache/ + +# Upload files (for development) +uploads/* +!uploads/.gitkeep \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..4d1d060 --- /dev/null +++ b/.env @@ -0,0 +1,51 @@ +# Flask 配置 +FLASK_ENV=development +FLASK_DEBUG=true +SECRET_KEY=your-secret-key-change-in-production + +# 資料庫配置 +DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060 +MYSQL_HOST=mysql.theaken.com +MYSQL_PORT=33306 +MYSQL_USER=A060 +MYSQL_PASSWORD=WLeSCi0yhtc7 +MYSQL_DATABASE=db_A060 +MYSQL_CHARSET=utf8mb4 + +# Redis 配置 (Docker 環境使用 redis 服務名) +REDIS_URL=redis://redis:6379/0 +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 + +# LDAP 配置 +LDAP_SERVER=panjit.com.tw +LDAP_PORT=389 +LDAP_USE_SSL=false +LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW +LDAP_BIND_USER_PASSWORD=panjit2481 +LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw +LDAP_USER_LOGIN_ATTR=userPrincipalName + +# SMTP 配置 +SMTP_SERVER=mail.panjit.com.tw +SMTP_PORT=25 +SMTP_USE_TLS=false +SMTP_USE_SSL=false +SMTP_AUTH_REQUIRED=false +SMTP_SENDER_EMAIL=document_translator@panjit.com.tw +SMTP_SENDER_PASSWORD= + +# 檔案儲存 +UPLOAD_FOLDER=uploads +MAX_CONTENT_LENGTH=26214400 +FILE_RETENTION_DAYS=7 + +# 日誌配置 +LOG_LEVEL=INFO +LOG_FILE=logs/app.log + +# 管理員帳號 +ADMIN_EMAIL=ymirliu@panjit.com.tw + +# 應用設定 +APP_NAME=PANJIT Document Translator \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..61ffae9 --- /dev/null +++ b/.env.example @@ -0,0 +1,51 @@ +# Flask 配置 +FLASK_ENV=development +FLASK_DEBUG=true +SECRET_KEY=your-secret-key-change-in-production + +# 資料庫配置 +DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060 +MYSQL_HOST=mysql.theaken.com +MYSQL_PORT=33306 +MYSQL_USER=A060 +MYSQL_PASSWORD=WLeSCi0yhtc7 +MYSQL_DATABASE=db_A060 +MYSQL_CHARSET=utf8mb4 + +# Redis 配置 +REDIS_URL=redis://localhost:6379/0 +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 + +# LDAP 配置 +LDAP_SERVER=panjit.com.tw +LDAP_PORT=389 +LDAP_USE_SSL=false +LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW +LDAP_BIND_USER_PASSWORD=panjit2481 +LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw +LDAP_USER_LOGIN_ATTR=userPrincipalName + +# SMTP 配置 +SMTP_SERVER=mail.panjit.com.tw +SMTP_PORT=25 +SMTP_USE_TLS=false +SMTP_USE_SSL=false +SMTP_AUTH_REQUIRED=false +SMTP_SENDER_EMAIL=todo-system@panjit.com.tw +SMTP_SENDER_PASSWORD= + +# 檔案儲存 +UPLOAD_FOLDER=uploads +MAX_CONTENT_LENGTH=26214400 +FILE_RETENTION_DAYS=7 + +# 日誌配置 +LOG_LEVEL=INFO +LOG_FILE=logs/app.log + +# 管理員帳號 +ADMIN_EMAIL=ymirliu@panjit.com.tw + +# 應用設定 +APP_NAME=PANJIT Document Translator \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52ddbc1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,150 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Python compiled files +*.pyc +*.pyo +*.pyd + +# Flask session files +*flask_session/ +flask_session/ + +# Virtual environments +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs/ +*.log + +# Flask +instance/ +.webassets-cache + +# Session files +flask_session/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Uploads +uploads/ +temp/ +tmp/ + +# Node.js (frontend) +node_modules/ +frontend/node_modules/ +frontend/dist/ +frontend/.nuxt/ +frontend/.output/ +frontend/.vite/ +frontend/.npm/ + +# Frontend build artifacts +frontend/build/ +frontend/out/ + +# Frontend cache +frontend/.cache/ +frontend/.parcel-cache/ + +# Frontend environment variables (keep .env in root but ignore frontend .env files) +frontend/.env +frontend/.env.local +frontend/.env.development.local +frontend/.env.test.local +frontend/.env.production.local + +# Package managers +package-lock.json +yarn.lock +pnpm-lock.yaml + +# MacOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Backup files +*.bak +*.backup +*~ + +# Temporary files +*.tmp +*.temp + +# Configuration backups +*_old.py +*_backup.py +nul diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..0440787 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,384 @@ +# PANJIT 文件翻譯系統 - 部署手冊 + +## 部署概述 + +本系統已完成生產環境準備,包含完整的 Docker 配置和環境設定。系統使用 12010 端口,符合公司端口規範 (12010-12019)。 + +## 系統架構 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │ │ Backend │ │ Celery │ +│ (Vue.js) │ │ (Flask) │ │ (Worker) │ +│ Port: 12010 │────│ Port: 12010 │────│ 獨立容器 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + │ + ┌─────────────────┐ + │ Redis │ + │ (Queue/Cache) │ + │ Port: 6379 │ + └─────────────────┘ +``` + +**架構優化說明:** +- Flask 應用專注於 Web 服務,不再內建 Celery Worker +- Celery Worker 運行在獨立容器中,提供更好的資源管理 +- 消除了重複的 worker 實例,避免任務重複執行風險 +- 心跳同步更加穩定,減少集群管理開銷 + +## 快速部署 + +### 前置需求 +- Docker 20.10+ +- Docker Compose 1.28+ +- 至少 4GB 可用記憶體 +- 至少 20GB 可用磁碟空間 + +### 一鍵部署 +```bash +# 1. 進入專案目錄 +cd Document_translator_V2 + +# 2. 建置並啟動所有服務 +docker-compose up -d + +# 3. 檢查服務狀態 +docker-compose ps + +# 4. 查看日誌 +docker-compose logs -f app +``` + +### 驗證部署 +```bash +# 檢查主應用健康狀態 +curl http://localhost:12010/api/v1/health + +# 檢查前端訪問 +curl http://localhost:12010/ + +# 檢查 Celery Worker 狀態 +docker-compose exec celery-worker celery -A celery_app inspect active +``` + +## 詳細部署步驟 + +### 1. 環境準備 + +**檢查系統資源** +```bash +# 檢查記憶體 +free -h + +# 檢查磁碟空間 +df -h + +# 檢查端口占用 +netstat -tulpn | grep 12010 +``` + +**檢查 Docker 環境** +```bash +docker --version +docker-compose --version +docker system info +``` + +### 2. 配置文件檢查 + +系統已包含完整的生產環境配置: + +**資料庫配置** +- MySQL 主機:mysql.theaken.com:33306 +- 資料庫:db_A060 +- 連接已內建在 Docker 映像中 + +**郵件配置** +- SMTP 服務器:mail.panjit.com.tw +- 端口:25 (無認證) + +**LDAP 配置** +- 服務器:panjit.com.tw +- 認證已配置完成 + +### 3. 建置映像 + +```bash +# 建置主應用映像 +docker build -t panjit-translator:latest . + +# 檢查映像大小 +docker images panjit-translator +``` + +### 4. 啟動服務 + +**使用 Docker Compose (推薦)** +```bash +# 啟動所有服務 +docker-compose up -d + +# 分別檢查各服務 +docker-compose ps +docker-compose logs app +docker-compose logs celery-worker +docker-compose logs redis +``` + +**手動 Docker 部署** +```bash +# 啟動 Redis +docker run -d --name panjit-redis \ + -p 6379:6379 \ + -v redis_data:/data \ + redis:7-alpine + +# 啟動主應用 +docker run -d --name panjit-translator \ + -p 12010:12010 \ + -v $(pwd)/uploads:/app/uploads \ + -v $(pwd)/cache:/app/cache \ + -v $(pwd)/logs:/app/logs \ + --link panjit-redis:redis \ + -e REDIS_URL=redis://redis:6379/0 \ + panjit-translator:latest + +# 啟動 Celery Worker +docker run -d --name panjit-worker \ + -v $(pwd)/uploads:/app/uploads \ + -v $(pwd)/cache:/app/cache \ + --link panjit-redis:redis \ + -e REDIS_URL=redis://redis:6379/0 \ + panjit-translator:latest \ + celery -A celery_app worker --loglevel=info +``` + +### 5. 服務驗證 + +**健康檢查** +```bash +# API 健康檢查 +curl -f http://localhost:12010/api/v1/health + +# 預期回應: +{ + "status": "healthy", + "timestamp": "2025-09-04T12:00:00Z", + "service": "PANJIT Document Translator API", + "version": "2.0.0" +} +``` + +**功能測試** +```bash +# 測試 LDAP 連接 (需要有效帳號) +curl -X POST http://localhost:12010/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "test@panjit.com.tw", "password": "password"}' + +# 測試檔案上傳端點 +curl -f http://localhost:12010/api/v1/files/supported-formats +``` + +**Celery 檢查** +```bash +# 檢查 Worker 狀態 +docker-compose exec celery-worker celery -A celery_app inspect active + +# 檢查佇列狀態 +docker-compose exec celery-worker celery -A celery_app inspect reserved +``` + +## 監控和維護 + +### 日誌管理 +```bash +# 查看實時日誌 +docker-compose logs -f + +# 查看特定服務日誌 +docker-compose logs -f app +docker-compose logs -f celery-worker + +# 查看錯誤日誌 +docker-compose logs app | grep ERROR +``` + +### 性能監控 +```bash +# 檢查容器資源使用 +docker stats + +# 檢查服務狀態 +docker-compose ps + +# 檢查健康狀況 +docker inspect panjit-translator-app --format='{{json .State.Health}}' +``` + +### 備份和恢復 +```bash +# 備份上傳檔案 +tar -czf uploads_backup_$(date +%Y%m%d).tar.gz uploads/ + +# 備份快取 +tar -czf cache_backup_$(date +%Y%m%d).tar.gz cache/ + +# 備份日誌 +tar -czf logs_backup_$(date +%Y%m%d).tar.gz logs/ +``` + +## 更新和升級 + +### 更新應用 +```bash +# 1. 備份重要數據 +docker-compose exec app tar -czf /app/backup_$(date +%Y%m%d).tar.gz uploads/ cache/ + +# 2. 停止服務 +docker-compose down + +# 3. 更新代碼和重新建置 +git pull origin main +docker-compose build + +# 4. 啟動服務 +docker-compose up -d + +# 5. 驗證更新 +curl http://localhost:12010/api/v1/health +``` + +### 滾動更新(零停機時間) +```bash +# 1. 建置新映像 +docker build -t panjit-translator:v2.1.0 . + +# 2. 更新 docker-compose.yml 中的映像版本 +# 3. 逐一重啟服務 +docker-compose up -d --no-deps app +docker-compose up -d --no-deps celery-worker +``` + +## 故障排除 + +### 常見問題 + +**1. 容器無法啟動** +```bash +# 檢查端口占用 +sudo netstat -tulpn | grep 12010 + +# 檢查映像是否存在 +docker images panjit-translator + +# 檢查容器日誌 +docker-compose logs app +``` + +**2. 資料庫連接失敗** +```bash +# 測試資料庫連接 +docker-compose exec app python -c " +import pymysql +try: + conn = pymysql.connect( + host='mysql.theaken.com', + port=33306, + user='A060', + password='WLeSCi0yhtc7', + database='db_A060' + ) + print('資料庫連接成功') + conn.close() +except Exception as e: + print(f'資料庫連接失敗: {e}') +" +``` + +**3. Celery Worker 無法啟動** +```bash +# 檢查 Redis 連接 +docker-compose exec app python -c " +import redis +try: + r = redis.Redis.from_url('redis://redis:6379/0') + r.ping() + print('Redis 連接成功') +except Exception as e: + print(f'Redis 連接失敗: {e}') +" + +# 重啟 Worker +docker-compose restart celery-worker +``` + +### 緊急恢復 +```bash +# 完全重置並重啟 +docker-compose down -v +docker-compose up -d + +# 清理未使用的映像和容器 +docker system prune -f + +# 重新建置 +docker-compose build --no-cache +docker-compose up -d +``` + +## 安全配置 + +### 防火牆設定 +```bash +# 開放必要端口 +sudo ufw allow 12010/tcp + +# 限制 Redis 端口(僅本機) +sudo ufw deny 6379/tcp +``` + +### SSL/TLS 配置 +如需 HTTPS,建議在前端配置 Nginx 反向代理: + +```nginx +server { + listen 443 ssl; + server_name translator.panjit.com.tw; + + ssl_certificate /path/to/certificate.crt; + ssl_certificate_key /path/to/private.key; + + location / { + proxy_pass http://localhost:12010; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## 聯繫支援 + +如遇到部署問題,請聯繫: + +**PANJIT IT Team** +- Email: it-support@panjit.com.tw +- 內線電話: 2481 +- 緊急支援: 24/7 待命 + +**系統資訊** +- 版本:v2.1.0 (優化版) +- 部署日期:2025-09-11 +- 維護人員:System Administrator +- 更新內容: + - 優化 Celery 架構,移除重複 worker + - 修正 Excel 翻譯輸出格式 + - 清理開發階段檔案 + - 簡化 Docker 映像構建 + +--- +*本部署手冊適用於 PANJIT 文件翻譯系統 v2.1.0* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..855787c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# PANJIT Document Translator - Production Dockerfile +# Multi-stage build for optimized production image + +# Stage 1: Frontend build +FROM node:18-alpine AS frontend-builder + +WORKDIR /app/frontend + +# Copy package files +COPY frontend/package*.json ./ + +# Install dependencies (including dev dependencies for build) +RUN npm ci + +# Copy source files (excluding node_modules) +COPY frontend/src ./src +COPY frontend/public ./public +COPY frontend/index.html ./ +COPY frontend/vite.config.js ./ +COPY frontend/auto-imports.d.ts ./ + +# Build frontend +RUN npm run build + +# Stage 2: Python production image +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FLASK_ENV=production \ + PORT=12010 + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app/ ./app/ +COPY *.py ./ + +# Copy configuration files +COPY .env ./ +COPY api.txt ./ + +# Copy batch scripts (for reference) +COPY *.bat ./scripts/ + +# Copy frontend build output +COPY --from=frontend-builder /app/frontend/dist ./static + +# Create required directories +RUN mkdir -p uploads logs scripts + +# Create startup script (removed redundant celery worker) +RUN echo '#!/bin/bash' > /app/start.sh && \ + echo 'python app.py' >> /app/start.sh && \ + chmod +x /app/start.sh + +# Set permissions +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app && \ + chmod -R 755 /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 12010 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:12010/api/v1/health || exit 1 + +# Start application +CMD ["/app/start.sh"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..66209e3 --- /dev/null +++ b/README.md @@ -0,0 +1,334 @@ +# PANJIT 文件翻譯系統 + +## 專案簡介 + +PANJIT 文件翻譯系統是一個企業級的多語言文件翻譯平台,支持多種文件格式的自動翻譯。系統採用 Flask + Vue.js 架構,整合 LDAP 認證、Celery 異步處理、通知系統等企業功能。 + +### 主要功能 + +- **多格式翻譯**:支援 Word (.docx)、PowerPoint (.pptx)、Excel (.xlsx)、PDF 文件翻譯 +- **多語言支援**:支援繁體中文、簡體中文、英語、日語、韓語、越南語等 +- **LDAP 認證**:整合企業 Active Directory 用戶系統 +- **異步處理**:使用 Celery + Redis 處理翻譯任務 +- **即時通知**:WebSocket 即時狀態更新 + 郵件通知 +- **檔案管理**:支援單檔下載、批量下載、合併檔案下載 +- **管理後台**:系統統計、用戶管理等功能 + +## 技術架構 + +**後端** +- Python 3.8+ +- Flask 3.0 + SQLAlchemy 2.0 +- MySQL 資料庫 +- Celery 4.5 + Redis +- LDAP3 認證 +- Socket.IO 即時通信 + +**前端** +- Vue.js 3.0 + Composition API +- Element Plus UI 框架 +- Pinia 狀態管理 +- Vite 建置工具 + +## 系統需求 + +- Python 3.8+ +- Node.js 16+ +- Redis Server +- MySQL 資料庫(已配置) +- Windows 10+ 或 Linux 系統 + +## 快速啟動 + +### 生產部署(推薦) + +**使用 Docker Compose 一鍵部署:** + +```bash +# 1. 進入專案目錄 +cd Document_translator_V2 + +# 2. 建置並啟動所有服務 +docker-compose up -d + +# 3. 檢查服務狀態 +docker-compose ps + +# 4. 訪問系統 +curl http://localhost:12010/api/v1/health +``` + +詳細部署說明請參考 [DEPLOYMENT.md](DEPLOYMENT.md) + +### 開發環境 + +1. **克隆專案** + ```bash + cd Document_translator_V2 + ``` + +2. **手動啟動後端** + ```bash + # 建立虛擬環境 + python -m venv venv + venv\Scripts\activate + + # 安裝依賴 + pip install -r requirements.txt + + # 啟動應用 + python app.py + ``` + +3. **手動啟動前端**(另開命令視窗) + ```bash + cd frontend + npm install + npm run dev + ``` + +4. **手動啟動 Celery Worker**(另開命令視窗) + ```bash + venv\Scripts\activate + celery -A celery_app worker --loglevel=info --pool=solo + ``` + +### 系統訪問 + +- **前端界面**: http://127.0.0.1:5173 (開發模式) +- **後端 API**: http://127.0.0.1:12010 (生產模式) +- **API 文檔**: http://127.0.0.1:12010/api +- **健康檢查**: http://127.0.0.1:12010/api/v1/health + +## 專案結構 + +``` +Document_translator_V2/ +├── app/ # 後端應用 +│ ├── api/ # API 路由 +│ ├── models/ # 資料模型 +│ ├── services/ # 業務邏輯 +│ ├── tasks/ # Celery 任務 +│ └── utils/ # 工具函數 +├── frontend/ # 前端應用 +│ ├── src/ +│ │ ├── components/ # Vue 組件 +│ │ ├── views/ # 頁面視圖 +│ │ ├── stores/ # Pinia 狀態 +│ │ └── utils/ # 工具函數 +│ └── package.json +├── uploads/ # 檔案上傳目錄 +├── logs/ # 日誌目錄 +├── app.py # 主應用入口 +├── celery_app.py # Celery 配置 +├── requirements.txt # Python 依賴 +└── .env # 環境變數 +``` + +## 配置說明 + +### 環境變數 (.env) + +系統需要以下環境變數配置: + +```env +# 資料庫配置 +DATABASE_URL=mysql+pymysql://user:pass@host:port/db_name +MYSQL_HOST=mysql.theaken.com +MYSQL_PORT=33306 +MYSQL_USER=A060 +MYSQL_DATABASE=db_A060 + +# LDAP 配置 +LDAP_SERVER=panjit.com.tw +LDAP_PORT=389 +LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW + +# SMTP 配置 +SMTP_SERVER=mail.panjit.com.tw +SMTP_PORT=25 +SMTP_SENDER_EMAIL=todo-system@panjit.com.tw + +# Redis 配置 +REDIS_URL=redis://localhost:6379/0 +``` + +### API 配置 (api.txt) + +系統使用 Dify API 進行翻譯,需要配置: + +``` +base_url:YOUR_DIFY_API_BASE_URL +api:YOUR_DIFY_API_KEY +``` + +## 部署指南 + +### Docker 部署 + +1. **建置映像** + ```bash + docker build -t panjit-translator . + ``` + +2. **啟動服務** + ```bash + docker-compose up -d + ``` + +3. **檢查狀態** + ```bash + docker-compose ps + docker logs panjit-translator + ``` + +### 生產環境 + +1. **使用 Gunicorn 啟動** + ```bash + pip install gunicorn + gunicorn -w 4 -b 0.0.0.0:12010 app:app + ``` + +2. **前端建置** + ```bash + cd frontend + npm run build + ``` + +3. **配置 Web 服務器** + 將 `frontend/dist` 部署到 Nginx 或 Apache + +## API 文檔 + +### 認證相關 +- `POST /api/v1/auth/login` - 用戶登入 +- `POST /api/v1/auth/logout` - 用戶登出 +- `GET /api/v1/auth/me` - 獲取當前用戶 + +### 檔案上傳 +- `POST /api/v1/files/upload` - 上傳檔案 + +### 任務管理 +- `GET /api/v1/jobs` - 獲取任務列表 +- `GET /api/v1/jobs/{uuid}` - 獲取任務詳情 +- `POST /api/v1/jobs/{uuid}/retry` - 重試任務 + +### 檔案下載 +- `GET /api/v1/files/{uuid}/download/{lang}` - 下載指定語言版本 +- `GET /api/v1/files/{uuid}/download/batch` - 批量下載 (ZIP) +- `GET /api/v1/files/{uuid}/download/combine` - 下載合併檔案 + +### 通知系統 +- `GET /api/v1/notifications` - 獲取通知列表 +- `POST /api/v1/notifications/{id}/read` - 標記已讀 + +### 系統管理 +- `GET /api/v1/admin/stats` - 系統統計 +- `GET /api/v1/health` - 健康檢查 + +## 故障排除 + +### 常見問題 + +1. **Redis 連接失敗** + - 確認 Redis 服務是否運行 + - 檢查 REDIS_URL 設定 + +2. **資料庫連接失敗** + - 確認 MySQL 連接參數 + - 檢查網路連接 + +3. **LDAP 認證失敗** + - 確認 LDAP 服務器設定 + - 檢查服務帳號權限 + +4. **檔案上傳失敗** + - 檢查 uploads 目錄權限 + - 確認磁碟空間充足 + +### 日誌查看 + +```bash +# 應用日誌 +tail -f logs/app.log + +# Celery 日誌 +tail -f logs/celery.log + +# 查看錯誤日誌 +grep ERROR logs/app.log +``` + +## 維護指南 + +### 資料庫維護 + +```bash +# 備份資料庫 +mysqldump -u A060 -p db_A060 > backup_$(date +%Y%m%d).sql + +# 清理舊檔案(90天前) +find uploads/ -mtime +90 -delete +``` + +### 日誌清理 + +```bash +# 清理應用日誌(保留30天) +find logs/ -name "*.log" -mtime +30 -delete +``` + +## Docker 部署 + +### 快速部署 + +```bash +# 1. 建置 Docker 映像 +docker build -t panjit-translator . + +# 2. 運行容器 +docker run -d -p 12010:12010 --name panjit-translator panjit-translator + +# 3. 檢查服務狀態 +docker ps +docker logs panjit-translator +``` + +### 服務管理 + +```bash +# 停止服務 +docker stop panjit-translator + +# 啟動服務 +docker start panjit-translator + +# 重啟服務 +docker restart panjit-translator +``` + +### 部署方式 + +```bash +# Docker 部署 (推薦) +docker build -t panjit-translator . +docker run -d -p 12010:12010 --name panjit-translator panjit-translator +``` + +## 支援與聯絡 + +**PANJIT IT Team** +- Email: it-support@panjit.com.tw +- 內線電話: 2481 +- 辦公時間: 週一至週五 9:00-18:00 + +## 版本資訊 + +- **版本**: v2.0.0 +- **發布日期**: 2025-09-04 +- **維護人員**: PANJIT IT Team + +## 授權條款 + +此軟體為 PANJIT 集團內部使用系統,版權歸 PANJIT 所有,僅供公司內部使用。 \ No newline at end of file diff --git a/USERMANUAL.md b/USERMANUAL.md new file mode 100644 index 0000000..d79abbf --- /dev/null +++ b/USERMANUAL.md @@ -0,0 +1,316 @@ +# PANJIT 文件翻譯系統 - 用戶操作手冊 + +## 目錄 +1. [系統登入](#系統登入) +2. [首頁概覽](#首頁概覽) +3. [檔案上傳與翻譯](#檔案上傳與翻譯) +4. [任務管理](#任務管理) +5. [檔案下載](#檔案下載) +6. [通知系統](#通知系統) +7. [用戶設定](#用戶設定) +8. [常見問題](#常見問題) + +--- + +## 系統登入 + +### 1.1 訪問系統 +- 打開瀏覽器,輸入系統網址 +- 建議使用 Chrome、Firefox 或 Edge 瀏覽器 +- 確保瀏覽器版本為最新版本以獲得最佳體驗 + +### 1.2 登入步驟 +1. 在登入頁面輸入您的 PANJIT 帳號 + - 帳號格式:`username@panjit.com.tw` + - 例如:`john.smith@panjit.com.tw` + +2. 輸入您的網域密碼 + +3. 點擊「登入」按鈕 + +### 1.3 登入問題排除 +- **帳號或密碼錯誤**:請確認輸入的帳號密碼是否正確 +- **網路連線問題**:檢查網路連線是否正常 +- **帳號被鎖定**:聯繫 IT 部門解除帳號鎖定 + +--- + +## 首頁概覽 + +### 2.1 頁面佈局 +登入成功後,您將看到系統主頁面,包含以下區域: + +**頂部導航欄** +- 左側:系統 LOGO 和頁面標題 +- 右側:通知鈴鐺、用戶頭像和下拉選單 + +**左側選單** +- 首頁:系統概覽和統計信息 +- 檔案上傳:上傳需要翻譯的檔案 +- 任務列表:查看所有翻譯任務 +- 歷史記錄:查看已完成的翻譯記錄 + +**主要內容區** +- 顯示當前頁面的主要內容 +- 包含各種操作按鈕和信息展示 + +### 2.2 首頁統計信息 +首頁顯示您的個人使用統計: +- 總任務數量 +- 進行中的任務 +- 已完成任務 +- 失敗任務數量 + +--- + +## 檔案上傳與翻譯 + +### 3.1 支援的檔案格式 +系統支援以下檔案格式: +- **Word 文件**:`.docx` +- **PowerPoint 簡報**:`.pptx` +- **Excel 試算表**:`.xlsx` +- **PDF 文件**:`.pdf` + +### 3.2 上傳步驟 +1. **進入上傳頁面** + - 點擊左側選單的「檔案上傳」 + +2. **選擇檔案** + - 點擊「選擇檔案」按鈕或拖拽檔案到上傳區域 + - 可以一次選擇多個檔案進行批量上傳 + - 單個檔案最大 50MB + +3. **設定翻譯選項** + - **來源語言**:選擇原始檔案的語言 + - **目標語言**:選擇要翻譯成的語言(可多選) + - 支援的語言包括:繁體中文、簡體中文、英語、日語、韓語、越南語等 + +4. **開始翻譯** + - 確認設定無誤後,點擊「開始翻譯」按鈕 + - 系統會顯示上傳進度 + - 上傳完成後,任務會自動加入翻譯佇列 + +### 3.3 翻譯設定說明 +- **自動偵測語言**:系統可以自動偵測來源語言 +- **多語言翻譯**:可同時翻譯成多種語言 +- **保留格式**:翻譯後會保持原始檔案的格式和排版 + +--- + +## 任務管理 + +### 4.1 任務列表 +在「任務列表」頁面可以查看所有翻譯任務: + +**任務狀態說明** +- 🟡 **等待中**:任務已提交,等待處理 +- 🔵 **處理中**:正在進行翻譯 +- 🟢 **已完成**:翻譯成功完成 +- 🔴 **失敗**:翻譯過程中發生錯誤 +- ⏸️ **已取消**:任務已被取消 + +**任務信息** +- 檔案名稱 +- 來源語言和目標語言 +- 任務狀態和進度 +- 建立時間 +- 預估完成時間 + +### 4.2 任務操作 +針對不同狀態的任務,可以執行以下操作: + +**等待中/處理中的任務** +- 查看詳細信息 +- 取消任務 + +**已完成的任務** +- 查看詳細信息 +- 下載翻譯檔案 +- 刪除任務 + +**失敗的任務** +- 查看錯誤信息 +- 重試翻譯 +- 刪除任務 + +### 4.3 任務詳情 +點擊任務名稱可以查看詳細信息: +- 檔案基本信息 +- 翻譯設定 +- 處理時間軸 +- 錯誤日誌(如有) +- 檔案下載選項 + +--- + +## 檔案下載 + +### 5.1 下載方式 +系統提供多種檔案下載方式: + +**單一語言下載** +- 在任務詳情頁面,點擊對應語言的下載按鈕 +- 檔案會以原始格式下載,如 `.docx`、`.pdf` 等 + +**合併檔案下載** +- 點擊「下載合併檔案」 +- 將多種語言的翻譯合併在一個檔案中 +- 適合需要對照不同語言版本的情況 + +**批量下載(ZIP)** +- 點擊「下載全部檔案(ZIP)」 +- 將所有翻譯檔案打包成 ZIP 檔案下載 +- 包含所有語言版本和原始檔案 + +### 5.2 下載注意事項 +- 下載的檔案會保持原始格式和排版 +- 合併檔案中會清楚標示不同語言的內容 +- 建議在網路穩定的環境下進行下載 +- 大檔案下載可能需要較長時間,請耐心等待 + +--- + +## 通知系統 + +### 6.1 通知類型 +系統會在以下情況發送通知: +- 翻譯任務完成 +- 翻譯任務失敗 +- 系統維護通知 +- 重要更新通知 + +### 6.2 通知方式 +**網頁通知** +- 頂部導航欄的鈴鐺圖示會顯示未讀通知數量 +- 點擊鈴鐺可查看通知列表 +- 新通知會以醒目顏色標示 + +**郵件通知** +- 重要通知會同時發送到您的郵箱 +- 包含任務完成、失敗等關鍵事件 +- 請確保郵箱設定正確並定期查看 + +### 6.3 通知管理 +- **標記已讀**:點擊「標記已讀」按鈕 +- **全部已讀**:點擊「全部標記已讀」清空所有未讀通知 +- **通知設定**:在用戶設定中可調整通知偏好 + +--- + +## 用戶設定 + +### 7.1 個人資料 +在右上角點擊用戶頭像,選擇「個人設定」: +- 查看帳號信息 +- 修改顯示名稱 +- 更新聯絡資料 + +### 7.2 系統偏好設定 +- **語言偏好**:設定預設的來源語言和目標語言 +- **通知設定**:選擇接收哪些類型的通知 +- **介面設定**:調整頁面顯示選項 + +### 7.3 使用統計 +查看個人使用統計: +- 總翻譯檔案數量 +- 翻譯字數統計 +- 最常使用的語言對 +- 月度使用趨勢 + +--- + +## 常見問題 + +### 8.1 檔案上傳相關 + +**Q: 為什麼我的檔案上傳失敗?** +A: 可能的原因包括: +- 檔案格式不支援(請確認是 .docx、.pptx、.xlsx、.pdf) +- 檔案大小超過 50MB 限制 +- 網路連線不穩定 +- 檔案已損壞或受密碼保護 + +**Q: 可以上傳受密碼保護的檔案嗎?** +A: 目前系統不支援受密碼保護的檔案,請先解除密碼保護後再上傳。 + +**Q: 為什麼我的 PDF 檔案翻譯結果不理想?** +A: PDF 檔案的文字提取可能受到以下因素影響: +- PDF 是圖片掃描版本(無法提取文字) +- 複雜的排版格式 +- 特殊字型或符號 +建議使用 Word 檔案獲得最佳翻譯效果。 + +### 8.2 翻譯品質相關 + +**Q: 如何提高翻譯品質?** +A: 建議遵循以下原則: +- 使用標準格式的檔案 +- 確保原文語法正確 +- 避免過於複雜的句子結構 +- 專業術語可能需要人工校對 + +**Q: 翻譯結果可以編輯嗎?** +A: 系統提供的是機器翻譯結果,下載後可以使用相應的軟體(如 Word、PowerPoint)進行編輯修改。 + +### 8.3 系統使用相關 + +**Q: 為什麼任務一直顯示「等待中」?** +A: 這通常是正常情況: +- 系統正在排隊處理任務 +- 大檔案需要較長處理時間 +- 如超過 30 分鐘仍未開始處理,請聯繫技術支援 + +**Q: 可以取消已提交的任務嗎?** +A: 可以,在任務狀態為「等待中」或「處理中」時,可以在任務列表或詳情頁面點擊「取消任務」。 + +**Q: 歷史任務會保存多久?** +A: 已完成的任務和檔案會保存 90 天,建議及時下載需要的翻譯檔案。 + +### 8.4 技術支援 + +**Q: 遇到系統錯誤怎麼辦?** +A: 請按以下步驟處理: +1. 嘗試重新整理頁面 +2. 清除瀏覽器快取和 Cookie +3. 更換瀏覽器或使用無痕模式 +4. 如問題持續,請聯繫技術支援 + +**聯絡方式:** +- Email: it-support@panjit.com.tw +- 內線電話: 2481 +- 服務時間: 週一至週五 9:00-18:00 + +--- + +## 附錄 + +### 支援的語言清單 +- 繁體中文 (Traditional Chinese) +- 簡體中文 (Simplified Chinese) +- 英語 (English) +- 日語 (Japanese) +- 韓語 (Korean) +- 越南語 (Vietnamese) +- 泰語 (Thai) +- 德語 (German) +- 法語 (French) +- 西班牙語 (Spanish) +- 俄語 (Russian) +- 阿拉伯語 (Arabic) + +### 瀏覽器相容性 +- **推薦瀏覽器**:Chrome 80+、Firefox 75+、Edge 80+ +- **行動裝置**:支援響應式設計,可在手機和平板上使用 +- **注意**:IE 瀏覽器不支援,請使用現代瀏覽器 + +### 檔案大小和數量限制 +- **單檔大小**:最大 50MB +- **批量上傳**:最多同時上傳 10 個檔案 +- **總容量**:每用戶 1GB 儲存空間 +- **並發任務**:最多同時處理 5 個翻譯任務 + +--- + +*本手冊最後更新日期:2025年9月4日* +*如有疑問或建議,請聯繫 PANJIT IT Team* \ No newline at end of file diff --git a/api.txt b/api.txt new file mode 100644 index 0000000..2c5d773 --- /dev/null +++ b/api.txt @@ -0,0 +1,2 @@ +base_url:https://dify.theaken.com/v1 +api:app-SmB3TwVMcp5OyQviYeAoTden \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..3f253f1 --- /dev/null +++ b/app.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Document Translator Flask 應用程式入口 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import os +import sys +from pathlib import Path +from datetime import datetime + +# 添加專案根目錄到 Python 路徑 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from app import create_app, db +from app.models import User, TranslationJob, JobFile, TranslationCache, APIUsageStats, SystemLog + +# 創建 Flask 應用 +app = create_app() + +# 導出 Celery 實例供 worker 使用 +celery = app.celery + +# 確保在模組級別可以訪問 +__all__ = ['app', 'celery'] + + +@app.shell_context_processor +def make_shell_context(): + """為 Flask shell 提供上下文""" + return { + 'db': db, + 'User': User, + 'TranslationJob': TranslationJob, + 'JobFile': JobFile, + 'TranslationCache': TranslationCache, + 'APIUsageStats': APIUsageStats, + 'SystemLog': SystemLog + } + + +@app.cli.command() +def init_db(): + """初始化資料庫""" + click.echo('Initializing database...') + db.create_all() + click.echo('Database initialized.') + + +@app.cli.command() +def test(): + """運行測試""" + import unittest + tests = unittest.TestLoader().discover('tests') + unittest.TextTestRunner(verbosity=2).run(tests) + + +@app.route('/') +def index(): + """首頁路由 - 服務前端應用""" + try: + from flask import send_from_directory + return send_from_directory('/app/static', 'index.html') + except Exception as e: + # 如果靜態文件不存在,返回API信息 + return { + 'application': 'PANJIT Document Translator', + 'version': '1.0.0', + 'status': 'running', + 'api_base_url': '/api/v1', + 'note': 'Frontend files not found, serving API info' + } + + +@app.route('/') +def serve_static(path): + """服務靜態文件""" + try: + from flask import send_from_directory + return send_from_directory('/app/static', path) + except Exception: + # 如果文件不存在,返回index.html (SPA路由) + return send_from_directory('/app/static', 'index.html') + + +@app.route('/api') +def api_info(): + """API 資訊""" + return { + 'api_version': 'v1', + 'base_url': '/api/v1', + 'endpoints': { + 'auth': '/api/v1/auth', + 'files': '/api/v1/files', + 'jobs': '/api/v1/jobs', + 'admin': '/api/v1/admin', + 'health': '/api/v1/health' + }, + 'documentation': 'Available endpoints provide RESTful API for document translation' + } + + +@app.route('/api/health') +@app.route('/api/v1/health') +def health_check(): + """健康檢查端點""" + return { + 'status': 'healthy', + 'timestamp': datetime.utcnow().isoformat(), + 'service': 'PANJIT Document Translator API', + 'version': '1.0.0' + }, 200 + + +if __name__ == '__main__': + # 檢查環境變數 + port = int(os.environ.get('PORT', 12010)) + debug = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true' + host = os.environ.get('HOST', '0.0.0.0') + + # 只在主進程或非 debug 模式下顯示啟動訊息 + # 在 debug 模式下,Flask 會創建兩個進程,只在 reloader 主進程顯示訊息 + if not debug or os.environ.get('WERKZEUG_RUN_MAIN'): + print(f""" + PANJIT Document Translator Starting... + + Server: http://{host}:{port} + Debug Mode: {debug} + API Documentation: http://{host}:{port}/api + Health Check: http://{host}:{port}/api/v1/health + + Upload Directory: {app.config.get('UPLOAD_FOLDER')} + Database: {app.config.get('SQLALCHEMY_DATABASE_URI', '').split('/')[-1]} + SMTP: {app.config.get('SMTP_SERVER')} + LDAP: {app.config.get('LDAP_SERVER')} + + Press Ctrl+C to stop the server. + """) + + # 啟動應用 + try: + app.run( + host=host, + port=port, + debug=debug, + use_reloader=debug + ) + except KeyboardInterrupt: + if not debug or os.environ.get('WERKZEUG_RUN_MAIN'): + print("\nServer stopped by user.") + except Exception as e: + if not debug or os.environ.get('WERKZEUG_RUN_MAIN'): + print(f"\nServer failed to start: {str(e)}") + sys.exit(1) \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..18cb738 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Flask 應用程式工廠 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import os +import redis +from flask import Flask, request, make_response +from flask_sqlalchemy import SQLAlchemy +from flask_cors import CORS +from flask_jwt_extended import JWTManager +from celery import Celery +from app.config import config +from app.utils.logger import init_logging + +# 初始化擴展 +db = SQLAlchemy() +cors = CORS() +jwt = JWTManager() + + +def make_celery(app): + """創建 Celery 實例""" + celery = Celery( + app.import_name, + backend=app.config['CELERY_RESULT_BACKEND'], + broker=app.config['CELERY_BROKER_URL'] + ) + celery.conf.update(app.config) + + class ContextTask(celery.Task): + """在 Flask 應用上下文中執行任務""" + def __call__(self, *args, **kwargs): + with app.app_context(): + return self.run(*args, **kwargs) + + celery.Task = ContextTask + return celery + + +def create_app(config_name=None): + """應用程式工廠""" + app = Flask(__name__) + + # 載入配置 + config_name = config_name or os.getenv('FLASK_ENV', 'default') + + # 先載入 Dify API 配置 + config[config_name].load_dify_config() + + # 然後載入配置到 Flask app + app.config.from_object(config[config_name]) + + # 初始化必要目錄 + config[config_name].init_directories() + + # 初始化擴展 + db.init_app(app) + + # 不使用 Flask-CORS 避免衝突,使用手動CORS處理 + + # 初始化 JWT + jwt.init_app(app) + app.logger.info(f"🔑 [JWT Config] JWT_SECRET_KEY: {app.config.get('JWT_SECRET_KEY')[:10]}...{app.config.get('JWT_SECRET_KEY')[-10:] if app.config.get('JWT_SECRET_KEY') else 'None'}") + app.logger.info(f"🔑 [JWT Config] JWT_ACCESS_TOKEN_EXPIRES: {app.config.get('JWT_ACCESS_TOKEN_EXPIRES')}") + app.logger.info(f"🔑 [JWT Config] JWT_REFRESH_TOKEN_EXPIRES: {app.config.get('JWT_REFRESH_TOKEN_EXPIRES')}") + + app.logger.info("🔑 [JWT] Using JWT authentication") + + # 設定 Redis(用於Celery) + try: + redis_client = redis.from_url(app.config['REDIS_URL']) + app.redis_client = redis_client + except Exception as e: + app.logger.warning(f"Redis initialization failed: {str(e)}") + app.redis_client = None + + # 初始化日誌 + init_logging(app) + + # 註冊 API 路由 + from app.api import api_v1 + app.register_blueprint(api_v1) + + # 註冊錯誤處理器 + register_error_handlers(app) + + # 添加 CORS 響應headers + @app.after_request + def after_request(response): + origin = request.headers.get('Origin') + allowed_origins = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:3001', 'http://127.0.0.1:3001', 'http://localhost:12010', 'http://127.0.0.1:12010'] + + if origin and origin in allowed_origins: + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Requested-With' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS, PATCH' + response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Max-Age'] = '86400' + + return response + + # 處理 OPTIONS 預檢請求 + @app.before_request + def before_request(): + if request.method == 'OPTIONS': + response = make_response() + origin = request.headers.get('Origin') + allowed_origins = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:3001', 'http://127.0.0.1:3001', 'http://localhost:12010', 'http://127.0.0.1:12010'] + + if origin and origin in allowed_origins: + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Requested-With' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS, PATCH' + response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Max-Age'] = '86400' + + return response + + # 建立資料表 + with app.app_context(): + # 導入模型 + from app.models import User, TranslationJob, JobFile, TranslationCache, APIUsageStats, SystemLog, Notification + + db.create_all() + + # 創建默認管理員用戶(如果不存在) + create_default_admin() + + # 創建 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 + + +def register_error_handlers(app): + """註冊錯誤處理器""" + + @app.errorhandler(404) + def not_found(error): + return { + 'success': False, + 'error': 'NOT_FOUND', + 'message': '請求的資源不存在' + }, 404 + + @app.errorhandler(403) + def forbidden(error): + return { + 'success': False, + 'error': 'FORBIDDEN', + 'message': '權限不足' + }, 403 + + @app.errorhandler(401) + def unauthorized(error): + return { + 'success': False, + 'error': 'UNAUTHORIZED', + 'message': '需要認證' + }, 401 + + @app.errorhandler(500) + def internal_server_error(error): + return { + 'success': False, + 'error': 'INTERNAL_SERVER_ERROR', + 'message': '系統內部錯誤' + }, 500 + + @app.errorhandler(413) + def request_entity_too_large(error): + return { + 'success': False, + 'error': 'FILE_TOO_LARGE', + 'message': '檔案大小超過限制' + }, 413 + + +def create_default_admin(): + """創建默認管理員用戶""" + try: + from app.models import User + + admin_email = os.environ.get('ADMIN_EMAIL', 'ymirliu@panjit.com.tw') + + # 檢查是否已存在管理員 + admin_user = User.query.filter_by(email=admin_email).first() + + if not admin_user: + # 創建管理員用戶(待 LDAP 登入時完善資訊) + admin_user = User( + username=admin_email.split('@')[0], + display_name='系統管理員', + email=admin_email, + department='IT', + is_admin=True + ) + db.session.add(admin_user) + db.session.commit() + + print(f"Created default admin user: {admin_email}") + + except Exception as e: + print(f"Failed to create default admin: {str(e)}") + + +# 導入模型在需要時才進行,避免循環導入 \ No newline at end of file diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..3b943c8 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +API 模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from flask import Blueprint + +# 建立 API Blueprint +api_v1 = Blueprint('api_v1', __name__, url_prefix='/api/v1') + +# 匯入各 API 模組 +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(notification.notification_bp) \ No newline at end of file diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..0d5dbdf --- /dev/null +++ b/app/api/admin.py @@ -0,0 +1,1071 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +管理員 API + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from datetime import datetime, timedelta +from flask import Blueprint, request, jsonify, g, send_file +from app.utils.decorators import admin_required +from app.utils.validators import validate_pagination, validate_date_range +from app.utils.helpers import create_response +from app.utils.exceptions import ValidationError +from app.utils.logger import get_logger +from app.models.user import User +from app.models.job import TranslationJob +from app.models.stats import APIUsageStats +from app.utils.timezone import format_taiwan_time +from app.models.log import SystemLog +from app.models.cache import TranslationCache +from sqlalchemy import func, desc + +admin_bp = Blueprint('admin', __name__, url_prefix='/admin') +logger = get_logger(__name__) + + +@admin_bp.route('/stats', methods=['GET']) +@admin_required +def get_system_stats(): + """取得系統統計資料(簡化版本)""" + try: + from app import db + + # 基本統計 - 計算實際的總成本和今日活躍用戶 + total_cost = db.session.query(func.sum(TranslationJob.total_cost)).scalar() or 0.0 + + # 計算今日活躍用戶 (今天有任務活動的用戶) + today = datetime.utcnow().date() + active_users_today = db.session.query(TranslationJob.user_id).filter( + func.date(TranslationJob.created_at) == today + ).distinct().count() + + overview = { + 'total_jobs': TranslationJob.query.count(), + 'completed_jobs': TranslationJob.query.filter_by(status='COMPLETED').count(), + 'failed_jobs': TranslationJob.query.filter_by(status='FAILED').count(), + 'pending_jobs': TranslationJob.query.filter_by(status='PENDING').count(), + 'processing_jobs': TranslationJob.query.filter_by(status='PROCESSING').count(), + 'total_users': User.query.count(), + 'active_users_today': active_users_today, + 'total_cost': float(total_cost) + } + + # 用戶排行榜 - 按任務數和成本排序 + user_rankings = db.session.query( + User.id, + User.display_name, + func.count(TranslationJob.id).label('job_count'), + func.sum(TranslationJob.total_cost).label('total_cost') + ).outerjoin(TranslationJob).group_by( + User.id, User.display_name + ).order_by( + func.count(TranslationJob.id).desc() + ).limit(10).all() + + user_rankings_data = [] + for ranking in user_rankings: + user_rankings_data.append({ + 'user_id': ranking.id, + 'display_name': ranking.display_name, + 'job_count': ranking.job_count or 0, + 'total_cost': float(ranking.total_cost or 0.0) + }) + + # 計算每日統計 + period = request.args.get('period', 'month') + days = {'week': 7, 'month': 30, 'quarter': 90}.get(period, 30) + + # 取得指定期間的每日統計 + daily_stats = [] + for i in range(days): + target_date = (datetime.utcnow() - timedelta(days=i)).date() + + # 當日任務統計 + daily_jobs = TranslationJob.query.filter( + func.date(TranslationJob.created_at) == target_date + ).count() + + daily_completed = TranslationJob.query.filter( + func.date(TranslationJob.created_at) == target_date, + TranslationJob.status == 'COMPLETED' + ).count() + + # 當日失敗任務統計 + daily_failed = TranslationJob.query.filter( + func.date(TranslationJob.created_at) == target_date, + TranslationJob.status == 'FAILED' + ).count() + + # 當日成本統計 + daily_cost = db.session.query( + func.sum(TranslationJob.total_cost) + ).filter( + func.date(TranslationJob.created_at) == target_date + ).scalar() or 0.0 + + daily_stats.append({ + 'date': target_date.strftime('%Y-%m-%d'), + 'jobs': daily_jobs, + 'completed': daily_completed, + 'failed': daily_failed, + 'cost': float(daily_cost) + }) + + # 反轉順序,最早的日期在前 + daily_stats.reverse() + + return jsonify(create_response( + success=True, + data={ + 'overview': overview, + 'daily_stats': daily_stats, + 'user_rankings': user_rankings_data, + 'period': period, + 'start_date': format_taiwan_time(datetime.utcnow() - timedelta(days=days), "%Y-%m-%d %H:%M:%S"), + 'end_date': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S") + } + )) + + except Exception as e: + logger.error(f"Get system stats error: {str(e)}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得系統統計失敗' + )), 500 + + +@admin_bp.route('/jobs', methods=['GET']) +@admin_required +def get_all_jobs(): + """取得所有使用者任務""" + try: + # 取得查詢參數 + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + user_id = request.args.get('user_id', type=int) + status = request.args.get('status') + search = request.args.get('search', '').strip() + include_deleted = request.args.get('include_deleted', 'false').lower() == 'true' + + # 驗證分頁參數 + page, per_page = validate_pagination(page, min(per_page, 100)) + + # 建立查詢 + query = TranslationJob.query + + # 預設排除軟刪除的記錄,除非明確要求包含 + if not include_deleted: + query = query.filter(TranslationJob.deleted_at.is_(None)) + + # 使用者篩選 + if user_id: + query = query.filter_by(user_id=user_id) + + # 狀態篩選 + if status and status != 'all': + valid_statuses = ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRY'] + if status.upper() in valid_statuses: + query = query.filter_by(status=status.upper()) + + # 檔案名搜尋 + if search: + query = query.filter(TranslationJob.original_filename.like(f'%{search}%')) + + # 排序 + query = query.order_by(TranslationJob.created_at.desc()) + + # 分頁 + pagination = query.paginate( + page=page, + per_page=per_page, + error_out=False + ) + + jobs = pagination.items + + # 組合回應資料(包含使用者資訊) + jobs_data = [] + for job in jobs: + job_data = job.to_dict() + job_data['user'] = { + 'id': job.user.id, + 'username': job.user.username, + 'display_name': job.user.display_name, + 'email': job.user.email + } + jobs_data.append(job_data) + + return jsonify(create_response( + success=True, + data={ + 'jobs': jobs_data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': pagination.total, + 'pages': pagination.pages, + 'has_prev': pagination.has_prev, + 'has_next': pagination.has_next + } + } + )) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Get all jobs error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得任務列表失敗' + )), 500 + + +@admin_bp.route('/users', methods=['GET']) +@admin_required +def get_all_users(): + """取得所有使用者(簡化版本)""" + try: + # 簡化版本 - 不使用分頁,直接返回所有用戶 + users = User.query.order_by(User.created_at.desc()).limit(50).all() + + users_data = [] + for user in users: + # 直接構建基本用戶資料,不使用to_dict方法 + users_data.append({ + 'id': user.id, + 'username': user.username, + 'display_name': user.display_name, + 'email': user.email, + 'department': user.department or '', + 'is_admin': user.is_admin, + 'last_login': user.last_login.isoformat() if user.last_login else None, + 'created_at': user.created_at.isoformat() if user.created_at else None, + 'updated_at': user.updated_at.isoformat() if user.updated_at else None + }) + + return jsonify(create_response( + success=True, + data={ + 'users': users_data, + 'pagination': { + 'page': 1, + 'per_page': 50, + 'total': len(users_data), + 'pages': 1, + 'has_prev': False, + 'has_next': False + } + } + )) + + except Exception as e: + logger.error(f"Get all users error: {str(e)}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得使用者列表失敗' + )), 500 + + +@admin_bp.route('/logs', methods=['GET']) +@admin_required +def get_system_logs(): + """取得系統日誌""" + try: + # 取得查詢參數 + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 100, type=int) + level = request.args.get('level') + module = request.args.get('module') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + # 驗證參數 + page, per_page = validate_pagination(page, min(per_page, 500)) + + if start_date or end_date: + start_date, end_date = validate_date_range(start_date, end_date) + + # 取得日誌 + logs = SystemLog.get_logs( + level=level, + module=module, + start_date=start_date, + end_date=end_date, + limit=per_page, + offset=(page - 1) * per_page + ) + + # 取得總數(簡化版本,不完全精確) + total = len(logs) if len(logs) < per_page else (page * per_page) + 1 + + logs_data = [log.to_dict() for log in logs] + + return jsonify(create_response( + success=True, + data={ + 'logs': logs_data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': total, + 'has_more': len(logs) == per_page + } + } + )) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Get system logs error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得系統日誌失敗' + )), 500 + + +@admin_bp.route('/api-usage', methods=['GET']) +@admin_required +def get_api_usage(): + """取得 API 使用統計(簡化版本)""" + try: + from app import db + + # 基本統計 + total_calls = db.session.query(APIUsageStats).count() + total_cost = db.session.query(func.sum(APIUsageStats.cost)).scalar() or 0.0 + total_tokens = db.session.query(func.sum(APIUsageStats.total_tokens)).scalar() or 0 + + # 簡化版本返回基本數據 + return jsonify(create_response( + success=True, + data={ + 'daily_stats': [], # 簡化版本 + 'top_users': [], # 簡化版本 + 'endpoint_stats': [], # 簡化版本 + 'cost_trend': [], # 簡化版本 + 'period_days': 30, + 'summary': { + 'total_calls': total_calls, + 'total_cost': float(total_cost), + 'total_tokens': total_tokens + } + } + )) + + except Exception as e: + logger.error(f"Get API usage error: {str(e)}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得API使用統計失敗' + )), 500 + + +@admin_bp.route('/cache/stats', methods=['GET']) +@admin_required +def get_cache_stats(): + """取得翻譯快取統計""" + try: + cache_stats = TranslationCache.get_cache_statistics() + + return jsonify(create_response( + success=True, + data=cache_stats + )) + + except Exception as e: + logger.error(f"Get cache stats error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得快取統計失敗' + )), 500 + + +@admin_bp.route('/health', methods=['GET']) +@admin_required +def get_system_health(): + """取得系統健康狀態(管理員專用)""" + try: + from datetime import datetime + status = { + 'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"), + 'status': 'healthy', + 'services': {} + } + + # 資料庫檢查 + try: + from app import db + from sqlalchemy import text + db.session.execute(text('SELECT 1')) + status['services']['database'] = {'status': 'healthy'} + except Exception as e: + status['services']['database'] = { + 'status': 'unhealthy', + 'error': str(e) + } + status['status'] = 'unhealthy' + + # 翻譯服務統計 + try: + total_jobs = TranslationJob.query.count() + pending_jobs = TranslationJob.query.filter_by(status='PENDING').count() + processing_jobs = TranslationJob.query.filter_by(status='PROCESSING').count() + status['services']['translation_service'] = { + 'status': 'healthy', + 'total_jobs': total_jobs, + 'pending_jobs': pending_jobs, + 'processing_jobs': processing_jobs + } + except Exception as e: + status['services']['translation_service'] = { + 'status': 'unhealthy', + 'error': str(e) + } + status['status'] = 'unhealthy' + + # Celery 工作者檢查 - 使用替代方案檢測 + try: + import redis + import os + from flask import current_app + + # 方法1: 檢查Redis中是否有Celery相關的key + redis_client = redis.from_url(current_app.config['REDIS_URL']) + + # 檢查Celery binding keys(worker存在時會有這些keys) + celery_keys = redis_client.keys('_kombu.binding.celery*') + + # 方法2: 檢查進程(Docker環境中) + worker_detected = False + worker_count = 0 + + try: + # 檢查是否有Celery相關的keys + if celery_keys: + worker_detected = True + worker_count = 1 # Docker環境中通常只有一個worker + + # 額外檢查:如果有最近的任務處理記錄,說明worker在工作 + recent_tasks = TranslationJob.query.filter( + TranslationJob.updated_at >= datetime.utcnow() - timedelta(minutes=10), + TranslationJob.status.in_(['PROCESSING', 'COMPLETED']) + ).count() + + if recent_tasks > 0: + worker_detected = True + worker_count = max(worker_count, 1) + except Exception: + pass + + if worker_detected: + status['services']['celery'] = { + 'status': 'healthy', + 'active_workers': worker_count, + 'message': 'Worker detected via Redis/Task activity' + } + else: + # Celery 工作者沒有檢測到 + status['services']['celery'] = { + 'status': 'warning', + 'message': 'No Celery worker activity detected', + 'active_workers': 0 + } + # 不設置整體系統為異常,只是警告 + + except Exception as e: + # Redis連接失敗或其他錯誤 + status['services']['celery'] = { + 'status': 'warning', + 'message': f'Cannot check Celery status: {str(e)[:100]}', + 'active_workers': 0 + } + # 不設置整體系統為異常,只是警告 + + # 檔案系統檢查 + try: + import os + from app.config import Config + + # 檢查上傳目錄 + upload_dir = getattr(Config, 'UPLOAD_FOLDER', 'uploads') + if os.path.exists(upload_dir) and os.access(upload_dir, os.W_OK): + status['services']['file_system'] = {'status': 'healthy'} + else: + status['services']['file_system'] = { + 'status': 'unhealthy', + 'error': f'Upload directory {upload_dir} not accessible' + } + status['status'] = 'unhealthy' + except Exception as e: + status['services']['file_system'] = { + 'status': 'unhealthy', + 'error': str(e) + } + + # 重新評估整體系統狀態 + unhealthy_services = [service for service, info in status['services'].items() + if info.get('status') == 'unhealthy'] + + if unhealthy_services: + status['status'] = 'unhealthy' + status['unhealthy_services'] = unhealthy_services + else: + warning_services = [service for service, info in status['services'].items() + if info.get('status') == 'warning'] + if warning_services: + status['status'] = 'warning' + status['warning_services'] = warning_services + else: + status['status'] = 'healthy' + + return jsonify(create_response( + success=True, + data=status + )) + + except Exception as e: + logger.error(f"Get system health error: {str(e)}") + return jsonify({ + 'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"), + 'status': 'error', + 'error': str(e) + }), 500 + + +@admin_bp.route('/metrics', methods=['GET']) +@admin_required +def get_system_metrics(): + """取得系統指標(管理員專用)""" + try: + from datetime import datetime, timedelta + from app import db + + # 統計任務狀態(排除軟刪除的記錄,反映當前實際狀態) + job_stats = db.session.query( + TranslationJob.status, + func.count(TranslationJob.id) + ).filter( + TranslationJob.deleted_at.is_(None) + ).group_by(TranslationJob.status).all() + + job_counts = {status: count for status, count in job_stats} + + # 最近24小時的統計(排除軟刪除的記錄) + yesterday = datetime.utcnow() - timedelta(days=1) + recent_jobs = db.session.query( + TranslationJob.status, + func.count(TranslationJob.id) + ).filter( + TranslationJob.created_at >= yesterday, + TranslationJob.deleted_at.is_(None) + ).group_by(TranslationJob.status).all() + + recent_counts = {status: count for status, count in recent_jobs} + + metrics_data = { + 'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"), + 'jobs': { + 'pending': job_counts.get('PENDING', 0), + 'processing': job_counts.get('PROCESSING', 0), + 'completed': job_counts.get('COMPLETED', 0), + 'failed': job_counts.get('FAILED', 0), + 'retry': job_counts.get('RETRY', 0), + 'total': sum(job_counts.values()) + }, + 'recent_24h': { + 'pending': recent_counts.get('PENDING', 0), + 'processing': recent_counts.get('PROCESSING', 0), + 'completed': recent_counts.get('COMPLETED', 0), + 'failed': recent_counts.get('FAILED', 0), + 'retry': recent_counts.get('RETRY', 0), + 'total': sum(recent_counts.values()) + } + } + + return jsonify(create_response( + success=True, + data=metrics_data + )) + + except Exception as e: + logger.error(f"Get system metrics error: {str(e)}") + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得系統指標失敗' + )), 500 + + +@admin_bp.route('/maintenance/cleanup', methods=['POST']) +@admin_required +def cleanup_system(): + """系統清理維護""" + try: + data = request.get_json() or {} + + # 清理選項 + cleanup_logs = data.get('cleanup_logs', False) + cleanup_cache = data.get('cleanup_cache', False) + cleanup_files = data.get('cleanup_files', False) + + logs_days = data.get('logs_days', 30) + cache_days = data.get('cache_days', 90) + files_days = data.get('files_days', 7) + + cleanup_results = {} + + # 清理舊日誌 + if cleanup_logs: + deleted_logs = SystemLog.cleanup_old_logs(days_to_keep=logs_days) + cleanup_results['logs'] = { + 'deleted_count': deleted_logs, + 'days_kept': logs_days + } + + # 清理舊快取 + if cleanup_cache: + deleted_cache = TranslationCache.clear_old_cache(days_to_keep=cache_days) + cleanup_results['cache'] = { + 'deleted_count': deleted_cache, + 'days_kept': cache_days + } + + # 清理舊檔案 + if cleanup_files: + try: + from datetime import datetime, timedelta + import os + from pathlib import Path + + # 找到超過指定天數的已完成或失敗任務 + cutoff_date = datetime.utcnow() - timedelta(days=files_days) + old_jobs = TranslationJob.query.filter( + TranslationJob.created_at < cutoff_date, + TranslationJob.status.in_(['COMPLETED', 'FAILED']) + ).all() + + deleted_files_count = 0 + for job in old_jobs: + try: + # 刪除與任務相關的所有檔案 + for file_record in job.files: + file_path = Path(file_record.file_path) + if file_path.exists(): + os.remove(file_path) + deleted_files_count += 1 + + # 也刪除任務目錄 + if job.file_path: + job_dir = Path(job.file_path).parent + if job_dir.exists() and len(list(job_dir.iterdir())) == 0: + job_dir.rmdir() + + except Exception as file_error: + logger.warning(f"Failed to cleanup files for job {job.job_uuid}: {file_error}") + + cleanup_results['files'] = { + 'deleted_count': deleted_files_count, + 'jobs_processed': len(old_jobs), + 'days_kept': files_days + } + + except Exception as cleanup_error: + cleanup_results['files'] = { + 'error': f'File cleanup failed: {str(cleanup_error)}', + 'days_kept': files_days + } + + # 記錄維護日誌 + SystemLog.info( + 'admin.maintenance', + f'System cleanup performed by {g.current_user.username}', + user_id=g.current_user.id, + extra_data={ + 'cleanup_options': data, + 'results': cleanup_results + } + ) + + logger.info(f"System cleanup performed by {g.current_user.username}") + + return jsonify(create_response( + success=True, + data=cleanup_results, + message='系統清理完成' + )) + + except Exception as e: + logger.error(f"System cleanup error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='系統清理失敗' + )), 500 + + +@admin_bp.route('/export/', methods=['GET']) +@admin_required +def export_report(report_type): + """匯出報表""" + try: + from io import BytesIO + import pandas as pd + from app import db + + # 驗證報表類型 + valid_types = ['usage', 'cost', 'jobs'] + if report_type not in valid_types: + return jsonify(create_response( + success=False, + error='INVALID_REPORT_TYPE', + message='無效的報表類型' + )), 400 + + # 取得查詢參數 + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + # 設定預設時間範圍(最近30天) + if not end_date: + end_date = datetime.utcnow() + else: + end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + + if not start_date: + start_date = end_date - timedelta(days=30) + else: + start_date = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + + # 生成報表數據 + if report_type == 'usage': + # 使用統計報表 + data = generate_usage_report(start_date, end_date) + filename = f'usage_report_{start_date.strftime("%Y%m%d")}_{end_date.strftime("%Y%m%d")}.xlsx' + + elif report_type == 'cost': + # 成本分析報表 + data = generate_cost_report(start_date, end_date) + filename = f'cost_report_{start_date.strftime("%Y%m%d")}_{end_date.strftime("%Y%m%d")}.xlsx' + + elif report_type == 'jobs': + # 任務清單報表 + data = generate_jobs_report(start_date, end_date) + filename = f'jobs_report_{start_date.strftime("%Y%m%d")}_{end_date.strftime("%Y%m%d")}.xlsx' + + # 建立Excel檔案 + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + for sheet_name, df in data.items(): + df.to_excel(writer, sheet_name=sheet_name, index=False) + + output.seek(0) + + # 記錄匯出日誌 + SystemLog.info( + 'admin.export_report', + f'Report exported: {report_type}', + user_id=g.current_user.id, + extra_data={ + 'report_type': report_type, + 'start_date': start_date.isoformat(), + 'end_date': end_date.isoformat() + } + ) + + logger.info(f"Report exported by {g.current_user.username}: {report_type}") + + # 發送檔案 + return send_file( + BytesIO(output.getvalue()), + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + except Exception as e: + logger.error(f"Export report error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='匯出報表失敗' + )), 500 + + +def generate_usage_report(start_date, end_date): + """生成使用統計報表""" + import pandas as pd + from app import db + + # 用戶使用統計 + user_stats = db.session.query( + User.username, + User.display_name, + User.department, + func.count(TranslationJob.id).label('job_count'), + func.sum(TranslationJob.total_cost).label('total_cost'), + func.sum(TranslationJob.total_tokens).label('total_tokens') + ).outerjoin(TranslationJob).filter( + TranslationJob.created_at.between(start_date, end_date) + ).group_by( + User.id, User.username, User.display_name, User.department + ).order_by(func.count(TranslationJob.id).desc()).all() + + user_df = pd.DataFrame([{ + '用戶名': stat.username, + '顯示名稱': stat.display_name, + '部門': stat.department or '', + '任務數': stat.job_count or 0, + '總成本 ($)': float(stat.total_cost or 0.0), + '總Token數': stat.total_tokens or 0 + } for stat in user_stats]) + + # 每日使用統計 + daily_stats = db.session.query( + func.date(TranslationJob.created_at).label('date'), + func.count(TranslationJob.id).label('job_count'), + func.sum(TranslationJob.total_cost).label('total_cost'), + func.sum(TranslationJob.total_tokens).label('total_tokens') + ).filter( + TranslationJob.created_at.between(start_date, end_date) + ).group_by( + func.date(TranslationJob.created_at) + ).order_by(func.date(TranslationJob.created_at)).all() + + daily_df = pd.DataFrame([{ + '日期': stat.date.strftime('%Y-%m-%d'), + '任務數': stat.job_count, + '總成本 ($)': float(stat.total_cost or 0.0), + '總Token數': stat.total_tokens or 0 + } for stat in daily_stats]) + + return { + '用戶使用統計': user_df, + '每日使用統計': daily_df + } + + +def generate_cost_report(start_date, end_date): + """生成成本分析報表""" + import pandas as pd + from app import db + + # 按語言的成本統計 + lang_costs = {} + jobs = TranslationJob.query.filter( + TranslationJob.created_at.between(start_date, end_date), + TranslationJob.total_cost.isnot(None) + ).all() + + for job in jobs: + for lang in job.target_languages: + if lang not in lang_costs: + lang_costs[lang] = {'count': 0, 'cost': 0.0, 'tokens': 0} + lang_costs[lang]['count'] += 1 + lang_costs[lang]['cost'] += float(job.total_cost or 0.0) / len(job.target_languages) + lang_costs[lang]['tokens'] += (job.total_tokens or 0) // len(job.target_languages) + + lang_df = pd.DataFrame([{ + '目標語言': lang, + '任務數': data['count'], + '總成本 ($)': data['cost'], + '總Token數': data['tokens'], + '平均單次成本 ($)': data['cost'] / data['count'] if data['count'] > 0 else 0 + } for lang, data in lang_costs.items()]) + + # 按檔案類型的成本統計 + file_stats = db.session.query( + TranslationJob.file_extension, + func.count(TranslationJob.id).label('job_count'), + func.sum(TranslationJob.total_cost).label('total_cost'), + func.sum(TranslationJob.total_tokens).label('total_tokens') + ).filter( + TranslationJob.created_at.between(start_date, end_date) + ).group_by(TranslationJob.file_extension).all() + + file_df = pd.DataFrame([{ + '檔案類型': stat.file_extension, + '任務數': stat.job_count, + '總成本 ($)': float(stat.total_cost or 0.0), + '總Token數': stat.total_tokens or 0, + '平均單次成本 ($)': float(stat.total_cost or 0.0) / stat.job_count if stat.job_count > 0 else 0 + } for stat in file_stats]) + + return { + '按語言成本分析': lang_df, + '按檔案類型成本分析': file_df + } + + +def generate_jobs_report(start_date, end_date): + """生成任務清單報表""" + import pandas as pd + from app import db + + jobs = db.session.query(TranslationJob).filter( + TranslationJob.created_at.between(start_date, end_date) + ).options(db.joinedload(TranslationJob.user)).order_by( + TranslationJob.created_at.desc() + ).all() + + jobs_df = pd.DataFrame([{ + '任務ID': job.job_uuid, + '用戶名': job.user.username if job.user else '', + '顯示名稱': job.user.display_name if job.user else '', + '部門': job.user.department if job.user and job.user.department else '', + '原始檔案': job.original_filename, + '檔案大小': job.file_size, + '來源語言': job.source_language, + '目標語言': ', '.join(job.target_languages), + '狀態': job.status, + '總成本 ($)': float(job.total_cost or 0.0), + '總Token數': job.total_tokens or 0, + '建立時間': job.created_at.strftime('%Y-%m-%d %H:%M:%S'), + '完成時間': job.completed_at.strftime('%Y-%m-%d %H:%M:%S') if job.completed_at else '', + '錯誤訊息': job.error_message or '' + } for job in jobs]) + + return { + '任務清單': jobs_df + } + + +@admin_bp.route('/jobs//cancel', methods=['POST']) +@admin_required +def admin_cancel_job(job_uuid): + """管理員取消任務""" + try: + from app import db + + job = TranslationJob.query.filter_by(job_uuid=job_uuid).first() + + if not job: + return jsonify(create_response( + success=False, + error='NOT_FOUND', + message='任務不存在' + )), 404 + + # 只能取消等待中或處理中的任務 + if job.status not in ['PENDING', 'PROCESSING']: + return jsonify(create_response( + success=False, + error='CANNOT_CANCEL', + message='只能取消等待中或處理中的任務' + )), 400 + + # 如果任務正在處理中,嘗試撤銷 Celery 任務 + if job.status == 'PROCESSING': + try: + from app.services.celery_service import revoke_task + revoke_task(job_uuid) + logger.info(f"Admin {g.current_user.username} revoked Celery task for job {job_uuid}") + except Exception as e: + logger.warning(f"Failed to revoke Celery task {job_uuid}: {e}") + # 即使撤銷失敗,也繼續標記任務為失敗 + + # 更新任務狀態 + job.status = 'FAILED' + job.error_message = f'管理員 {g.current_user.username} 取消了任務' + job.updated_at = datetime.utcnow() + + db.session.commit() + + logger.info(f"Admin {g.current_user.username} cancelled job {job_uuid}") + + return jsonify(create_response( + success=True, + data={ + 'job_uuid': job_uuid, + 'status': job.status, + 'message': '任務已取消' + } + )) + + except Exception as e: + logger.error(f"Error cancelling job {job_uuid}: {e}", exc_info=True) + return jsonify(create_response( + success=False, + error='INTERNAL_ERROR', + message=str(e) + )), 500 + + +@admin_bp.route('/jobs/', methods=['DELETE']) +@admin_required +def admin_delete_job(job_uuid): + """管理員刪除任務(軟刪除)""" + try: + from app import db + + job = TranslationJob.query.filter_by(job_uuid=job_uuid).first() + + if not job: + return jsonify(create_response( + success=False, + error='NOT_FOUND', + message='任務不存在' + )), 404 + + # 如果任務正在處理中,先嘗試撤銷 Celery 任務 + if job.status == 'PROCESSING': + try: + from app.services.celery_service import revoke_task + revoke_task(job_uuid) + logger.info(f"Admin {g.current_user.username} revoked Celery task before deletion for job {job_uuid}") + except Exception as e: + logger.warning(f"Failed to revoke Celery task {job_uuid} before deletion: {e}") + + # 軟刪除資料庫記錄(保留數據供報表使用) + job.soft_delete() + + logger.info(f"Admin {g.current_user.username} soft deleted job {job_uuid}") + + return jsonify(create_response( + success=True, + data={ + 'job_uuid': job_uuid, + 'message': '任務已刪除' + } + )) + + except Exception as e: + logger.error(f"Error deleting job {job_uuid}: {e}", exc_info=True) + return jsonify(create_response( + success=False, + error='INTERNAL_ERROR', + message=str(e) + )), 500 \ No newline at end of file diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..0876f8d --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +JWT 認證 API + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-09-02 +""" + +from flask import Blueprint, request, jsonify, current_app +from flask_jwt_extended import ( + create_access_token, create_refresh_token, + jwt_required, get_jwt_identity, get_jwt +) +from app.utils.ldap_auth import LDAPAuthService +from app.utils.decorators import validate_json, rate_limit +from app.utils.exceptions import AuthenticationError +from app.utils.logger import get_logger +from app.models.user import User +from app.models.log import SystemLog + +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') +logger = get_logger(__name__) + + +@auth_bp.route('/login', methods=['POST']) +@rate_limit(max_requests=10, per_seconds=300) # 5分鐘內最多10次嘗試 +@validate_json(['username', 'password']) +def login(): + """使用者登入""" + try: + data = request.get_json() + username = data['username'].strip() + password = data['password'] + + if not username or not password: + return jsonify({ + 'success': False, + 'error': 'INVALID_INPUT', + 'message': '帳號和密碼不能為空' + }), 400 + + # LDAP 認證 + ldap_service = LDAPAuthService() + user_info = ldap_service.authenticate_user(username, password) + + # 取得或建立使用者 + user = User.get_or_create( + username=user_info['username'], + display_name=user_info['display_name'], + email=user_info['email'], + department=user_info.get('department') + ) + + # 更新登入時間 + user.update_last_login() + + # 創建 JWT tokens + access_token = create_access_token( + identity=user.username, + additional_claims={ + 'user_id': user.id, + 'is_admin': user.is_admin, + 'display_name': user.display_name, + 'email': user.email + } + ) + refresh_token = create_refresh_token(identity=user.username) + + # 記錄登入日誌 + SystemLog.info( + 'auth.login', + f'User {username} logged in successfully', + user_id=user.id, + extra_data={ + 'ip_address': request.remote_addr, + 'user_agent': request.headers.get('User-Agent') + } + ) + + logger.info(f"🔑 [JWT Created] User: {username}, UserID: {user.id}") + logger.info(f"User {username} logged in successfully") + + return jsonify({ + 'success': True, + 'data': { + 'access_token': access_token, + 'refresh_token': refresh_token, + 'user': user.to_dict() + }, + 'message': '登入成功' + }) + + except AuthenticationError as e: + # 記錄認證失敗 + SystemLog.warning( + 'auth.login_failed', + f'Authentication failed for user {username}: {str(e)}', + extra_data={ + 'username': username, + 'ip_address': request.remote_addr, + 'error': str(e) + } + ) + + logger.warning(f"Authentication failed for user {username}: {str(e)}") + + return jsonify({ + 'success': False, + 'error': 'INVALID_CREDENTIALS', + 'message': str(e) + }), 401 + + except Exception as e: + logger.error(f"Login error: {str(e)}") + + SystemLog.error( + 'auth.login_error', + f'Login system error: {str(e)}', + extra_data={ + 'username': username, + 'error': str(e) + } + ) + + return jsonify({ + 'success': False, + 'error': 'SYSTEM_ERROR', + 'message': '系統錯誤,請稍後再試' + }), 500 + + +@auth_bp.route('/logout', methods=['POST']) +@jwt_required() +def logout(): + """使用者登出""" + try: + username = get_jwt_identity() + + # 記錄登出日誌 + SystemLog.info( + 'auth.logout', + f'User {username} logged out' + ) + + logger.info(f"🚪 [JWT Logout] User: {username}") + logger.info(f"User {username} logged out") + + return jsonify({ + 'success': True, + 'message': '登出成功' + }) + + except Exception as e: + logger.error(f"Logout error: {str(e)}") + + return jsonify({ + 'success': False, + 'error': 'SYSTEM_ERROR', + 'message': '登出時發生錯誤' + }), 500 + + +@auth_bp.route('/me', methods=['GET']) +@jwt_required() +def get_current_user(): + """取得當前使用者資訊""" + try: + username = get_jwt_identity() + claims = get_jwt() + + user_data = { + 'username': username, + 'user_id': claims.get('user_id'), + 'is_admin': claims.get('is_admin'), + 'display_name': claims.get('display_name'), + 'email': claims.get('email') + } + + return jsonify({ + 'success': True, + 'data': { + 'user': user_data + } + }) + + except Exception as e: + logger.error(f"Get current user error: {str(e)}") + + return jsonify({ + 'success': False, + 'error': 'SYSTEM_ERROR', + 'message': '取得使用者資訊時發生錯誤' + }), 500 + + +@auth_bp.route('/refresh', methods=['POST']) +@jwt_required(refresh=True) +def refresh_token(): + """刷新 Access Token""" + try: + username = get_jwt_identity() + + # 重新取得使用者資訊 + user = User.query.filter_by(username=username).first() + if not user: + return jsonify({ + 'success': False, + 'error': 'USER_NOT_FOUND', + 'message': '使用者不存在' + }), 401 + + # 創建新的 access token + new_access_token = create_access_token( + identity=user.username, + additional_claims={ + 'user_id': user.id, + 'is_admin': user.is_admin, + 'display_name': user.display_name, + 'email': user.email + } + ) + + logger.info(f"Token refreshed for user {user.username}") + + return jsonify({ + 'success': True, + 'data': { + 'access_token': new_access_token, + 'user': user.to_dict() + }, + 'message': 'Token 已刷新' + }) + + except Exception as e: + logger.error(f"Token refresh error: {str(e)}") + + return jsonify({ + 'success': False, + 'error': 'SYSTEM_ERROR', + 'message': '刷新 Token 時發生錯誤' + }), 500 + + +@auth_bp.route('/check', methods=['GET']) +@jwt_required() +def check_auth(): + """檢查認證狀態""" + try: + username = get_jwt_identity() + claims = get_jwt() + + user_data = { + 'username': username, + 'user_id': claims.get('user_id'), + 'is_admin': claims.get('is_admin'), + 'display_name': claims.get('display_name'), + 'email': claims.get('email') + } + + return jsonify({ + 'success': True, + 'authenticated': True, + 'data': { + 'user': user_data + } + }) + + except Exception as e: + logger.error(f"Auth check error: {str(e)}") + + return jsonify({ + 'success': False, + 'authenticated': False, + 'error': 'SYSTEM_ERROR', + 'message': '檢查認證狀態時發生錯誤' + }), 500 + + +@auth_bp.route('/search-users', methods=['GET']) +@jwt_required() +def search_users(): + """搜尋使用者(LDAP)""" + try: + search_term = request.args.get('q', '').strip() + limit = min(int(request.args.get('limit', 20)), 50) + + if len(search_term) < 2: + return jsonify({ + 'success': False, + 'error': 'INVALID_SEARCH_TERM', + 'message': '搜尋關鍵字至少需要2個字元' + }), 400 + + ldap_service = LDAPAuthService() + users = ldap_service.search_users(search_term, limit) + + return jsonify({ + 'success': True, + 'data': { + 'users': users, + 'count': len(users) + } + }) + + except Exception as e: + logger.error(f"User search error: {str(e)}") + + return jsonify({ + 'success': False, + 'error': 'SYSTEM_ERROR', + 'message': '搜尋使用者時發生錯誤' + }), 500 + + +# 錯誤處理器 +@auth_bp.errorhandler(429) +def rate_limit_handler(e): + """速率限制錯誤處理器""" + return jsonify({ + 'success': False, + 'error': 'RATE_LIMIT_EXCEEDED', + 'message': '請求過於頻繁,請稍後再試' + }), 429 \ No newline at end of file diff --git a/app/api/files.py b/app/api/files.py new file mode 100644 index 0000000..f0630a0 --- /dev/null +++ b/app/api/files.py @@ -0,0 +1,691 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +檔案管理 API + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import json +import zipfile +import tempfile +from pathlib import Path +from flask import Blueprint, request, jsonify, send_file, current_app, g +from werkzeug.utils import secure_filename +from app.utils.decorators import jwt_login_required, rate_limit +from app.utils.validators import validate_file, validate_languages, validate_job_uuid +from app.utils.helpers import ( + save_uploaded_file, + create_response, + format_file_size, + generate_download_token +) +from app.utils.exceptions import ValidationError, FileProcessingError +from app.utils.logger import get_logger +from app.models.job import TranslationJob +from app.models.log import SystemLog + +files_bp = Blueprint('files', __name__, url_prefix='/files') +logger = get_logger(__name__) + + +@files_bp.route('/upload', methods=['POST']) +@jwt_login_required +@rate_limit(max_requests=20, per_seconds=3600) # 每小時最多20次上傳 +def upload_file(): + """檔案上傳""" + try: + # 檢查是否有檔案 + if 'file' not in request.files: + return jsonify(create_response( + success=False, + error='NO_FILE', + message='未選擇檔案' + )), 400 + + file_obj = request.files['file'] + + # 驗證檔案 + file_info = validate_file(file_obj) + + # 取得翻譯設定 + source_language = request.form.get('source_language', 'auto') + target_languages_str = request.form.get('target_languages', '[]') + + try: + target_languages = json.loads(target_languages_str) + except json.JSONDecodeError: + return jsonify(create_response( + success=False, + error='INVALID_TARGET_LANGUAGES', + message='目標語言格式錯誤' + )), 400 + + # 驗證語言設定 + lang_info = validate_languages(source_language, target_languages) + + # 建立翻譯任務 + job = TranslationJob( + user_id=g.current_user_id, + original_filename=file_info['filename'], + file_extension=file_info['file_extension'], + file_size=file_info['file_size'], + file_path='', # 暫時為空,稍後更新 + source_language=lang_info['source_language'], + target_languages=lang_info['target_languages'], + status='PENDING' + ) + + # 先保存到資料庫以取得 job_uuid + from app import db + db.session.add(job) + db.session.commit() + + # 儲存檔案 + file_result = save_uploaded_file(file_obj, job.job_uuid) + + if not file_result['success']: + # 如果儲存失敗,刪除任務記錄 + db.session.delete(job) + db.session.commit() + + raise FileProcessingError(f"檔案儲存失敗: {file_result['error']}") + + # 更新任務的檔案路徑 + job.file_path = file_result['file_path'] + + # 新增原始檔案記錄 + job.add_original_file( + filename=file_result['filename'], + file_path=file_result['file_path'], + file_size=file_result['file_size'] + ) + + db.session.commit() + + # 計算佇列位置 + queue_position = TranslationJob.get_queue_position(job.job_uuid) + + # 記錄日誌 + SystemLog.info( + 'files.upload', + f'File uploaded successfully: {file_info["filename"]}', + user_id=g.current_user_id, + job_id=job.id, + extra_data={ + 'filename': file_info['filename'], + 'file_size': file_info['file_size'], + 'source_language': source_language, + 'target_languages': target_languages + } + ) + + logger.info(f"File uploaded successfully: {job.job_uuid} - {file_info['filename']}") + + # 觸發翻譯任務 + try: + from app.tasks.translation import process_translation_job + + # 嘗試使用 Celery 異步處理 + try: + task = process_translation_job.delay(job.id) + logger.info(f"Translation task queued with Celery: {task.id} for job {job.job_uuid}") + except Exception as celery_error: + logger.warning(f"Celery not available, falling back to synchronous processing: {str(celery_error)}") + + # Celery 不可用時,使用同步處理 + try: + from app.services.translation_service import TranslationService + service = TranslationService() + + # 在後台執行翻譯(同步處理) + logger.info(f"Starting synchronous translation for job {job.job_uuid}") + result = service.translate_document(job.job_uuid) + logger.info(f"Synchronous translation completed for job {job.job_uuid}: {result}") + + except Exception as sync_error: + logger.error(f"Synchronous translation failed for job {job.job_uuid}: {str(sync_error)}") + job.update_status('FAILED', error_message=f"翻譯處理失敗: {str(sync_error)}") + db.session.commit() + + except Exception as e: + logger.error(f"Failed to process translation for job {job.job_uuid}: {str(e)}") + job.update_status('FAILED', error_message=f"任務處理失敗: {str(e)}") + db.session.commit() + + return jsonify(create_response( + success=True, + data={ + 'job_uuid': job.job_uuid, + 'original_filename': job.original_filename, + 'file_size': job.file_size, + 'file_size_formatted': format_file_size(job.file_size), + 'source_language': job.source_language, + 'target_languages': job.target_languages, + 'status': job.status, + 'queue_position': queue_position, + 'created_at': job.created_at.isoformat() + }, + message='檔案上傳成功,已加入翻譯佇列' + )) + + except ValidationError as e: + logger.warning(f"File upload validation error: {str(e)}") + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except FileProcessingError as e: + logger.error(f"File processing error: {str(e)}") + return jsonify(create_response( + success=False, + error='FILE_PROCESSING_ERROR', + message=str(e) + )), 500 + + except Exception as e: + logger.error(f"File upload error: {str(e)}") + + SystemLog.error( + 'files.upload_error', + f'File upload failed: {str(e)}', + user_id=g.current_user_id, + extra_data={'error': str(e)} + ) + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='檔案上傳失敗' + )), 500 + + +@files_bp.route('//download/', methods=['GET']) +@jwt_login_required +def download_file(job_uuid, language_code): + """下載翻譯檔案""" + try: + # 驗證 UUID 格式 + validate_job_uuid(job_uuid) + + # 取得任務 + job = TranslationJob.query.filter_by(job_uuid=job_uuid).first() + + if not job: + return jsonify(create_response( + success=False, + error='JOB_NOT_FOUND', + message='任務不存在' + )), 404 + + # 檢查權限 + if job.user_id != g.current_user_id and not g.is_admin: + return jsonify(create_response( + success=False, + error='PERMISSION_DENIED', + message='無權限存取此檔案' + )), 403 + + # 檢查任務狀態 + if job.status != 'COMPLETED': + return jsonify(create_response( + success=False, + error='JOB_NOT_COMPLETED', + message='任務尚未完成' + )), 400 + + # 尋找對應的翻譯檔案 + translated_file = None + for file_record in job.files: + if file_record.file_type == 'TRANSLATED' and file_record.language_code == language_code: + translated_file = file_record + break + + if not translated_file: + return jsonify(create_response( + success=False, + error='FILE_NOT_FOUND', + message=f'找不到 {language_code} 的翻譯檔案' + )), 404 + + # 檢查檔案是否存在 + file_path = Path(translated_file.file_path) + if not file_path.exists(): + logger.error(f"File not found on disk: {file_path}") + + return jsonify(create_response( + success=False, + error='FILE_NOT_FOUND_ON_DISK', + message='檔案在伺服器上不存在' + )), 404 + + # 記錄下載日誌 + SystemLog.info( + 'files.download', + f'File downloaded: {translated_file.filename}', + user_id=g.current_user_id, + job_id=job.id, + extra_data={ + 'filename': translated_file.filename, + 'language_code': language_code, + 'file_size': translated_file.file_size + } + ) + + logger.info(f"File downloaded: {job.job_uuid} - {language_code}") + + # 發送檔案 + return send_file( + str(file_path), + as_attachment=True, + download_name=translated_file.filename, + mimetype='application/octet-stream' + ) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"File download error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='檔案下載失敗' + )), 500 + + +@files_bp.route('//download/original', methods=['GET']) +@jwt_login_required +def download_original_file(job_uuid): + """下載原始檔案""" + try: + # 驗證 UUID 格式 + validate_job_uuid(job_uuid) + + # 取得任務 + job = TranslationJob.query.filter_by(job_uuid=job_uuid).first() + + if not job: + return jsonify(create_response( + success=False, + error='JOB_NOT_FOUND', + message='任務不存在' + )), 404 + + # 檢查權限 + if job.user_id != g.current_user_id and not g.is_admin: + return jsonify(create_response( + success=False, + error='PERMISSION_DENIED', + message='無權限存取此檔案' + )), 403 + + # 取得原始檔案 + original_file = job.get_original_file() + + if not original_file: + return jsonify(create_response( + success=False, + error='ORIGINAL_FILE_NOT_FOUND', + message='找不到原始檔案記錄' + )), 404 + + # 檢查檔案是否存在 + file_path = Path(original_file.file_path) + if not file_path.exists(): + logger.error(f"Original file not found on disk: {file_path}") + + return jsonify(create_response( + success=False, + error='FILE_NOT_FOUND_ON_DISK', + message='原始檔案在伺服器上不存在' + )), 404 + + # 記錄下載日誌 + SystemLog.info( + 'files.download_original', + f'Original file downloaded: {original_file.filename}', + user_id=g.current_user_id, + job_id=job.id, + extra_data={ + 'filename': original_file.filename, + 'file_size': original_file.file_size + } + ) + + logger.info(f"Original file downloaded: {job.job_uuid}") + + # 發送檔案 + return send_file( + str(file_path), + as_attachment=True, + download_name=job.original_filename, + mimetype='application/octet-stream' + ) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Original file download error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='原始檔案下載失敗' + )), 500 + + +@files_bp.route('/supported-formats', methods=['GET']) +def get_supported_formats(): + """取得支援的檔案格式""" + try: + formats = { + '.docx': { + 'name': 'Word 文件 (.docx)', + 'description': 'Microsoft Word 2007+ 格式', + 'icon': 'file-word' + }, + '.doc': { + 'name': 'Word 文件 (.doc)', + 'description': 'Microsoft Word 97-2003 格式', + 'icon': 'file-word' + }, + '.pptx': { + 'name': 'PowerPoint 簡報 (.pptx)', + 'description': 'Microsoft PowerPoint 2007+ 格式', + 'icon': 'file-powerpoint' + }, + '.xlsx': { + 'name': 'Excel 試算表 (.xlsx)', + 'description': 'Microsoft Excel 2007+ 格式', + 'icon': 'file-excel' + }, + '.xls': { + 'name': 'Excel 試算表 (.xls)', + 'description': 'Microsoft Excel 97-2003 格式', + 'icon': 'file-excel' + }, + '.pdf': { + 'name': 'PDF 文件 (.pdf)', + 'description': 'Portable Document Format', + 'icon': 'file-pdf' + } + } + + max_size = current_app.config.get('MAX_CONTENT_LENGTH', 26214400) + + return jsonify(create_response( + success=True, + data={ + 'supported_formats': formats, + 'max_file_size': max_size, + 'max_file_size_formatted': format_file_size(max_size) + } + )) + + except Exception as e: + logger.error(f"Get supported formats error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得支援格式失敗' + )), 500 + + +@files_bp.route('/supported-languages', methods=['GET']) +def get_supported_languages(): + """取得支援的語言""" + try: + from app.utils.helpers import get_supported_languages + + languages = get_supported_languages() + + return jsonify(create_response( + success=True, + data={ + 'supported_languages': languages + } + )) + + except Exception as e: + logger.error(f"Get supported languages error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得支援語言失敗' + )), 500 + + +@files_bp.route('//download/batch', methods=['GET']) +@jwt_login_required +def download_batch_files(job_uuid): + """批量下載所有翻譯檔案為 ZIP""" + try: + # 驗證 UUID 格式 + validate_job_uuid(job_uuid) + + # 取得任務 + job = TranslationJob.query.filter_by(job_uuid=job_uuid).first() + + if not job: + return jsonify(create_response( + success=False, + error='JOB_NOT_FOUND', + message='任務不存在' + )), 404 + + # 檢查權限 + if job.user_id != g.current_user_id and not g.is_admin: + return jsonify(create_response( + success=False, + error='PERMISSION_DENIED', + message='無權限存取此檔案' + )), 403 + + # 檢查任務狀態 + if job.status != 'COMPLETED': + return jsonify(create_response( + success=False, + error='JOB_NOT_COMPLETED', + message='任務尚未完成' + )), 400 + + # 收集所有翻譯檔案 + translated_files = job.get_translated_files() + + if not translated_files: + return jsonify(create_response( + success=False, + error='NO_TRANSLATED_FILES', + message='沒有找到翻譯檔案' + )), 404 + + # 建立臨時 ZIP 檔案 + temp_dir = tempfile.gettempdir() + zip_filename = f"{job.original_filename.split('.')[0]}_translations_{job.job_uuid[:8]}.zip" + zip_path = Path(temp_dir) / zip_filename + + try: + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zip_file: + files_added = 0 + + # 添加原始檔案 + original_file = job.get_original_file() + if original_file and Path(original_file.file_path).exists(): + zip_file.write( + original_file.file_path, + f"original/{original_file.filename}" + ) + files_added += 1 + + # 添加所有翻譯檔案(避免重複) + added_files = set() # 追蹤已添加的檔案,避免重複 + for tf in translated_files: + file_path = Path(tf.file_path) + if file_path.exists(): + # 按語言建立資料夾結構 + archive_name = f"{tf.language_code}/{tf.filename}" + + # 檢查是否已經添加過這個檔案 + if archive_name not in added_files: + zip_file.write(str(file_path), archive_name) + added_files.add(archive_name) + files_added += 1 + else: + logger.warning(f"Translation file not found: {tf.file_path}") + + if files_added == 0: + return jsonify(create_response( + success=False, + error='NO_FILES_TO_ZIP', + message='沒有可用的檔案進行壓縮' + )), 404 + + # 檢查 ZIP 檔案是否建立成功 + if not zip_path.exists(): + return jsonify(create_response( + success=False, + error='ZIP_CREATION_FAILED', + message='ZIP 檔案建立失敗' + )), 500 + + # 記錄下載日誌 + SystemLog.info( + 'files.download_batch', + f'Batch files downloaded: {zip_filename}', + user_id=g.current_user_id, + job_id=job.id, + extra_data={ + 'zip_filename': zip_filename, + 'files_count': files_added, + 'job_uuid': job_uuid + } + ) + + logger.info(f"Batch files downloaded: {job.job_uuid} - {files_added} files in ZIP") + + # 發送 ZIP 檔案 + return send_file( + str(zip_path), + as_attachment=True, + download_name=zip_filename, + mimetype='application/zip' + ) + + finally: + # 清理臨時檔案(在發送後會自動清理) + pass + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Batch download error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='批量下載失敗' + )), 500 + + +@files_bp.route('//download/combine', methods=['GET']) +@jwt_login_required +def download_combine_file(job_uuid): + """下載合併檔案""" + try: + # 驗證 UUID 格式 + validate_job_uuid(job_uuid) + + # 取得當前用戶 + current_user_id = g.current_user_id + + # 查找任務 + job = TranslationJob.query.filter_by( + job_uuid=job_uuid, + user_id=current_user_id + ).first() + + if not job: + return jsonify(create_response( + success=False, + error='JOB_NOT_FOUND', + message='任務不存在' + )), 404 + + # 檢查任務狀態 + if job.status != 'COMPLETED': + return jsonify(create_response( + success=False, + error='JOB_NOT_COMPLETED', + message='任務尚未完成' + )), 400 + + # 尋找 combine 檔案 + combine_file = None + for file in job.files: + if file.filename.lower().find('combine') != -1 or file.file_type == 'combined': + combine_file = file + break + + if not combine_file: + return jsonify(create_response( + success=False, + error='COMBINE_FILE_NOT_FOUND', + message='找不到合併檔案' + )), 404 + + # 檢查檔案是否存在 + file_path = Path(combine_file.file_path) + if not file_path.exists(): + return jsonify(create_response( + success=False, + error='FILE_NOT_FOUND', + message='合併檔案已被刪除' + )), 404 + + logger.info(f"Combine file downloaded: {job.job_uuid} - {combine_file.filename}") + + # 發送檔案 + return send_file( + str(file_path), + as_attachment=True, + download_name=combine_file.filename, + mimetype='application/octet-stream' + ) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Combine file download error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='合併檔案下載失敗' + )), 500 \ No newline at end of file diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..4f456a5 --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +系統健康檢查 API + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from datetime import datetime +from flask import Blueprint, jsonify +from app.utils.helpers import create_response +from app.utils.logger import get_logger +from app.models.job import TranslationJob +from app.utils.timezone import format_taiwan_time, now_taiwan + +health_bp = Blueprint('health', __name__, url_prefix='/health') +logger = get_logger(__name__) + + +@health_bp.route('', methods=['GET']) +def health_check(): + """系統健康檢查""" + try: + status = { + 'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"), + 'status': 'healthy', + 'services': {} + } + + # 資料庫檢查 + try: + from app import db + from sqlalchemy import text + db.session.execute(text('SELECT 1')) + status['services']['database'] = {'status': 'healthy'} + except Exception as e: + status['services']['database'] = { + 'status': 'unhealthy', + 'error': str(e) + } + status['status'] = 'unhealthy' + + # Redis 檢查 + try: + import redis + from flask import current_app + redis_client = redis.from_url(current_app.config['REDIS_URL']) + redis_client.ping() + status['services']['redis'] = {'status': 'healthy'} + except Exception as e: + status['services']['redis'] = { + 'status': 'unhealthy', + 'error': str(e) + } + # Redis 暫時異常不影響整體狀態(如果沒有使用 Celery) + + # LDAP 檢查 + try: + from app.utils.ldap_auth import LDAPAuthService + ldap_service = LDAPAuthService() + if ldap_service.test_connection(): + status['services']['ldap'] = {'status': 'healthy'} + else: + status['services']['ldap'] = {'status': 'unhealthy', 'error': 'Connection failed'} + except Exception as e: + status['services']['ldap'] = { + 'status': 'unhealthy', + 'error': str(e) + } + # LDAP 異常會影響整體狀態 + status['status'] = 'unhealthy' + + # 檔案系統檢查 + try: + from pathlib import Path + from flask import current_app + upload_folder = Path(current_app.config['UPLOAD_FOLDER']) + + # 檢查上傳目錄是否可寫 + test_file = upload_folder / 'health_check.tmp' + test_file.write_text('health_check') + test_file.unlink() + + status['services']['filesystem'] = {'status': 'healthy'} + except Exception as e: + status['services']['filesystem'] = { + 'status': 'unhealthy', + 'error': str(e) + } + status['status'] = 'unhealthy' + + # 檢查 Dify API(如果配置了) + try: + from flask import current_app + if current_app.config.get('DIFY_API_KEY') and current_app.config.get('DIFY_API_BASE_URL'): + # 這裡會在實作 Dify 服務時加入連線測試 + status['services']['dify_api'] = {'status': 'not_tested'} + else: + status['services']['dify_api'] = {'status': 'not_configured'} + except Exception as e: + status['services']['dify_api'] = { + 'status': 'error', + 'error': str(e) + } + + return jsonify(status), 200 if status['status'] == 'healthy' else 503 + + except Exception as e: + logger.error(f"Health check error: {str(e)}") + return jsonify({ + 'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"), + 'status': 'error', + 'error': str(e) + }), 500 + + +@health_bp.route('/metrics', methods=['GET']) +def get_metrics(): + """系統指標""" + try: + # 統計任務狀態 + from app import db + from sqlalchemy import func + + job_stats = db.session.query( + TranslationJob.status, + func.count(TranslationJob.id) + ).group_by(TranslationJob.status).all() + + job_counts = {status: count for status, count in job_stats} + + # 系統指標 + metrics_data = { + 'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"), + 'jobs': { + 'pending': job_counts.get('PENDING', 0), + 'processing': job_counts.get('PROCESSING', 0), + 'completed': job_counts.get('COMPLETED', 0), + 'failed': job_counts.get('FAILED', 0), + 'retry': job_counts.get('RETRY', 0), + 'total': sum(job_counts.values()) + } + } + + # 添加最近24小時的統計 + from datetime import timedelta + yesterday = datetime.utcnow() - timedelta(days=1) + + recent_jobs = db.session.query( + TranslationJob.status, + func.count(TranslationJob.id) + ).filter( + TranslationJob.created_at >= yesterday + ).group_by(TranslationJob.status).all() + + recent_counts = {status: count for status, count in recent_jobs} + + metrics_data['recent_24h'] = { + 'pending': recent_counts.get('PENDING', 0), + 'processing': recent_counts.get('PROCESSING', 0), + 'completed': recent_counts.get('COMPLETED', 0), + 'failed': recent_counts.get('FAILED', 0), + 'retry': recent_counts.get('RETRY', 0), + 'total': sum(recent_counts.values()) + } + + return jsonify(create_response( + success=True, + data=metrics_data + )) + + except Exception as e: + logger.error(f"Get metrics error: {str(e)}") + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得系統指標失敗' + )), 500 + + +@health_bp.route('/version', methods=['GET']) +def get_version(): + """取得版本資訊""" + try: + version_info = { + 'application': 'PANJIT Document Translator', + 'version': '1.0.0', + 'build_date': '2024-01-28', + 'python_version': None, + 'flask_version': None + } + + # 取得 Python 版本 + import sys + version_info['python_version'] = sys.version + + # 取得 Flask 版本 + import flask + version_info['flask_version'] = flask.__version__ + + return jsonify(create_response( + success=True, + data=version_info + )) + + except Exception as e: + logger.error(f"Get version error: {str(e)}") + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得版本資訊失敗' + )), 500 + + +@health_bp.route('/ping', methods=['GET']) +def ping(): + """簡單的 ping 檢查""" + return jsonify({ + 'status': 'ok', + 'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"), + 'message': 'pong' + }) \ No newline at end of file diff --git a/app/api/jobs.py b/app/api/jobs.py new file mode 100644 index 0000000..0c20978 --- /dev/null +++ b/app/api/jobs.py @@ -0,0 +1,548 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +翻譯任務管理 API + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from flask import Blueprint, request, jsonify, g +from app.utils.decorators import jwt_login_required, admin_required +from app.utils.validators import ( + validate_job_uuid, + validate_pagination, + validate_date_range +) +from app.utils.helpers import create_response, calculate_processing_time +from app.utils.exceptions import ValidationError +from app.utils.logger import get_logger +from app.models.job import TranslationJob +from app.models.stats import APIUsageStats +from app.models.log import SystemLog +from sqlalchemy import and_, or_ + +jobs_bp = Blueprint('jobs', __name__, url_prefix='/jobs') +logger = get_logger(__name__) + + +@jobs_bp.route('', methods=['GET']) +@jwt_login_required +def get_user_jobs(): + """取得使用者任務列表""" + try: + # 取得查詢參數 + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + status = request.args.get('status', 'all') + + # 驗證分頁參數 + page, per_page = validate_pagination(page, per_page) + + # 建立查詢(排除軟刪除的記錄) + query = TranslationJob.query.filter_by(user_id=g.current_user_id).filter(TranslationJob.deleted_at.is_(None)) + + # 狀態篩選 + if status and status != 'all': + valid_statuses = ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRY'] + if status.upper() in valid_statuses: + query = query.filter_by(status=status.upper()) + + # 排序 + query = query.order_by(TranslationJob.created_at.desc()) + + # 分頁 + pagination = query.paginate( + page=page, + per_page=per_page, + error_out=False + ) + + jobs = pagination.items + + # 組合回應資料 + jobs_data = [] + for job in jobs: + job_data = job.to_dict(include_files=False) + + # 計算處理時間 + if job.processing_started_at and job.completed_at: + job_data['processing_time'] = calculate_processing_time( + job.processing_started_at, job.completed_at + ) + + # 取得佇列位置(只對 PENDING 狀態) + if job.status == 'PENDING': + job_data['queue_position'] = TranslationJob.get_queue_position(job.job_uuid) + + jobs_data.append(job_data) + + return jsonify(create_response( + success=True, + data={ + 'jobs': jobs_data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': pagination.total, + 'pages': pagination.pages, + 'has_prev': pagination.has_prev, + 'has_next': pagination.has_next + } + } + )) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Get user jobs error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得任務列表失敗' + )), 500 + + +@jobs_bp.route('/', methods=['GET']) +@jwt_login_required +def get_job_detail(job_uuid): + """取得任務詳細資訊""" + try: + # 驗證 UUID 格式 + validate_job_uuid(job_uuid) + + # 取得任務(排除軟刪除的記錄) + job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first() + + if not job: + return jsonify(create_response( + success=False, + error='JOB_NOT_FOUND', + message='任務不存在' + )), 404 + + # 檢查權限 + if job.user_id != g.current_user_id and not g.is_admin: + return jsonify(create_response( + success=False, + error='PERMISSION_DENIED', + message='無權限存取此任務' + )), 403 + + # 取得任務詳細資料 + job_data = job.to_dict(include_files=True) + + # 計算處理時間 + if job.processing_started_at and job.completed_at: + job_data['processing_time'] = calculate_processing_time( + job.processing_started_at, job.completed_at + ) + elif job.processing_started_at: + job_data['processing_time'] = calculate_processing_time( + job.processing_started_at + ) + + # 取得佇列位置(只對 PENDING 狀態) + if job.status == 'PENDING': + job_data['queue_position'] = TranslationJob.get_queue_position(job.job_uuid) + + # 取得 API 使用統計(如果已完成) + if job.status == 'COMPLETED': + api_stats = APIUsageStats.get_user_statistics( + user_id=job.user_id, + start_date=job.created_at, + end_date=job.completed_at + ) + job_data['api_usage'] = api_stats + + return jsonify(create_response( + success=True, + data={ + 'job': job_data + } + )) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Get job detail error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得任務詳情失敗' + )), 500 + + +@jobs_bp.route('//retry', methods=['POST']) +@jwt_login_required +def retry_job(job_uuid): + """重試失敗任務""" + try: + # 驗證 UUID 格式 + validate_job_uuid(job_uuid) + + # 取得任務(排除軟刪除的記錄) + job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first() + + if not job: + return jsonify(create_response( + success=False, + error='JOB_NOT_FOUND', + message='任務不存在' + )), 404 + + # 檢查權限 + if job.user_id != g.current_user_id and not g.is_admin: + return jsonify(create_response( + success=False, + error='PERMISSION_DENIED', + message='無權限操作此任務' + )), 403 + + # 檢查是否可以重試 + if not job.can_retry(): + return jsonify(create_response( + success=False, + error='CANNOT_RETRY', + message='任務無法重試(狀態不正確或重試次數已達上限)' + )), 400 + + # 重置任務狀態 + job.update_status('PENDING', error_message=None) + job.increment_retry() + + # 計算新的佇列位置 + queue_position = TranslationJob.get_queue_position(job.job_uuid) + + # 記錄重試日誌 + SystemLog.info( + 'jobs.retry', + f'Job retry requested: {job_uuid}', + user_id=g.current_user_id, + job_id=job.id, + extra_data={ + 'retry_count': job.retry_count, + 'previous_error': job.error_message + } + ) + + logger.info(f"Job retry requested: {job_uuid} (retry count: {job.retry_count})") + + # 重新觸發翻譯任務(這裡會在實作 Celery 時加入) + # from app.tasks.translation import process_translation_job + # process_translation_job.delay(job.id) + + return jsonify(create_response( + success=True, + data={ + 'job_uuid': job.job_uuid, + 'status': job.status, + 'retry_count': job.retry_count, + 'queue_position': queue_position + }, + message='任務已重新加入佇列' + )) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Job retry error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='重試任務失敗' + )), 500 + + +@jobs_bp.route('/statistics', methods=['GET']) +@jwt_login_required +def get_user_statistics(): + """取得使用者統計資料""" + try: + # 取得日期範圍參數 + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + # 驗證日期範圍 + if start_date or end_date: + start_date, end_date = validate_date_range(start_date, end_date) + + # 取得任務統計 + job_stats = TranslationJob.get_statistics( + user_id=g.current_user_id, + start_date=start_date, + end_date=end_date + ) + + # 取得 API 使用統計 + api_stats = APIUsageStats.get_user_statistics( + user_id=g.current_user_id, + start_date=start_date, + end_date=end_date + ) + + return jsonify(create_response( + success=True, + data={ + 'job_statistics': job_stats, + 'api_statistics': api_stats + } + )) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Get user statistics error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得統計資料失敗' + )), 500 + + +@jobs_bp.route('/queue/status', methods=['GET']) +def get_queue_status(): + """取得佇列狀態(不需登入)""" + try: + # 取得各狀態任務數量 + pending_count = TranslationJob.query.filter_by(status='PENDING').count() + processing_count = TranslationJob.query.filter_by(status='PROCESSING').count() + + # 取得當前處理中的任務(最多5個) + processing_jobs = TranslationJob.query.filter_by( + status='PROCESSING' + ).order_by(TranslationJob.processing_started_at).limit(5).all() + + processing_jobs_data = [] + for job in processing_jobs: + processing_jobs_data.append({ + 'job_uuid': job.job_uuid, + 'original_filename': job.original_filename, + 'progress': float(job.progress) if job.progress else 0.0, + 'processing_started_at': job.processing_started_at.isoformat() if job.processing_started_at else None, + 'processing_time': calculate_processing_time(job.processing_started_at) if job.processing_started_at else None + }) + + return jsonify(create_response( + success=True, + data={ + 'queue_status': { + 'pending': pending_count, + 'processing': processing_count, + 'total_in_queue': pending_count + processing_count + }, + 'processing_jobs': processing_jobs_data + } + )) + + except Exception as e: + logger.error(f"Get queue status error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取得佇列狀態失敗' + )), 500 + + +@jobs_bp.route('//cancel', methods=['POST']) +@jwt_login_required +def cancel_job(job_uuid): + """取消任務(支援 PENDING 和 PROCESSING 狀態)""" + try: + # 驗證 UUID 格式 + validate_job_uuid(job_uuid) + + # 取得任務(排除軟刪除的記錄) + job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first() + + if not job: + return jsonify(create_response( + success=False, + error='JOB_NOT_FOUND', + message='任務不存在' + )), 404 + + # 檢查權限 + if job.user_id != g.current_user_id and not g.is_admin: + return jsonify(create_response( + success=False, + error='PERMISSION_DENIED', + message='無權限操作此任務' + )), 403 + + # 只能取消等待中或處理中的任務 + if job.status not in ['PENDING', 'PROCESSING']: + return jsonify(create_response( + success=False, + error='CANNOT_CANCEL', + message='只能取消等待中或處理中的任務' + )), 400 + + # 如果是處理中的任務,需要中斷 Celery 任務 + if job.status == 'PROCESSING': + try: + from app.services.celery_service import revoke_task + # 嘗試撤銷 Celery 任務 + revoke_task(job.job_uuid) + logger.info(f"Celery task revoked for job: {job.job_uuid}") + except Exception as celery_error: + logger.warning(f"Failed to revoke Celery task for job {job.job_uuid}: {celery_error}") + # 即使撤銷失敗也繼續取消任務,因為用戶請求取消 + + # 更新任務狀態為失敗(取消) + cancel_message = f'使用者取消任務 (原狀態: {job.status})' + job.update_status('FAILED', error_message=cancel_message) + + # 記錄取消日誌 + SystemLog.info( + 'jobs.cancel', + f'Job cancelled by user: {job_uuid}', + user_id=g.current_user_id, + job_id=job.id + ) + + logger.info(f"Job cancelled by user: {job_uuid}") + + return jsonify(create_response( + success=True, + data={ + 'job_uuid': job.job_uuid, + 'status': job.status + }, + message='任務已取消' + )) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Cancel job error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='取消任務失敗' + )), 500 + + +@jobs_bp.route('/', methods=['DELETE']) +@jwt_login_required +def delete_job(job_uuid): + """刪除任務""" + try: + # 驗證 UUID 格式 + validate_job_uuid(job_uuid) + + # 取得任務 + job = TranslationJob.query.filter_by(job_uuid=job_uuid).first() + + if not job: + return jsonify(create_response( + success=False, + error='JOB_NOT_FOUND', + message='任務不存在' + )), 404 + + # 檢查權限 + if job.user_id != g.current_user_id and not g.is_admin: + return jsonify(create_response( + success=False, + error='PERMISSION_DENIED', + message='無權限操作此任務' + )), 403 + + # 如果是處理中的任務,先嘗試中斷 Celery 任務 + if job.status == 'PROCESSING': + try: + from app.services.celery_service import revoke_task + # 嘗試撤銷 Celery 任務 + revoke_task(job.job_uuid) + logger.info(f"Celery task revoked before deletion for job: {job.job_uuid}") + except Exception as celery_error: + logger.warning(f"Failed to revoke Celery task before deletion for job {job.job_uuid}: {celery_error}") + # 即使撤銷失敗也繼續刪除任務,因為用戶要求刪除 + + # 刪除任務相關檔案 + import os + import shutil + from pathlib import Path + + try: + if job.file_path and os.path.exists(job.file_path): + # 取得任務目錄(通常是 uploads/job_uuid) + job_dir = Path(job.file_path).parent + if job_dir.exists() and job_dir.name == job.job_uuid: + shutil.rmtree(job_dir) + logger.info(f"Deleted job directory: {job_dir}") + except Exception as file_error: + logger.warning(f"Failed to delete job files: {str(file_error)}") + + # 記錄刪除日誌 + SystemLog.info( + 'jobs.delete', + f'Job deleted by user: {job_uuid}', + user_id=g.current_user_id, + job_id=job.id, + extra_data={ + 'filename': job.original_filename, + 'status': job.status + } + ) + + from app import db + + # 軟刪除資料庫記錄(保留數據供報表使用) + job.soft_delete() + + logger.info(f"Job soft deleted by user: {job_uuid}") + + return jsonify(create_response( + success=True, + message='任務已刪除' + )) + + except ValidationError as e: + return jsonify(create_response( + success=False, + error=e.error_code, + message=str(e) + )), 400 + + except Exception as e: + logger.error(f"Delete job error: {str(e)}") + + return jsonify(create_response( + success=False, + error='SYSTEM_ERROR', + message='刪除任務失敗' + )), 500 \ No newline at end of file diff --git a/app/api/notification.py b/app/api/notification.py new file mode 100644 index 0000000..23a9799 --- /dev/null +++ b/app/api/notification.py @@ -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 app.utils.decorators import jwt_login_required +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_login_required +def get_notifications(): + """獲取當前用戶的通知列表""" + try: + # 獲取當前用戶 + current_user_id = g.current_user_id + + # 獲取查詢參數 + 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('/', methods=['GET']) +@jwt_login_required +def get_notification(notification_id): + """獲取單個通知詳情""" + try: + current_user_id = g.current_user_id + + # 查找通知 + 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('//read', methods=['POST']) +@jwt_login_required +def mark_notification_read(notification_id): + """標記通知為已讀""" + try: + current_user_id = g.current_user_id + + # 查找通知 + 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_login_required +def mark_all_read(): + """標記所有通知為已讀""" + try: + current_user_id = g.current_user_id + + # 取得所有未讀通知 + 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('/', methods=['DELETE']) +@jwt_login_required +def delete_notification(notification_id): + """刪除通知""" + try: + current_user_id = g.current_user_id + + # 查找通知 + 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_login_required +def clear_read_notifications(): + """清空所有已讀通知""" + try: + current_user_id = g.current_user_id + + # 刪除所有已讀通知 + 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_login_required +def create_test_notification(): + """創建測試通知(開發用)""" + try: + current_user_id = g.current_user_id + + # 創建測試通知 + 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 \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..ca5b794 --- /dev/null +++ b/app/config.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +應用程式配置模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import os +import secrets +from pathlib import Path +from datetime import timedelta +from dotenv import load_dotenv + +# 載入環境變數 +load_dotenv() + +class Config: + """基礎配置類別""" + + # 基本應用配置 + SECRET_KEY = os.environ.get('SECRET_KEY') or secrets.token_hex(32) + APP_NAME = os.environ.get('APP_NAME', 'PANJIT Document Translator') + + # 資料庫配置 + DATABASE_URL = os.environ.get('DATABASE_URL') + if DATABASE_URL and DATABASE_URL.startswith("mysql://"): + DATABASE_URL = DATABASE_URL.replace("mysql://", "mysql+pymysql://", 1) + + SQLALCHEMY_DATABASE_URI = DATABASE_URL + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_pre_ping': True, + 'pool_recycle': 3600, + 'connect_args': { + 'charset': os.environ.get('MYSQL_CHARSET', 'utf8mb4'), + 'connect_timeout': 30, + 'read_timeout': 30, + 'write_timeout': 30, + } + } + + # JWT 配置 - 改用 JWT 認證 + JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or SECRET_KEY + JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=8) + JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) + JWT_ALGORITHM = 'HS256' + + # Redis 配置 + REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') + + # Celery 配置 + CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0') + CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0') + CELERY_TASK_SERIALIZER = 'json' + CELERY_RESULT_SERIALIZER = 'json' + CELERY_ACCEPT_CONTENT = ['json'] + CELERY_TIMEZONE = 'Asia/Taipei' + CELERY_ENABLE_UTC = False # 改為 False,讓 Celery 使用本地時區 + + # LDAP 配置 + LDAP_SERVER = os.environ.get('LDAP_SERVER') + LDAP_PORT = int(os.environ.get('LDAP_PORT', 389)) + LDAP_USE_SSL = os.environ.get('LDAP_USE_SSL', 'false').lower() == 'true' + LDAP_BIND_USER_DN = os.environ.get('LDAP_BIND_USER_DN') + LDAP_BIND_USER_PASSWORD = os.environ.get('LDAP_BIND_USER_PASSWORD') + LDAP_SEARCH_BASE = os.environ.get('LDAP_SEARCH_BASE') + LDAP_USER_LOGIN_ATTR = os.environ.get('LDAP_USER_LOGIN_ATTR', 'userPrincipalName') + + # SMTP 配置 + SMTP_SERVER = os.environ.get('SMTP_SERVER') + SMTP_PORT = int(os.environ.get('SMTP_PORT', 587)) + SMTP_USE_TLS = os.environ.get('SMTP_USE_TLS', 'false').lower() == 'true' + SMTP_USE_SSL = os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true' + SMTP_AUTH_REQUIRED = os.environ.get('SMTP_AUTH_REQUIRED', 'false').lower() == 'true' + SMTP_SENDER_EMAIL = os.environ.get('SMTP_SENDER_EMAIL') + SMTP_SENDER_PASSWORD = os.environ.get('SMTP_SENDER_PASSWORD', '') + + # 檔案上傳配置 + UPLOAD_FOLDER = Path(os.environ.get('UPLOAD_FOLDER', 'uploads')).absolute() + MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 26214400)) # 25MB + ALLOWED_EXTENSIONS = {'.docx', '.doc', '.pptx', '.xlsx', '.xls', '.pdf'} + FILE_RETENTION_DAYS = int(os.environ.get('FILE_RETENTION_DAYS', 7)) + + # Dify API 配置(從 api.txt 載入) + DIFY_API_BASE_URL = '' + DIFY_API_KEY = '' + + # 日誌配置 + LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') + LOG_FILE = Path(os.environ.get('LOG_FILE', 'logs/app.log')).absolute() + + # 管理員配置 + ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', 'ymirliu@panjit.com.tw') + + @classmethod + def load_dify_config(cls): + """從 api.txt 載入 Dify API 配置""" + api_file = Path('api.txt') + if api_file.exists(): + try: + with open(api_file, 'r', encoding='utf-8') as f: + for line in f: + if line.startswith('base_url:'): + cls.DIFY_API_BASE_URL = line.split(':', 1)[1].strip() + elif line.startswith('api:'): + cls.DIFY_API_KEY = line.split(':', 1)[1].strip() + except Exception: + pass + + @classmethod + def init_directories(cls): + """初始化必要目錄""" + directories = [ + cls.UPLOAD_FOLDER, + cls.LOG_FILE.parent, + ] + + for directory in directories: + directory.mkdir(parents=True, exist_ok=True) + + +class DevelopmentConfig(Config): + """開發環境配置""" + DEBUG = True + FLASK_ENV = 'development' + + +class ProductionConfig(Config): + """生產環境配置""" + DEBUG = False + FLASK_ENV = 'production' + + # 生產環境的額外配置 + SQLALCHEMY_ENGINE_OPTIONS = { + **Config.SQLALCHEMY_ENGINE_OPTIONS, + 'pool_size': 10, + 'max_overflow': 20, + } + + +class TestingConfig(Config): + """測試環境配置""" + TESTING = True + WTF_CSRF_ENABLED = False + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + + +# 配置映射 +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'testing': TestingConfig, + 'default': DevelopmentConfig +} \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..2113fb4 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +資料模型模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from .user import User +from .job import TranslationJob, JobFile +from .cache import TranslationCache +from .stats import APIUsageStats +from .log import SystemLog +from .notification import Notification, NotificationType + +__all__ = [ + 'User', + 'TranslationJob', + 'JobFile', + 'TranslationCache', + 'APIUsageStats', + 'SystemLog', + 'Notification', + 'NotificationType' +] \ No newline at end of file diff --git a/app/models/cache.py b/app/models/cache.py new file mode 100644 index 0000000..3317ac1 --- /dev/null +++ b/app/models/cache.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +翻譯快取資料模型 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import hashlib +from sqlalchemy.sql import func +from app import db + + +class TranslationCache(db.Model): + """翻譯快取表 (dt_translation_cache)""" + __tablename__ = 'dt_translation_cache' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + source_text_hash = db.Column(db.String(64), nullable=False, comment='來源文字hash') + source_language = db.Column(db.String(50), nullable=False, comment='來源語言') + target_language = db.Column(db.String(50), nullable=False, comment='目標語言') + source_text = db.Column(db.Text, nullable=False, comment='來源文字') + translated_text = db.Column(db.Text, nullable=False, comment='翻譯文字') + created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間') + + # 唯一約束 + __table_args__ = ( + db.UniqueConstraint('source_text_hash', 'source_language', 'target_language', name='uk_cache'), + db.Index('idx_languages', 'source_language', 'target_language'), + ) + + def __repr__(self): + return f'' + + def to_dict(self): + """轉換為字典格式""" + return { + 'id': self.id, + 'source_text_hash': self.source_text_hash, + 'source_language': self.source_language, + 'target_language': self.target_language, + 'source_text': self.source_text, + 'translated_text': self.translated_text, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + @staticmethod + def generate_hash(text): + """生成文字的 SHA256 hash""" + return hashlib.sha256(text.encode('utf-8')).hexdigest() + + @classmethod + def get_translation(cls, source_text, source_language, target_language): + """取得快取的翻譯""" + text_hash = cls.generate_hash(source_text) + + cache_entry = cls.query.filter_by( + source_text_hash=text_hash, + source_language=source_language, + target_language=target_language + ).first() + + return cache_entry.translated_text if cache_entry else None + + @classmethod + def save_translation(cls, source_text, source_language, target_language, translated_text): + """儲存翻譯到快取""" + text_hash = cls.generate_hash(source_text) + + # 檢查是否已存在 + existing = cls.query.filter_by( + source_text_hash=text_hash, + source_language=source_language, + target_language=target_language + ).first() + + if existing: + # 更新現有記錄 + existing.translated_text = translated_text + else: + # 建立新記錄 + cache_entry = cls( + source_text_hash=text_hash, + source_language=source_language, + target_language=target_language, + source_text=source_text, + translated_text=translated_text + ) + db.session.add(cache_entry) + + db.session.commit() + return True + + @classmethod + def get_cache_statistics(cls): + """取得快取統計資料""" + total_entries = cls.query.count() + + # 按語言對統計 + language_pairs = db.session.query( + cls.source_language, + cls.target_language, + func.count(cls.id).label('count') + ).group_by(cls.source_language, cls.target_language).all() + + # 最近一週的快取命中 + from datetime import datetime, timedelta + week_ago = datetime.utcnow() - timedelta(days=7) + recent_entries = cls.query.filter(cls.created_at >= week_ago).count() + + return { + 'total_entries': total_entries, + 'language_pairs': [ + { + 'source_language': pair.source_language, + 'target_language': pair.target_language, + 'count': pair.count + } + for pair in language_pairs + ], + 'recent_entries': recent_entries + } + + @classmethod + def clear_old_cache(cls, days_to_keep=90): + """清理舊快取記錄""" + from datetime import datetime, timedelta + + cutoff_date = datetime.utcnow() - timedelta(days=days_to_keep) + + deleted_count = cls.query.filter( + cls.created_at < cutoff_date + ).delete(synchronize_session=False) + + db.session.commit() + return deleted_count \ No newline at end of file diff --git a/app/models/job.py b/app/models/job.py new file mode 100644 index 0000000..763d562 --- /dev/null +++ b/app/models/job.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +翻譯任務資料模型 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import json +import uuid +from datetime import datetime, timedelta +from sqlalchemy.sql import func +from sqlalchemy import event +from app import db +from app.utils.timezone import format_taiwan_time + + +class TranslationJob(db.Model): + """翻譯任務表 (dt_translation_jobs)""" + __tablename__ = 'dt_translation_jobs' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + job_uuid = db.Column(db.String(36), unique=True, nullable=False, index=True, comment='任務唯一識別碼') + user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), nullable=False, comment='使用者ID') + original_filename = db.Column(db.String(500), nullable=False, comment='原始檔名') + file_extension = db.Column(db.String(10), nullable=False, comment='檔案副檔名') + file_size = db.Column(db.BigInteger, nullable=False, comment='檔案大小(bytes)') + file_path = db.Column(db.String(1000), nullable=False, comment='檔案路徑') + source_language = db.Column(db.String(50), default=None, comment='來源語言') + target_languages = db.Column(db.JSON, nullable=False, comment='目標語言陣列') + status = db.Column( + db.Enum('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRY', name='job_status'), + default='PENDING', + comment='任務狀態' + ) + progress = db.Column(db.Numeric(5, 2), default=0.00, comment='處理進度(%)') + retry_count = db.Column(db.Integer, default=0, comment='重試次數') + error_message = db.Column(db.Text, comment='錯誤訊息') + total_tokens = db.Column(db.Integer, default=0, comment='總token數') + total_cost = db.Column(db.Numeric(10, 4), default=0.0000, comment='總成本') + processing_started_at = db.Column(db.DateTime, comment='開始處理時間') + completed_at = db.Column(db.DateTime, comment='完成時間') + created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間') + updated_at = db.Column( + db.DateTime, + default=func.now(), + onupdate=func.now(), + comment='更新時間' + ) + deleted_at = db.Column(db.DateTime, comment='軟刪除時間') + + # 關聯關係 + files = db.relationship('JobFile', backref='job', lazy='dynamic', cascade='all, delete-orphan') + api_usage_stats = db.relationship('APIUsageStats', backref='job', lazy='dynamic') + + def __repr__(self): + return f'' + + def __init__(self, **kwargs): + """初始化,自動生成 UUID""" + super().__init__(**kwargs) + if not self.job_uuid: + self.job_uuid = str(uuid.uuid4()) + + def to_dict(self, include_files=False): + """轉換為字典格式""" + data = { + 'id': self.id, + 'job_uuid': self.job_uuid, + 'user_id': self.user_id, + 'original_filename': self.original_filename, + 'file_extension': self.file_extension, + 'file_size': self.file_size, + 'file_path': self.file_path, + 'source_language': self.source_language, + 'target_languages': self.target_languages, + 'status': self.status, + 'progress': float(self.progress) if self.progress else 0.0, + 'retry_count': self.retry_count, + 'error_message': self.error_message, + 'total_tokens': self.total_tokens, + 'total_cost': float(self.total_cost) if self.total_cost else 0.0, + 'processing_started_at': format_taiwan_time(self.processing_started_at, "%Y-%m-%d %H:%M:%S") if self.processing_started_at else None, + 'completed_at': format_taiwan_time(self.completed_at, "%Y-%m-%d %H:%M:%S") if self.completed_at else None, + 'created_at': format_taiwan_time(self.created_at, "%Y-%m-%d %H:%M:%S") if self.created_at else None, + 'updated_at': format_taiwan_time(self.updated_at, "%Y-%m-%d %H:%M:%S") if self.updated_at else None, + 'deleted_at': format_taiwan_time(self.deleted_at, "%Y-%m-%d %H:%M:%S") if self.deleted_at else None + } + + if include_files: + data['files'] = [f.to_dict() for f in self.files] + + return data + + def update_status(self, status, error_message=None, progress=None): + """更新任務狀態""" + self.status = status + + if error_message: + self.error_message = error_message + + if progress is not None: + self.progress = progress + + if status == 'PROCESSING' and not self.processing_started_at: + self.processing_started_at = datetime.utcnow() + elif status == 'COMPLETED': + self.completed_at = datetime.utcnow() + self.progress = 100.00 + + self.updated_at = datetime.utcnow() + db.session.commit() + + def add_original_file(self, filename, file_path, file_size): + """新增原始檔案記錄""" + original_file = JobFile( + job_id=self.id, + file_type='ORIGINAL', + filename=filename, + file_path=file_path, + file_size=file_size + ) + db.session.add(original_file) + db.session.commit() + return original_file + + def add_translated_file(self, language_code, filename, file_path, file_size): + """新增翻譯檔案記錄""" + translated_file = JobFile( + job_id=self.id, + file_type='TRANSLATED', + language_code=language_code, + filename=filename, + file_path=file_path, + file_size=file_size + ) + db.session.add(translated_file) + db.session.commit() + return translated_file + + def get_translated_files(self): + """取得翻譯檔案""" + return self.files.filter_by(file_type='TRANSLATED').all() + + def get_original_file(self): + """取得原始檔案""" + return self.files.filter_by(file_type='ORIGINAL').first() + + def can_retry(self): + """是否可以重試""" + return self.status in ['FAILED', 'RETRY'] and self.retry_count < 3 + + def increment_retry(self): + """增加重試次數""" + self.retry_count += 1 + self.updated_at = datetime.utcnow() + db.session.commit() + + def soft_delete(self): + """軟刪除任務(保留資料供報表使用)""" + self.deleted_at = datetime.utcnow() + self.updated_at = datetime.utcnow() + db.session.commit() + + def restore(self): + """恢復已刪除的任務""" + self.deleted_at = None + self.updated_at = datetime.utcnow() + db.session.commit() + + def is_deleted(self): + """檢查任務是否已被軟刪除""" + return self.deleted_at is not None + + @classmethod + def get_queue_position(cls, job_uuid): + """取得任務在佇列中的位置""" + job = cls.query.filter_by(job_uuid=job_uuid, deleted_at=None).first() + if not job: + return None + + position = cls.query.filter( + cls.status == 'PENDING', + cls.deleted_at.is_(None), + cls.created_at < job.created_at + ).count() + + return position + 1 + + @classmethod + def get_pending_jobs(cls): + """取得所有等待處理的任務""" + return cls.query.filter_by(status='PENDING', deleted_at=None).order_by(cls.created_at.asc()).all() + + @classmethod + def get_processing_jobs(cls): + """取得所有處理中的任務""" + return cls.query.filter_by(status='PROCESSING', deleted_at=None).all() + + @classmethod + def get_user_jobs(cls, user_id, status=None, limit=None, offset=None, include_deleted=False): + """取得使用者的任務列表""" + query = cls.query.filter_by(user_id=user_id) + + # 預設排除軟刪除的記錄,除非明確要求包含 + if not include_deleted: + query = query.filter(cls.deleted_at.is_(None)) + + if status and status != 'all': + query = query.filter_by(status=status.upper()) + + query = query.order_by(cls.created_at.desc()) + + if limit: + query = query.limit(limit) + if offset: + query = query.offset(offset) + + return query.all() + + @classmethod + def get_statistics(cls, user_id=None, start_date=None, end_date=None, include_deleted=True): + """取得統計資料(預設包含所有記錄以確保報表完整性)""" + query = cls.query + + # 報表統計預設包含已刪除記錄以確保數據完整性 + if not include_deleted: + query = query.filter(cls.deleted_at.is_(None)) + + if user_id: + query = query.filter_by(user_id=user_id) + + if start_date: + query = query.filter(cls.created_at >= start_date) + + if end_date: + query = query.filter(cls.created_at <= end_date) + + total = query.count() + completed = query.filter_by(status='COMPLETED').count() + failed = query.filter_by(status='FAILED').count() + processing = query.filter_by(status='PROCESSING').count() + pending = query.filter_by(status='PENDING').count() + + return { + 'total': total, + 'completed': completed, + 'failed': failed, + 'processing': processing, + 'pending': pending, + 'success_rate': (completed / total * 100) if total > 0 else 0 + } + + +class JobFile(db.Model): + """檔案記錄表 (dt_job_files)""" + __tablename__ = 'dt_job_files' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + job_id = db.Column(db.Integer, db.ForeignKey('dt_translation_jobs.id'), nullable=False, comment='任務ID') + file_type = db.Column( + db.Enum('ORIGINAL', 'TRANSLATED', name='file_type'), + nullable=False, + comment='檔案類型' + ) + language_code = db.Column(db.String(50), comment='語言代碼(翻譯檔案)') + filename = db.Column(db.String(500), nullable=False, comment='檔案名稱') + file_path = db.Column(db.String(1000), nullable=False, comment='檔案路徑') + file_size = db.Column(db.BigInteger, nullable=False, comment='檔案大小') + created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間') + + def __repr__(self): + return f'' + + def to_dict(self): + """轉換為字典格式""" + return { + 'id': self.id, + 'job_id': self.job_id, + 'file_type': self.file_type, + 'language_code': self.language_code, + 'filename': self.filename, + 'file_path': self.file_path, + 'file_size': self.file_size, + 'created_at': format_taiwan_time(self.created_at, "%Y-%m-%d %H:%M:%S") if self.created_at else None + } + + +# 事件監聽器:自動生成 UUID +@event.listens_for(TranslationJob, 'before_insert') +def receive_before_insert(mapper, connection, target): + """在插入前自動生成 UUID""" + if not target.job_uuid: + target.job_uuid = str(uuid.uuid4()) \ No newline at end of file diff --git a/app/models/log.py b/app/models/log.py new file mode 100644 index 0000000..3c1afeb --- /dev/null +++ b/app/models/log.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +系統日誌資料模型 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import json +from datetime import datetime, timedelta +from sqlalchemy.sql import func +from app import db + + +class SystemLog(db.Model): + """系統日誌表 (dt_system_logs)""" + __tablename__ = 'dt_system_logs' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + level = db.Column( + db.Enum('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL', name='log_level'), + nullable=False, + comment='日誌等級' + ) + module = db.Column(db.String(100), nullable=False, comment='模組名稱') + user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), comment='使用者ID') + job_id = db.Column(db.Integer, db.ForeignKey('dt_translation_jobs.id'), comment='任務ID') + message = db.Column(db.Text, nullable=False, comment='日誌訊息') + extra_data = db.Column(db.JSON, comment='額外資料') + created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間') + + def __repr__(self): + return f'' + + def to_dict(self): + """轉換為字典格式""" + return { + 'id': self.id, + 'level': self.level, + 'module': self.module, + 'user_id': self.user_id, + 'job_id': self.job_id, + 'message': self.message, + 'extra_data': self.extra_data, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + @classmethod + def log(cls, level, module, message, user_id=None, job_id=None, extra_data=None): + """記錄日誌""" + log_entry = cls( + level=level.upper(), + module=module, + message=message, + user_id=user_id, + job_id=job_id, + extra_data=extra_data + ) + + db.session.add(log_entry) + db.session.commit() + return log_entry + + @classmethod + def debug(cls, module, message, user_id=None, job_id=None, extra_data=None): + """記錄除錯日誌""" + return cls.log('DEBUG', module, message, user_id, job_id, extra_data) + + @classmethod + def info(cls, module, message, user_id=None, job_id=None, extra_data=None): + """記錄資訊日誌""" + return cls.log('INFO', module, message, user_id, job_id, extra_data) + + @classmethod + def warning(cls, module, message, user_id=None, job_id=None, extra_data=None): + """記錄警告日誌""" + return cls.log('WARNING', module, message, user_id, job_id, extra_data) + + @classmethod + def error(cls, module, message, user_id=None, job_id=None, extra_data=None): + """記錄錯誤日誌""" + return cls.log('ERROR', module, message, user_id, job_id, extra_data) + + @classmethod + def critical(cls, module, message, user_id=None, job_id=None, extra_data=None): + """記錄嚴重錯誤日誌""" + return cls.log('CRITICAL', module, message, user_id, job_id, extra_data) + + @classmethod + def get_logs(cls, level=None, module=None, user_id=None, start_date=None, end_date=None, limit=100, offset=0): + """查詢日誌""" + query = cls.query + + if level: + query = query.filter_by(level=level.upper()) + + if module: + query = query.filter(cls.module.like(f'%{module}%')) + + if user_id: + query = query.filter_by(user_id=user_id) + + if start_date: + query = query.filter(cls.created_at >= start_date) + + if end_date: + query = query.filter(cls.created_at <= end_date) + + # 按時間倒序排列 + query = query.order_by(cls.created_at.desc()) + + if limit: + query = query.limit(limit) + if offset: + query = query.offset(offset) + + return query.all() + + @classmethod + def get_log_statistics(cls, days=7): + """取得日誌統計資料""" + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # 按等級統計 + level_stats = db.session.query( + cls.level, + func.count(cls.id).label('count') + ).filter( + cls.created_at >= start_date + ).group_by(cls.level).all() + + # 按模組統計 + module_stats = db.session.query( + cls.module, + func.count(cls.id).label('count') + ).filter( + cls.created_at >= start_date + ).group_by(cls.module).order_by( + func.count(cls.id).desc() + ).limit(10).all() + + # 每日統計 + daily_stats = db.session.query( + func.date(cls.created_at).label('date'), + cls.level, + func.count(cls.id).label('count') + ).filter( + cls.created_at >= start_date + ).group_by( + func.date(cls.created_at), cls.level + ).order_by( + func.date(cls.created_at) + ).all() + + return { + 'level_stats': [ + {'level': stat.level, 'count': stat.count} + for stat in level_stats + ], + 'module_stats': [ + {'module': stat.module, 'count': stat.count} + for stat in module_stats + ], + 'daily_stats': [ + { + 'date': stat.date.isoformat(), + 'level': stat.level, + 'count': stat.count + } + for stat in daily_stats + ] + } + + @classmethod + def cleanup_old_logs(cls, days_to_keep=30): + """清理舊日誌""" + cutoff_date = datetime.utcnow() - timedelta(days=days_to_keep) + + deleted_count = cls.query.filter( + cls.created_at < cutoff_date + ).delete(synchronize_session=False) + + db.session.commit() + return deleted_count + + @classmethod + def get_error_summary(cls, days=1): + """取得錯誤摘要""" + start_date = datetime.utcnow() - timedelta(days=days) + + error_logs = cls.query.filter( + cls.level.in_(['ERROR', 'CRITICAL']), + cls.created_at >= start_date + ).order_by(cls.created_at.desc()).limit(50).all() + + # 按模組分組錯誤 + error_by_module = {} + for log in error_logs: + module = log.module + if module not in error_by_module: + error_by_module[module] = [] + error_by_module[module].append(log.to_dict()) + + return { + 'total_errors': len(error_logs), + 'error_by_module': error_by_module, + 'recent_errors': [log.to_dict() for log in error_logs[:10]] + } \ No newline at end of file diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..5163dee --- /dev/null +++ b/app/models/notification.py @@ -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"" + + 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}" # 連結到任務詳情頁 + ) \ No newline at end of file diff --git a/app/models/stats.py b/app/models/stats.py new file mode 100644 index 0000000..ad8cd43 --- /dev/null +++ b/app/models/stats.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +API使用統計資料模型 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from datetime import datetime, timedelta +from sqlalchemy.sql import func +from app import db +from app.utils.timezone import format_taiwan_time + + +class APIUsageStats(db.Model): + """API使用統計表 (dt_api_usage_stats)""" + __tablename__ = 'dt_api_usage_stats' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), nullable=False, comment='使用者ID') + job_id = db.Column(db.Integer, db.ForeignKey('dt_translation_jobs.id'), comment='任務ID') + api_endpoint = db.Column(db.String(200), nullable=False, comment='API端點') + prompt_tokens = db.Column(db.Integer, default=0, comment='Prompt token數') + completion_tokens = db.Column(db.Integer, default=0, comment='Completion token數') + total_tokens = db.Column(db.Integer, default=0, comment='總token數') + prompt_unit_price = db.Column(db.Numeric(10, 8), default=0.00000000, comment='單價') + prompt_price_unit = db.Column(db.String(20), default='USD', comment='價格單位') + cost = db.Column(db.Numeric(10, 4), default=0.0000, comment='成本') + response_time_ms = db.Column(db.Integer, default=0, comment='回應時間(毫秒)') + success = db.Column(db.Boolean, default=True, comment='是否成功') + error_message = db.Column(db.Text, comment='錯誤訊息') + created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間') + + def __repr__(self): + return f'' + + def to_dict(self): + """轉換為字典格式""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'job_id': self.job_id, + 'api_endpoint': self.api_endpoint, + 'prompt_tokens': self.prompt_tokens, + 'completion_tokens': self.completion_tokens, + 'total_tokens': self.total_tokens, + 'prompt_unit_price': float(self.prompt_unit_price) if self.prompt_unit_price else 0.0, + 'prompt_price_unit': self.prompt_price_unit, + 'cost': float(self.cost) if self.cost else 0.0, + 'response_time_ms': self.response_time_ms, + 'success': self.success, + 'error_message': self.error_message, + 'created_at': format_taiwan_time(self.created_at, "%Y-%m-%d %H:%M:%S") if self.created_at else None + } + + @classmethod + def record_api_call(cls, user_id, job_id, api_endpoint, metadata, response_time_ms, success=True, error_message=None): + """記錄 API 呼叫統計""" + # 從 Dify API metadata 解析使用量資訊 + usage_data = metadata.get('usage', {}) + + prompt_tokens = usage_data.get('prompt_tokens', 0) + completion_tokens = usage_data.get('completion_tokens', 0) + total_tokens = usage_data.get('total_tokens', prompt_tokens + completion_tokens) + + # 計算成本 - 使用 Dify API 提供的總成本 + if 'total_price' in usage_data: + # 直接使用 API 提供的總價格 + cost = float(usage_data.get('total_price', 0.0)) + else: + # 備用計算方式 + prompt_price = float(usage_data.get('prompt_price', 0.0)) + completion_price = float(usage_data.get('completion_price', 0.0)) + cost = prompt_price + completion_price + + # 單價資訊 + prompt_unit_price = usage_data.get('prompt_unit_price', 0.0) + completion_unit_price = usage_data.get('completion_unit_price', 0.0) + prompt_price_unit = usage_data.get('currency', 'USD') + + stats = cls( + user_id=user_id, + job_id=job_id, + api_endpoint=api_endpoint, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + prompt_unit_price=prompt_unit_price, + prompt_price_unit=prompt_price_unit, + cost=cost, + response_time_ms=response_time_ms, + success=success, + error_message=error_message + ) + + db.session.add(stats) + db.session.commit() + return stats + + @classmethod + def get_user_statistics(cls, user_id, start_date=None, end_date=None): + """取得使用者統計資料""" + query = cls.query.filter_by(user_id=user_id) + + if start_date: + query = query.filter(cls.created_at >= start_date) + if end_date: + query = query.filter(cls.created_at <= end_date) + + # 統計資料 + total_calls = query.count() + successful_calls = query.filter_by(success=True).count() + total_tokens = query.with_entities(func.sum(cls.total_tokens)).scalar() or 0 + total_cost = query.with_entities(func.sum(cls.cost)).scalar() or 0.0 + avg_response_time = query.with_entities(func.avg(cls.response_time_ms)).scalar() or 0 + + return { + 'total_calls': total_calls, + 'successful_calls': successful_calls, + 'failed_calls': total_calls - successful_calls, + 'success_rate': (successful_calls / total_calls * 100) if total_calls > 0 else 0, + 'total_tokens': total_tokens, + 'total_cost': float(total_cost), + 'avg_response_time': float(avg_response_time) if avg_response_time else 0 + } + + @classmethod + def get_daily_statistics(cls, days=30): + """取得每日統計資料""" + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # 按日期分組統計 + daily_stats = db.session.query( + func.date(cls.created_at).label('date'), + func.count(cls.id).label('total_calls'), + func.sum(cls.total_tokens).label('total_tokens'), + func.sum(cls.cost).label('total_cost'), + func.count().filter(cls.success == True).label('successful_calls') + ).filter( + cls.created_at >= start_date, + cls.created_at <= end_date + ).group_by(func.date(cls.created_at)).all() + + return [ + { + 'date': stat.date.isoformat(), + 'total_calls': stat.total_calls, + 'successful_calls': stat.successful_calls, + 'failed_calls': stat.total_calls - stat.successful_calls, + 'total_tokens': stat.total_tokens or 0, + 'total_cost': float(stat.total_cost or 0) + } + for stat in daily_stats + ] + + @classmethod + def get_top_users(cls, limit=10, start_date=None, end_date=None): + """取得使用量排行榜""" + query = db.session.query( + cls.user_id, + func.count(cls.id).label('total_calls'), + func.sum(cls.total_tokens).label('total_tokens'), + func.sum(cls.cost).label('total_cost') + ) + + if start_date: + query = query.filter(cls.created_at >= start_date) + if end_date: + query = query.filter(cls.created_at <= end_date) + + top_users = query.group_by(cls.user_id).order_by( + func.sum(cls.cost).desc() + ).limit(limit).all() + + return [ + { + 'user_id': user.user_id, + 'total_calls': user.total_calls, + 'total_tokens': user.total_tokens or 0, + 'total_cost': float(user.total_cost or 0) + } + for user in top_users + ] + + @classmethod + def get_cost_trend(cls, days=30): + """取得成本趨勢""" + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # 按日期統計成本 + cost_trend = db.session.query( + func.date(cls.created_at).label('date'), + func.sum(cls.cost).label('daily_cost') + ).filter( + cls.created_at >= start_date, + cls.created_at <= end_date + ).group_by(func.date(cls.created_at)).order_by( + func.date(cls.created_at) + ).all() + + return [ + { + 'date': trend.date.isoformat(), + 'cost': float(trend.daily_cost or 0) + } + for trend in cost_trend + ] + + @classmethod + def get_endpoint_statistics(cls): + """取得 API 端點統計""" + endpoint_stats = db.session.query( + cls.api_endpoint, + func.count(cls.id).label('total_calls'), + func.sum(cls.cost).label('total_cost'), + func.avg(cls.response_time_ms).label('avg_response_time') + ).group_by(cls.api_endpoint).order_by( + func.count(cls.id).desc() + ).all() + + return [ + { + 'endpoint': stat.api_endpoint, + 'total_calls': stat.total_calls, + 'total_cost': float(stat.total_cost or 0), + 'avg_response_time': float(stat.avg_response_time or 0) + } + for stat in endpoint_stats + ] \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..63462ca --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +使用者資料模型 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from datetime import datetime, timedelta +from sqlalchemy.sql import func +from app import db +from app.utils.timezone import format_taiwan_time + + +class User(db.Model): + """使用者資訊表 (dt_users)""" + __tablename__ = 'dt_users' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + username = db.Column(db.String(100), unique=True, nullable=False, index=True, comment='AD帳號') + display_name = db.Column(db.String(200), nullable=False, comment='顯示名稱') + email = db.Column(db.String(255), nullable=False, index=True, comment='電子郵件') + department = db.Column(db.String(100), comment='部門') + is_admin = db.Column(db.Boolean, default=False, comment='是否為管理員') + last_login = db.Column(db.DateTime, comment='最後登入時間') + created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間') + updated_at = db.Column( + db.DateTime, + default=func.now(), + onupdate=func.now(), + comment='更新時間' + ) + + # 關聯關係 + translation_jobs = db.relationship('TranslationJob', backref='user', lazy='dynamic', cascade='all, delete-orphan') + api_usage_stats = db.relationship('APIUsageStats', backref='user', lazy='dynamic', cascade='all, delete-orphan') + system_logs = db.relationship('SystemLog', backref='user', lazy='dynamic') + + def __repr__(self): + return f'' + + def to_dict(self, include_stats=False): + """轉換為字典格式""" + data = { + 'id': self.id, + 'username': self.username, + 'display_name': self.display_name, + 'email': self.email, + 'department': self.department, + 'is_admin': self.is_admin, + 'last_login': format_taiwan_time(self.last_login, "%Y-%m-%d %H:%M:%S") if self.last_login else None, + 'created_at': format_taiwan_time(self.created_at, "%Y-%m-%d %H:%M:%S") if self.created_at else None, + 'updated_at': format_taiwan_time(self.updated_at, "%Y-%m-%d %H:%M:%S") if self.updated_at else None + } + + if include_stats: + data.update({ + 'total_jobs': self.translation_jobs.count(), + 'completed_jobs': self.translation_jobs.filter_by(status='COMPLETED').count(), + 'failed_jobs': self.translation_jobs.filter_by(status='FAILED').count(), + 'total_cost': self.get_total_cost() + }) + + return data + + def get_total_cost(self): + """計算使用者總成本""" + try: + from app.models.stats import APIUsageStats + return db.session.query( + func.sum(APIUsageStats.cost) + ).filter(APIUsageStats.user_id == self.id).scalar() or 0.0 + except Exception: + return 0.0 + + def update_last_login(self): + """更新最後登入時間""" + self.last_login = datetime.utcnow() + db.session.commit() + + @classmethod + def get_or_create(cls, username, display_name, email, department=None): + """取得或建立使用者""" + user = cls.query.filter_by(username=username).first() + + if user: + # 更新使用者資訊 + user.display_name = display_name + user.email = email + if department: + user.department = department + user.updated_at = datetime.utcnow() + else: + # 建立新使用者 + user = cls( + username=username, + display_name=display_name, + email=email, + department=department, + is_admin=(email.lower() == 'ymirliu@panjit.com.tw') # 硬編碼管理員 + ) + db.session.add(user) + + db.session.commit() + return user + + @classmethod + def get_admin_users(cls): + """取得所有管理員使用者""" + return cls.query.filter_by(is_admin=True).all() + + @classmethod + def get_active_users(cls, days=30): + """取得活躍使用者(指定天數內有登入)""" + cutoff_date = datetime.utcnow() - timedelta(days=days) + return cls.query.filter(cls.last_login >= cutoff_date).all() \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..2dce3b8 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +業務服務模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from .dify_client import DifyClient +from .translation_service import TranslationService +from .notification_service import NotificationService + +__all__ = [ + 'DifyClient', + 'TranslationService', + 'NotificationService' +] \ No newline at end of file diff --git a/app/services/celery_service.py b/app/services/celery_service.py new file mode 100644 index 0000000..79ebab9 --- /dev/null +++ b/app/services/celery_service.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Celery任務管理服務 + +Author: PANJIT IT Team +Created: 2025-09-04 +""" + +from celery import Celery +from app.utils.logger import get_logger +import os + +logger = get_logger(__name__) + + +def get_celery_app(): + """取得Celery應用實例""" + try: + from celery_app import app as celery_app + return celery_app + except ImportError: + # 如果無法導入,創建一個簡單的Celery實例 + broker_url = os.getenv('REDIS_URL', 'redis://localhost:6379/0') + celery_app = Celery('translation_worker', broker=broker_url) + return celery_app + + +def revoke_task(job_uuid): + """ + 撤銷指定任務的Celery任務 + + Args: + job_uuid (str): 任務UUID + + Returns: + bool: 撤銷是否成功 + """ + try: + celery_app = get_celery_app() + + # Celery任務ID通常與job_uuid相同或相關 + task_id = f"translate_document_{job_uuid}" + + # 嘗試撤銷任務 + celery_app.control.revoke(task_id, terminate=True, signal='SIGKILL') + + logger.info(f"Successfully revoked Celery task: {task_id}") + return True + + except Exception as e: + logger.error(f"Failed to revoke Celery task for job {job_uuid}: {str(e)}") + return False + + +def get_active_tasks(): + """ + 取得當前活躍的Celery任務 + + Returns: + list: 活躍任務列表 + """ + try: + celery_app = get_celery_app() + + # 取得活躍任務 + inspect = celery_app.control.inspect() + active_tasks = inspect.active() + + if active_tasks: + return active_tasks + else: + return {} + + except Exception as e: + logger.error(f"Failed to get active tasks: {str(e)}") + return {} + + +def is_task_active(job_uuid): + """ + 檢查指定任務是否在Celery中活躍 + + Args: + job_uuid (str): 任務UUID + + Returns: + bool: 任務是否活躍 + """ + try: + active_tasks = get_active_tasks() + task_id = f"translate_document_{job_uuid}" + + # 檢查所有worker的活躍任務 + for worker, tasks in active_tasks.items(): + for task in tasks: + if task.get('id') == task_id: + return True + + return False + + except Exception as e: + logger.error(f"Failed to check if task is active for job {job_uuid}: {str(e)}") + return False + + +def cleanup_stale_tasks(): + """ + 清理卡住的Celery任務 + + Returns: + int: 清理的任務數量 + """ + try: + from app.models.job import TranslationJob + from datetime import datetime, timedelta + + # 找出超過30分鐘仍在處理中的任務 + stale_threshold = datetime.utcnow() - timedelta(minutes=30) + stale_jobs = TranslationJob.query.filter( + TranslationJob.status == 'PROCESSING', + TranslationJob.processing_started_at < stale_threshold + ).all() + + cleanup_count = 0 + for job in stale_jobs: + if not is_task_active(job.job_uuid): + # 任務不在Celery中活躍,標記為失敗 + job.update_status('FAILED', error_message='任務處理超時,已自動取消') + cleanup_count += 1 + logger.info(f"Cleaned up stale job: {job.job_uuid}") + + return cleanup_count + + except Exception as e: + logger.error(f"Failed to cleanup stale tasks: {str(e)}") + return 0 \ No newline at end of file diff --git a/app/services/dify_client.py b/app/services/dify_client.py new file mode 100644 index 0000000..a1396c0 --- /dev/null +++ b/app/services/dify_client.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Dify API 客戶端服務 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import time +import requests +from typing import Dict, Any, Optional +from flask import current_app +from app.utils.logger import get_logger +from app.utils.exceptions import APIError +from app.models.stats import APIUsageStats + +logger = get_logger(__name__) + + +class DifyClient: + """Dify API 客戶端""" + + def __init__(self): + self.base_url = current_app.config.get('DIFY_API_BASE_URL', '') + self.api_key = current_app.config.get('DIFY_API_KEY', '') + self.timeout = (10, 60) # (連接超時, 讀取超時) + self.max_retries = 3 + self.retry_delay = 1.6 # 指數退避基數 + + if not self.base_url or not self.api_key: + logger.warning("Dify API configuration is incomplete") + + def _make_request(self, method: str, endpoint: str, data: Dict[str, Any] = None, + user_id: int = None, job_id: int = None) -> Dict[str, Any]: + """發送 HTTP 請求到 Dify API""" + + if not self.base_url or not self.api_key: + raise APIError("Dify API 未配置完整") + + url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json', + 'User-Agent': 'PANJIT-Document-Translator/1.0' + } + + # 重試邏輯 + last_exception = None + start_time = time.time() + + for attempt in range(self.max_retries): + try: + logger.debug(f"Making Dify API request: {method} {url} (attempt {attempt + 1})") + + if method.upper() == 'GET': + response = requests.get(url, headers=headers, timeout=self.timeout, params=data) + else: + response = requests.post(url, headers=headers, timeout=self.timeout, json=data) + + # 計算響應時間 + response_time_ms = int((time.time() - start_time) * 1000) + + # 檢查響應狀態 + response.raise_for_status() + + # 解析響應 + result = response.json() + + # 記錄 API 使用統計 + if user_id: + self._record_api_usage( + user_id=user_id, + job_id=job_id, + endpoint=endpoint, + response_data=result, + response_time_ms=response_time_ms, + success=True + ) + + logger.debug(f"Dify API request successful: {response_time_ms}ms") + return result + + except requests.exceptions.RequestException as e: + last_exception = e + response_time_ms = int((time.time() - start_time) * 1000) + + # 記錄失敗的 API 調用 + if user_id: + self._record_api_usage( + user_id=user_id, + job_id=job_id, + endpoint=endpoint, + response_data={}, + response_time_ms=response_time_ms, + success=False, + error_message=str(e) + ) + + logger.warning(f"Dify API request failed (attempt {attempt + 1}): {str(e)}") + + # 如果是最後一次嘗試,拋出異常 + if attempt == self.max_retries - 1: + break + + # 指數退避 + delay = self.retry_delay ** attempt + logger.debug(f"Retrying in {delay} seconds...") + time.sleep(delay) + + # 所有重試都失敗了 + error_msg = f"Dify API request failed after {self.max_retries} attempts: {str(last_exception)}" + logger.error(error_msg) + raise APIError(error_msg) + + def _record_api_usage(self, user_id: int, job_id: Optional[int], endpoint: str, + response_data: Dict, response_time_ms: int, success: bool, + error_message: str = None): + """記錄 API 使用統計""" + try: + # 從響應中提取使用量資訊 + metadata = response_data.get('metadata', {}) + + # 如果 job_id 無效,則設為 None 以避免外鍵約束錯誤 + APIUsageStats.record_api_call( + user_id=user_id, + job_id=job_id, # 已經是 Optional,如果無效會被設為 NULL + api_endpoint=endpoint, + metadata=metadata, + response_time_ms=response_time_ms, + success=success, + error_message=error_message + ) + except Exception as e: + logger.warning(f"Failed to record API usage: {str(e)}") + + def translate_text(self, text: str, source_language: str, target_language: str, + user_id: int = None, job_id: int = None) -> Dict[str, Any]: + """翻譯文字""" + + if not text.strip(): + raise APIError("翻譯文字不能為空") + + # 構建標準翻譯 prompt(英文指令格式) + language_names = { + 'zh-tw': 'Traditional Chinese', + 'zh-cn': 'Simplified Chinese', + 'en': 'English', + 'ja': 'Japanese', + 'ko': 'Korean', + 'vi': 'Vietnamese', + 'th': 'Thai', + 'id': 'Indonesian', + 'ms': 'Malay', + 'es': 'Spanish', + 'fr': 'French', + 'de': 'German', + 'ru': 'Russian', + 'ar': 'Arabic' + } + + source_lang_name = language_names.get(source_language, source_language) + target_lang_name = language_names.get(target_language, target_language) + + query = f"""Task: Translate ONLY into {target_lang_name} from {source_lang_name}. + +Rules: +- Output translation text ONLY (no source text, no notes, no questions, no language-detection remarks). +- Preserve original line breaks. +- Do NOT wrap in quotes or code blocks. +- Maintain original formatting and structure. + +{text.strip()}""" + + # 構建請求資料 - 使用成功版本的格式 + request_data = { + 'inputs': {}, + 'response_mode': 'blocking', + 'user': f"user_{user_id}" if user_id else "doc-translator-user", + 'query': query + } + + try: + response = self._make_request( + method='POST', + endpoint='/chat-messages', + data=request_data, + user_id=user_id, + job_id=job_id + ) + + # 從響應中提取翻譯結果 - 使用成功版本的方式 + answer = response.get('answer') + + if not isinstance(answer, str) or not answer.strip(): + raise APIError("Dify API 返回空的翻譯結果") + + return { + 'success': True, + 'translated_text': answer, + 'source_text': text, + 'source_language': source_language, + 'target_language': target_language, + 'metadata': response.get('metadata', {}) + } + + except APIError: + raise + except Exception as e: + error_msg = f"翻譯請求處理錯誤: {str(e)}" + logger.error(error_msg) + raise APIError(error_msg) + + def test_connection(self) -> bool: + """測試 Dify API 連接""" + try: + # 發送簡單的測試請求 + test_data = { + 'inputs': {'text': 'test'}, + 'response_mode': 'blocking', + 'user': 'health_check' + } + + response = self._make_request( + method='POST', + endpoint='/chat-messages', + data=test_data + ) + + return response is not None + + except Exception as e: + logger.error(f"Dify API connection test failed: {str(e)}") + return False + + def get_app_info(self) -> Dict[str, Any]: + """取得 Dify 應用資訊""" + try: + response = self._make_request( + method='GET', + endpoint='/parameters' + ) + + return { + 'success': True, + 'app_info': response + } + + except Exception as e: + logger.error(f"Failed to get Dify app info: {str(e)}") + return { + 'success': False, + 'error': str(e) + } + + @classmethod + def load_config_from_file(cls, file_path: str = 'api.txt'): + """從檔案載入 Dify API 配置""" + try: + import os + from pathlib import Path + + config_file = Path(file_path) + + if not config_file.exists(): + logger.warning(f"Dify config file not found: {file_path}") + return + + with open(config_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line.startswith('base_url:'): + base_url = line.split(':', 1)[1].strip() + current_app.config['DIFY_API_BASE_URL'] = base_url + elif line.startswith('api:'): + api_key = line.split(':', 1)[1].strip() + current_app.config['DIFY_API_KEY'] = api_key + + logger.info("Dify API config loaded from file") + + except Exception as e: + logger.error(f"Failed to load Dify config from file: {str(e)}") + + +def init_dify_config(app): + """初始化 Dify 配置""" + with app.app_context(): + # 從 api.txt 載入配置 + DifyClient.load_config_from_file() + + # 檢查配置完整性 + base_url = app.config.get('DIFY_API_BASE_URL') + api_key = app.config.get('DIFY_API_KEY') + + if base_url and api_key: + logger.info("Dify API configuration loaded successfully") + else: + logger.warning("Dify API configuration is incomplete") + logger.warning(f"Base URL: {'✓' if base_url else '✗'}") + logger.warning(f"API Key: {'✓' if api_key else '✗'}") \ No newline at end of file diff --git a/app/services/document_processor.py b/app/services/document_processor.py new file mode 100644 index 0000000..8bf6ebf --- /dev/null +++ b/app/services/document_processor.py @@ -0,0 +1,864 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +核心文檔處理邏輯 - 移植自最佳版本 +包含完整的 DOCX 文字提取和翻譯插入功能 + +Author: PANJIT IT Team +Created: 2024-09-02 +Modified: 2024-09-02 +""" + +import re +import sys +import time +from pathlib import Path +from typing import List, Dict, Tuple, Optional, Any +from docx.text.paragraph import Paragraph +from docx.table import Table, _Cell +from docx.shared import Pt +from docx.oxml import OxmlElement +from docx.oxml.ns import qn, nsdecls +import docx + +from app.utils.logger import get_logger +from app.utils.exceptions import FileProcessingError + +logger = get_logger(__name__) + +# ---------- Constants ---------- +INSERT_FONT_SIZE_PT = 10 +SENTENCE_MODE = True + +# ---------- Optional dependencies detection ---------- +try: + import blingfire + _HAS_BLINGFIRE = True +except ImportError: + _HAS_BLINGFIRE = False + +try: + import pysbd + _HAS_PYSBD = True +except ImportError: + _HAS_PYSBD = False + +# ---------- Helper functions ---------- +def _has_cjk(text: str) -> bool: + """Check if text contains CJK (Chinese/Japanese/Korean) characters.""" + for char in text: + if '\u4e00' <= char <= '\u9fff' or \ + '\u3400' <= char <= '\u4dbf' or \ + '\u20000' <= char <= '\u2a6df' or \ + '\u3040' <= char <= '\u309f' or \ + '\u30a0' <= char <= '\u30ff' or \ + '\uac00' <= char <= '\ud7af': + return True + return False + +def _normalize_text(text: str) -> str: + """Normalize text for comparison.""" + return re.sub(r'\s+', ' ', text.strip().lower()) + +def _append_after(p: Paragraph, text_block: str, italic: bool=True, font_size_pt: int=INSERT_FONT_SIZE_PT) -> Paragraph: + """Insert a new paragraph after p, return the new paragraph (for chain insert).""" + new_p = OxmlElement("w:p") + p._p.addnext(new_p) + np = Paragraph(new_p, p._parent) + lines = text_block.split("\n") + for i, line in enumerate(lines): + run = np.add_run(line) + if italic: + run.italic = True + if font_size_pt: + run.font.size = Pt(font_size_pt) + if i < len(lines) - 1: + run.add_break() + tag = np.add_run("\u200b") + if italic: + tag.italic = True + if font_size_pt: + tag.font.size = Pt(font_size_pt) + return np + +def _is_our_insert_block(p: Paragraph) -> bool: + """Return True iff paragraph contains our zero-width marker.""" + return any("\u200b" in (r.text or "") for r in p.runs) + +def _find_last_inserted_after(p: Paragraph, limit: int = 8) -> Optional[Paragraph]: + """Find the last paragraph that was inserted after p (up to limit paragraphs).""" + try: + # Get all paragraphs in the parent container + if hasattr(p._parent, 'paragraphs'): + all_paras = list(p._parent.paragraphs) + else: + # Handle cases where _parent doesn't have paragraphs (e.g., table cells) + return None + + # Find p's index + p_index = -1 + for i, para in enumerate(all_paras): + if para._element == p._element: + p_index = i + break + + if p_index == -1: + return None + + # Check paragraphs after p + last_found = None + for i in range(p_index + 1, min(p_index + 1 + limit, len(all_paras))): + if _is_our_insert_block(all_paras[i]): + last_found = all_paras[i] + else: + break # Stop at first non-inserted paragraph + except Exception: + return None + + return last_found + +def _p_text_with_breaks(p: Paragraph) -> str: + """Extract text from paragraph with line breaks preserved.""" + parts = [] + for node in p._element.xpath(".//*[local-name()='t' or local-name()='br' or local-name()='tab']"): + tag = node.tag.split('}', 1)[-1] + if tag == "t": + parts.append(node.text or "") + elif tag == "br": + parts.append("\n") + elif tag == "tab": + parts.append("\t") + return "".join(parts) + +def _get_cell_full_text(cell) -> str: + """ + 提取表格儲存格的完整文字內容,包含所有段落 + """ + try: + cell_texts = [] + for para in cell.paragraphs: + para_text = _p_text_with_breaks(para) + if para_text.strip(): + cell_texts.append(para_text.strip()) + + # 用換行符連接所有段落 + return '\n'.join(cell_texts) + except Exception as e: + logger.warning(f"提取儲存格文字失敗: {e}") + return "" + +def _is_our_insert_block_text(text: str) -> bool: + """檢查文字是否為翻譯插入區塊""" + if not text: + return False + text_lower = text.lower().strip() + return ( + text_lower.startswith('【') or + text_lower.startswith('[翻譯') or + '翻譯:' in text_lower or + 'translation:' in text_lower or + text_lower.startswith('translated:') or + "\u200b" in text + ) + +def _is_our_insert_block(p: Paragraph) -> bool: + """Check if paragraph is our inserted translation (contains zero-width space marker).""" + text = _p_text_with_breaks(p) + return "\u200b" in text + +def should_translate(text: str, src_lang: str) -> bool: + """Determine if text should be translated based on content and source language.""" + text = text.strip() + + # 只要有字就翻譯 - 最小長度設為1 + if len(text) < 1: + return False + + # Skip pure numbers, dates, etc. + if re.match(r'^[\d\s\.\-\:\/]+$', text): + return False + + # For auto-detect, translate if has CJK or meaningful text + if src_lang.lower() in ('auto', 'auto-detect'): + return _has_cjk(text) or len(text) > 5 + + return True + +def _split_sentences(text: str, lang: str = 'auto') -> List[str]: + """Split text into sentences using available libraries.""" + if not text.strip(): + return [] + + # Try blingfire first + if _HAS_BLINGFIRE and SENTENCE_MODE: + try: + sentences = blingfire.text_to_sentences(text).split('\n') + sentences = [s.strip() for s in sentences if s.strip()] + if sentences: + return sentences + except Exception as e: + logger.warning(f"Blingfire failed: {e}") + + # Try pysbd + if _HAS_PYSBD and SENTENCE_MODE: + try: + seg = pysbd.Segmenter(language="en" if lang == "auto" else lang) + sentences = seg.segment(text) + sentences = [s.strip() for s in sentences if s.strip()] + if sentences: + return sentences + except Exception as e: + logger.warning(f"PySBD failed: {e}") + + # Fallback to simple splitting + separators = ['. ', '。', '!', '?', '!', '?', '\n'] + sentences = [text] + + for sep in separators: + new_sentences = [] + for s in sentences: + parts = s.split(sep) + if len(parts) > 1: + new_sentences.extend([p.strip() + sep.rstrip() for p in parts[:-1] if p.strip()]) + if parts[-1].strip(): + new_sentences.append(parts[-1].strip()) + else: + new_sentences.append(s) + sentences = new_sentences + + return [s for s in sentences if len(s.strip()) > 3] + +# ---------- Segment class ---------- +class Segment: + """Represents a translatable text segment in a document.""" + + def __init__(self, kind: str, ref: Any, ctx: str, text: str): + self.kind = kind # 'para' | 'txbx' + self.ref = ref # Reference to original document element + self.ctx = ctx # Context information + self.text = text # Text content + +# ---------- TextBox helpers ---------- +def _txbx_iter_texts(doc: docx.Document): + """ + Yield (txbxContent_element, joined_source_text) + - Deeply collect all descendant under txbxContent + - Skip our inserted translations: contains zero-width or (all italic and no CJK) + - Keep only lines that still have CJK + """ + def _p_text_flags(p_el): + parts = [] + for node in p_el.xpath(".//*[local-name()='t' or local-name()='br' or local-name()='tab']"): + tag = node.tag.split('}', 1)[-1] + if tag == "t": + parts.append(node.text or "") + elif tag == "br": + parts.append("\n") + else: + parts.append(" ") + text = "".join(parts) + has_zero = ("\u200b" in text) + runs = p_el.xpath(".//*[local-name()='r']") + vis, ital = [], [] + for r in runs: + rt = "".join([(t.text or "") for t in r.xpath(".//*[local-name()='t']")]) + if (rt or "").strip(): + vis.append(rt) + ital.append(bool(r.xpath(".//*[local-name()='i']"))) + all_italic = (len(vis) > 0 and all(ital)) + return text, has_zero, all_italic + + for tx in doc._element.xpath(".//*[local-name()='txbxContent']"): + kept = [] + for p in tx.xpath(".//*[local-name()='p']"): # all descendant paragraphs + text, has_zero, all_italic = _p_text_flags(p) + if not (text or "").strip(): + continue + if has_zero: + continue # our inserted + for line in text.split("\n"): + if line.strip(): + kept.append(line.strip()) + if kept: + joined = "\n".join(kept) + yield tx, joined + +def _txbx_append_paragraph(tx, text_block: str, italic: bool = True, font_size_pt: int = INSERT_FONT_SIZE_PT): + """Append a paragraph to textbox content.""" + p = OxmlElement("w:p") + r = OxmlElement("w:r") + rPr = OxmlElement("w:rPr") + if italic: + rPr.append(OxmlElement("w:i")) + if font_size_pt: + sz = OxmlElement("w:sz") + sz.set(qn("w:val"), str(int(font_size_pt * 2))) + rPr.append(sz) + r.append(rPr) + lines = text_block.split("\n") + for i, line in enumerate(lines): + if i > 0: + r.append(OxmlElement("w:br")) + t = OxmlElement("w:t") + t.set(qn("xml:space"), "preserve") + t.text = line + r.append(t) + tag = OxmlElement("w:t") + tag.set(qn("xml:space"), "preserve") + tag.text = "\u200b" + r.append(tag) + p.append(r) + tx.append(p) + +def _txbx_tail_equals(tx, translations: List[str]) -> bool: + """Check if textbox already contains the expected translations.""" + paras = tx.xpath("./*[local-name()='p']") + if len(paras) < len(translations): + return False + tail = paras[-len(translations):] + for q, expect in zip(tail, translations): + parts = [] + for node in q.xpath(".//*[local-name()='t' or local-name()='br']"): + tag = node.tag.split("}", 1)[-1] + parts.append("\n" if tag == "br" else (node.text or "")) + if _normalize_text("".join(parts).strip()) != _normalize_text(expect): + return False + return True + +# ---------- Main extraction logic ---------- +def _get_paragraph_key(p: Paragraph) -> str: + """Generate a stable unique key for paragraph deduplication.""" + try: + # Use XML content hash + text content for stable deduplication + xml_content = p._p.xml if hasattr(p._p, 'xml') else str(p._p) + text_content = _p_text_with_breaks(p) + combined = f"{hash(xml_content)}_{len(text_content)}_{text_content[:50]}" + return combined + except Exception: + # Fallback to simple text-based key + text_content = _p_text_with_breaks(p) + return f"fallback_{hash(text_content)}_{len(text_content)}" + +def _collect_docx_segments(doc: docx.Document) -> List[Segment]: + """ + Enhanced segment collector with improved stability. + Handles paragraphs, tables, textboxes, and SDT Content Controls. + """ + segs: List[Segment] = [] + seen_par_keys = set() + + def _add_paragraph(p: Paragraph, ctx: str): + try: + p_key = _get_paragraph_key(p) + if p_key in seen_par_keys: + return + + txt = _p_text_with_breaks(p) + if txt.strip() and not _is_our_insert_block(p): + segs.append(Segment("para", p, ctx, txt)) + seen_par_keys.add(p_key) + except Exception as e: + # Log error but continue processing + logger.warning(f"段落處理錯誤: {e}, 跳過此段落") + + def _process_container_content(container, ctx: str): + """ + Recursively processes content within a container (body, cell, or SDT content). + Identifies and handles paragraphs, tables, and SDT elements. + """ + if container._element is None: + return + + for child_element in container._element: + qname = child_element.tag + + if qname.endswith('}p'): # Paragraph + p = Paragraph(child_element, container) + _add_paragraph(p, ctx) + + elif qname.endswith('}tbl'): # Table + table = Table(child_element, container) + for r_idx, row in enumerate(table.rows, 1): + for c_idx, cell in enumerate(row.cells, 1): + cell_ctx = f"{ctx} > Tbl(r{r_idx},c{c_idx})" + + # 使用儲存格為單位的提取方式(而非逐段落提取) + cell_text = _get_cell_full_text(cell) + if cell_text.strip() and not _is_our_insert_block_text(cell_text): + segs.append(Segment("table_cell", cell, cell_ctx, cell_text)) + + elif qname.endswith('}sdt'): # Structured Document Tag (SDT) + sdt_ctx = f"{ctx} > SDT" + + # 1. 提取 SDT 的元數據文本 (Placeholder, Dropdown items) + ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} + + # 提取 Placeholder text + placeholder_texts = [] + for t in child_element.xpath('.//w:placeholder//w:t', namespaces=ns): + if t.text: + placeholder_texts.append(t.text) + if placeholder_texts: + full_placeholder = "".join(placeholder_texts).strip() + if full_placeholder: + segs.append(Segment("para", child_element, f"{sdt_ctx}-Placeholder", full_placeholder)) + + # 提取 Dropdown list items + list_items = [] + for item in child_element.xpath('.//w:dropDownList/w:listItem', namespaces=ns): + display_text = item.get(qn('w:displayText')) + if display_text: + list_items.append(display_text) + if list_items: + items_as_text = "\n".join(list_items) + segs.append(Segment("para", child_element, f"{sdt_ctx}-Dropdown", items_as_text)) + + # 2. 遞迴處理 SDT 的實際內容 (sdtContent) + sdt_content_element = child_element.find(qn('w:sdtContent')) + if sdt_content_element is not None: + class SdtContentWrapper: + def __init__(self, element, parent): + self._element = element + self._parent = parent + + sdt_content_wrapper = SdtContentWrapper(sdt_content_element, container) + _process_container_content(sdt_content_wrapper, sdt_ctx) + + # --- Main execution starts here --- + + # 1. Process the main document body + _process_container_content(doc._body, "Body") + + # 2. Process textboxes + for tx, s in _txbx_iter_texts(doc): + if s.strip() and (_has_cjk(s) or should_translate(s, 'auto')): + segs.append(Segment("txbx", tx, "TextBox", s)) + + return segs + +def _insert_docx_translations(doc: docx.Document, segs: List[Segment], + tmap: Dict[Tuple[str, str], str], + targets: List[str], log=lambda s: None) -> Tuple[int, int]: + """ + Insert translations into DOCX document segments. + + CRITICAL: This function contains the fix for the major translation insertion bug. + The key fix is in the segment filtering logic - we now correctly check if any target + language has translation available using the proper key format (target_lang, text). + + Args: + doc: The DOCX document object + segs: List of segments to translate + tmap: Translation map with keys as (target_language, source_text) + targets: List of target languages in order + log: Logging function + + Returns: + Tuple of (successful_insertions, skipped_insertions) + + Key Bug Fix: + OLD (INCORRECT): if (seg.kind, seg.text) not in tmap and (targets[0], seg.text) not in tmap + NEW (CORRECT): has_any_translation = any((tgt, seg.text) in tmap for tgt in targets) + """ + ok_cnt = skip_cnt = 0 + + # Helper function to add a formatted run to a paragraph + def _add_formatted_run(p: Paragraph, text: str, italic: bool, font_size_pt: int): + lines = text.split("\n") + for i, line in enumerate(lines): + run = p.add_run(line) + if italic: + run.italic = True + if font_size_pt: + run.font.size = Pt(font_size_pt) + if i < len(lines) - 1: + run.add_break() + # Add our zero-width space marker + tag_run = p.add_run("\u200b") + if italic: + tag_run.italic = True + if font_size_pt: + tag_run.font.size = Pt(font_size_pt) + + for seg in segs: + # Check if any target language has translation for this segment + has_any_translation = any((tgt, seg.text) in tmap for tgt in targets) + if not has_any_translation: + log(f"[SKIP] 無翻譯結果: {seg.ctx} | {seg.text[:50]}...") + skip_cnt += 1 + continue + + # Get translations for all targets, with fallback for missing ones + translations = [] + for tgt in targets: + if (tgt, seg.text) in tmap: + translations.append(tmap[(tgt, seg.text)]) + else: + log(f"[WARNING] 缺少 {tgt} 翻譯: {seg.text[:30]}...") + translations.append(f"【翻譯查詢失敗|{tgt}】{seg.text[:50]}...") + + log(f"[INSERT] 準備插入 {len(translations)} 個翻譯到 {seg.ctx}: {seg.text[:30]}...") + + if seg.kind == "para": + # Check if this is an SDT segment (ref is an XML element, not a Paragraph) + if hasattr(seg.ref, 'tag') and seg.ref.tag.endswith('}sdt'): + # Handle SDT segments - insert translation into sdtContent + sdt_element = seg.ref + ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} + sdt_content = sdt_element.find(qn('w:sdtContent')) + + if sdt_content is not None: + # Check if translations already exist + existing_paras = sdt_content.xpath('.//w:p', namespaces=ns) + existing_texts = [] + for ep in existing_paras: + p_obj = Paragraph(ep, None) + if _is_our_insert_block(p_obj): + existing_texts.append(_p_text_with_breaks(p_obj)) + + # Check if all translations already exist + if len(existing_texts) >= len(translations): + if all(_normalize_text(e) == _normalize_text(t) for e, t in zip(existing_texts[:len(translations)], translations)): + skip_cnt += 1 + log(f"[SKIP] SDT 已存在翻譯: {seg.text[:30]}...") + continue + + # Add translations to SDT content + for t in translations: + if not any(_normalize_text(t) == _normalize_text(e) for e in existing_texts): + # Create new paragraph in SDT content + new_p_element = OxmlElement("w:p") + sdt_content.append(new_p_element) + new_p = Paragraph(new_p_element, None) + _add_formatted_run(new_p, t, italic=True, font_size_pt=INSERT_FONT_SIZE_PT) + + ok_cnt += 1 + log(f"[SUCCESS] SDT 插入翻譯(交錯格式)") + continue + + p: Paragraph = seg.ref + + # --- CONTEXT-AWARE INSERTION LOGIC (from successful version) --- + # Check if the paragraph's parent is a table cell + if isinstance(p._parent, _Cell): + cell = p._parent + + try: + # Find the current paragraph's position in the cell + cell_paragraphs = list(cell.paragraphs) + p_index = -1 + for idx, cell_p in enumerate(cell_paragraphs): + if cell_p._element == p._element: + p_index = idx + break + + if p_index == -1: + log(f"[WARNING] 無法找到段落在單元格中的位置,使用原始方法") + # Fallback to original method + for block in translations: + new_p = cell.add_paragraph() + _add_formatted_run(new_p, block, italic=True, font_size_pt=INSERT_FONT_SIZE_PT) + ok_cnt += 1 + continue + + # Check if translations already exist right after this paragraph + existing_texts = [] + check_limit = min(p_index + 1 + len(translations), len(cell_paragraphs)) + for idx in range(p_index + 1, check_limit): + if _is_our_insert_block(cell_paragraphs[idx]): + existing_texts.append(_p_text_with_breaks(cell_paragraphs[idx])) + + # Check if all translations already exist in order + if len(existing_texts) >= len(translations): + if all(_normalize_text(e) == _normalize_text(t) for e, t in zip(existing_texts[:len(translations)], translations)): + skip_cnt += 1 + log(f"[SKIP] 表格單元格已存在翻譯: {seg.text[:30]}...") + continue + + # Determine which translations need to be added + to_add = [] + for t in translations: + if not any(_normalize_text(t) == _normalize_text(e) for e in existing_texts): + to_add.append(t) + + if not to_add: + skip_cnt += 1 + log(f"[SKIP] 表格單元格所有翻譯已存在: {seg.text[:30]}...") + continue + + # Insert new paragraphs right after the current paragraph + insert_after = p + for block in to_add: + try: + # Create new paragraph and insert it after the current position + new_p_element = OxmlElement("w:p") + insert_after._element.addnext(new_p_element) + new_p = Paragraph(new_p_element, cell) + _add_formatted_run(new_p, block, italic=True, font_size_pt=INSERT_FONT_SIZE_PT) + insert_after = new_p # Update position for next insertion + except Exception as e: + log(f"[ERROR] 表格插入失敗: {e}, 嘗試fallback方法") + # Fallback: add at the end of cell + try: + new_p = cell.add_paragraph() + _add_formatted_run(new_p, block, italic=True, font_size_pt=INSERT_FONT_SIZE_PT) + log(f"[SUCCESS] Fallback插入成功") + except Exception as e2: + log(f"[FATAL] Fallback也失敗: {e2}") + continue + ok_cnt += 1 + log(f"[SUCCESS] 表格單元格插入 {len(to_add)} 個翻譯(緊接原文後)") + + except Exception as e: + log(f"[ERROR] 表格處理全面失敗: {e}, 跳過此段落") + continue + + else: + # Normal paragraph (not in table cell) - SIMPLIFIED FOR DEBUGGING + try: + # TEMPORARILY DISABLE existing translation check to force insertion + log(f"[DEBUG] 強制插入翻譯到段落: {seg.text[:30]}...") + + # Force all translations to be added + to_add = translations + + # Use simple positioning - always insert after current paragraph + anchor = p + + for block in to_add: + try: + log(f"[DEBUG] 嘗試插入: {block[:50]}...") + anchor = _append_after(anchor, block, italic=True, font_size_pt=INSERT_FONT_SIZE_PT) + log(f"[SUCCESS] _append_after成功插入") + except Exception as e: + log(f"[ERROR] _append_after失敗: {e}, 嘗試簡化插入") + try: + # Fallback: simple append + if hasattr(p._parent, 'add_paragraph'): + new_p = p._parent.add_paragraph() + _add_formatted_run(new_p, block, italic=True, font_size_pt=INSERT_FONT_SIZE_PT) + log(f"[SUCCESS] Fallback段落插入成功") + else: + log(f"[ERROR] 無法進行fallback插入") + except Exception as e2: + log(f"[FATAL] Fallback也失敗: {e2}") + continue + + ok_cnt += 1 + log(f"[SUCCESS] 段落強制插入 {len(to_add)} 個翻譯") + + except Exception as e: + log(f"[ERROR] 段落處理失敗: {e}, 跳過此段落") + continue + + elif seg.kind == "table_cell": + # 處理表格儲存格翻譯插入 + cell = seg.ref # cell 是 _Cell 對象 + + # 檢查儲存格是否已有翻譯 + existing_translations = [] + cell_paragraphs = list(cell.paragraphs) + + # 檢查儲存格末尾是否已有翻譯 + translation_start_index = len(cell_paragraphs) + for i in range(len(cell_paragraphs) - 1, -1, -1): + if _is_our_insert_block(cell_paragraphs[i]): + existing_translations.insert(0, _p_text_with_breaks(cell_paragraphs[i])) + translation_start_index = i + else: + break + + # 檢查是否所有翻譯都已存在且相同 + if len(existing_translations) >= len(translations): + if all(_normalize_text(e) == _normalize_text(t) for e, t in zip(existing_translations[:len(translations)], translations)): + skip_cnt += 1 + log(f"[SKIP] 表格儲存格已存在翻譯: {seg.text[:30]}...") + continue + + # 移除舊的翻譯段落(如果有的話) + for i in range(len(cell_paragraphs) - 1, translation_start_index - 1, -1): + if _is_our_insert_block(cell_paragraphs[i]): + cell._element.remove(cell_paragraphs[i]._element) + + # 檢查是否為簡單的短文本儲存格(只有原文,沒有複雜結構) + cell_content = cell.text.strip() + is_simple_cell = len(cell_content) <= 10 and cell_content == seg.text.strip() + + if is_simple_cell: + # 對於簡單短文本,直接替換內容而不是添加段落 + log(f"[INFO] 簡單儲存格內容替換: '{seg.text.strip()}' -> '{translations[0] if translations else 'N/A'}'") + + # 清空所有段落內容 + for para in cell.paragraphs: + para.clear() + + # 在第一個段落中添加原文和翻譯 + first_para = cell.paragraphs[0] if cell.paragraphs else cell.add_paragraph() + + # 添加原文 + run_orig = first_para.add_run(seg.text.strip()) + + # 添加換行和翻譯 + for t in translations: + first_para.add_run('\n') + run_trans = first_para.add_run(t) + run_trans.italic = True + if INSERT_FONT_SIZE_PT: + run_trans.font.size = Pt(INSERT_FONT_SIZE_PT) + + # 添加標記 + tag_run = first_para.add_run("\u200b") + tag_run.italic = True + if INSERT_FONT_SIZE_PT: + tag_run.font.size = Pt(INSERT_FONT_SIZE_PT) + else: + # 對於複雜儲存格,使用原有的添加段落方式 + for t in translations: + new_p = cell.add_paragraph() + _add_formatted_run(new_p, t, italic=True, font_size_pt=INSERT_FONT_SIZE_PT) + + ok_cnt += 1 + log(f"[SUCCESS] 表格儲存格插入 {len(translations)} 個翻譯") + + elif seg.kind == "txbx": + tx = seg.ref + # Check if textbox already has our translations at the end + if _txbx_tail_equals(tx, translations): + skip_cnt += 1 + log(f"[SKIP] 文字框已存在翻譯: {seg.text[:30]}...") + continue + + # Append translations to textbox + for t in translations: + _txbx_append_paragraph(tx, t, italic=True, font_size_pt=INSERT_FONT_SIZE_PT) + + ok_cnt += 1 + log(f"[SUCCESS] 文字框插入 {len(translations)} 個翻譯") + + return ok_cnt, skip_cnt + +# ---------- Main DocumentProcessor class ---------- +class DocumentProcessor: + """Enhanced document processor with complete DOCX handling capabilities.""" + + def __init__(self): + self.logger = logger + + def extract_docx_segments(self, file_path: str) -> List[Segment]: + """Extract all translatable segments from DOCX file.""" + try: + doc = docx.Document(file_path) + segments = _collect_docx_segments(doc) + + self.logger.info(f"Extracted {len(segments)} segments from {file_path}") + for seg in segments[:5]: # Log first 5 segments for debugging + self.logger.debug(f"Segment: {seg.kind} | {seg.ctx} | {seg.text[:50]}...") + + return segments + + except Exception as e: + self.logger.error(f"Failed to extract DOCX segments from {file_path}: {str(e)}") + raise FileProcessingError(f"DOCX 文件分析失敗: {str(e)}") + + def _rematch_segments_to_document(self, doc: docx.Document, old_segments: List[Segment]) -> List[Segment]: + """Re-match segments from old document instance to new document instance.""" + try: + # Extract fresh segments from the current document instance + fresh_segments = _collect_docx_segments(doc) + + # Match old segments with fresh segments based on text content + matched_segments = [] + + for old_seg in old_segments: + # Find matching segment in fresh segments + matched = False + for fresh_seg in fresh_segments: + if (old_seg.kind == fresh_seg.kind and + old_seg.ctx == fresh_seg.ctx and + _normalize_text(old_seg.text) == _normalize_text(fresh_seg.text)): + matched_segments.append(fresh_seg) + matched = True + break + + if not matched: + self.logger.warning(f"Failed to match segment: {old_seg.text[:50]}...") + # Still add the old segment but it might not work for insertion + matched_segments.append(old_seg) + + self.logger.debug(f"Re-matched {len(matched_segments)} segments to current document") + return matched_segments + + except Exception as e: + self.logger.error(f"Failed to re-match segments: {str(e)}") + # Return original segments as fallback + return old_segments + + def insert_docx_translations(self, file_path: str, segments: List[Segment], + translation_map: Dict[Tuple[str, str], str], + target_languages: List[str], output_path: str) -> Tuple[int, int]: + """Insert translations into DOCX file and save to output path.""" + try: + doc = docx.Document(file_path) + + # CRITICAL FIX: Re-match segments with the current document instance + # The original segments were extracted from a different document instance + matched_segments = self._rematch_segments_to_document(doc, segments) + + def log_func(msg: str): + self.logger.debug(msg) + + ok_count, skip_count = _insert_docx_translations( + doc, matched_segments, translation_map, target_languages, log_func + ) + + # Save the modified document + doc.save(output_path) + + self.logger.info(f"Inserted {ok_count} translations, skipped {skip_count}. Saved to: {output_path}") + return ok_count, skip_count + + except Exception as e: + self.logger.error(f"Failed to insert DOCX translations: {str(e)}") + raise FileProcessingError(f"DOCX 翻譯插入失敗: {str(e)}") + + def split_text_into_sentences(self, text: str, language: str = 'auto') -> List[str]: + """Split text into sentences using the best available method.""" + return _split_sentences(text, language) + + def should_translate_text(self, text: str, source_language: str) -> bool: + """Determine if text should be translated.""" + return should_translate(text, source_language) + + def insert_docx_combined_translations(self, file_path: str, segments: List[Segment], + translation_map: Dict[Tuple[str, str], str], + target_languages: List[str], output_path: str) -> Tuple[int, int]: + """Insert all translations into a single DOCX file with combined multi-language output. + + This creates a combined file where each original text is followed by all translations + in the format: original\n英文\n越南文 etc. + """ + try: + doc = docx.Document(file_path) + + # Re-match segments with the current document instance + matched_segments = self._rematch_segments_to_document(doc, segments) + + def log_func(msg: str): + self.logger.debug(msg) + + # Use the existing _insert_docx_translations function which already supports + # multiple target languages in a single document + ok_count, skip_count = _insert_docx_translations( + doc, matched_segments, translation_map, target_languages, log_func + ) + + # Save the combined document + doc.save(output_path) + + self.logger.info(f"Generated combined multi-language file: {output_path}") + self.logger.info(f"Inserted {ok_count} translations, skipped {skip_count}") + return ok_count, skip_count + + except Exception as e: + self.logger.error(f"Failed to create combined DOCX translations: {str(e)}") + raise FileProcessingError(f"組合多語言 DOCX 檔案生成失敗: {str(e)}") \ No newline at end of file diff --git a/app/services/notification_service.py b/app/services/notification_service.py new file mode 100644 index 0000000..1086c39 --- /dev/null +++ b/app/services/notification_service.py @@ -0,0 +1,645 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +通知服務 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import os +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +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__) + + +class NotificationService: + """通知服務""" + + def __init__(self): + self.smtp_server = current_app.config.get('SMTP_SERVER') + self.smtp_port = current_app.config.get('SMTP_PORT', 587) + self.use_tls = current_app.config.get('SMTP_USE_TLS', False) + self.use_ssl = current_app.config.get('SMTP_USE_SSL', False) + self.auth_required = current_app.config.get('SMTP_AUTH_REQUIRED', False) + self.sender_email = current_app.config.get('SMTP_SENDER_EMAIL') + self.sender_password = current_app.config.get('SMTP_SENDER_PASSWORD', '') + self.app_name = current_app.config.get('APP_NAME', 'PANJIT Document Translator') + + def _create_smtp_connection(self): + """建立 SMTP 連線""" + try: + if self.use_ssl: + server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port) + else: + server = smtplib.SMTP(self.smtp_server, self.smtp_port) + if self.use_tls: + server.starttls() + + if self.auth_required and self.sender_password: + server.login(self.sender_email, self.sender_password) + + return server + except Exception as e: + logger.error(f"SMTP connection failed: {str(e)}") + return None + + def _send_email(self, to_email: str, subject: str, html_content: str, text_content: str = None) -> bool: + """發送郵件的基礎方法""" + try: + if not self.smtp_server or not self.sender_email: + logger.error("SMTP configuration incomplete") + return False + + # 建立郵件 + msg = MIMEMultipart('alternative') + msg['From'] = f"{self.app_name} <{self.sender_email}>" + msg['To'] = to_email + msg['Subject'] = subject + + # 添加文本內容 + if text_content: + text_part = MIMEText(text_content, 'plain', 'utf-8') + msg.attach(text_part) + + # 添加 HTML 內容 + html_part = MIMEText(html_content, 'html', 'utf-8') + msg.attach(html_part) + + # 發送郵件 + server = self._create_smtp_connection() + if not server: + return False + + server.send_message(msg) + server.quit() + + logger.info(f"Email sent successfully to {to_email}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {to_email}: {str(e)}") + return False + + def send_job_completion_notification(self, job: TranslationJob) -> bool: + """發送任務完成通知""" + try: + if not job.user or not job.user.email: + logger.warning(f"No email address for job {job.job_uuid}") + return False + + # 準備郵件內容 + subject = f"📄 翻譯完成通知 - {job.original_filename}" + + # 計算處理時間 + processing_time = "" + if job.processing_started_at and job.completed_at: + duration = job.completed_at - job.processing_started_at + total_seconds = int(duration.total_seconds()) + + if total_seconds < 60: + processing_time = f"{total_seconds}秒" + elif total_seconds < 3600: + minutes = total_seconds // 60 + seconds = total_seconds % 60 + processing_time = f"{minutes}分{seconds}秒" + else: + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + processing_time = f"{hours}小時{minutes}分" + + # 生成下載連結(簡化版本) + download_links = [] + for lang in job.target_languages: + download_links.append(f"• {lang}: [下載翻譯檔案]") + + html_content = f""" + + + + + + + +
+
+

🎉 翻譯任務完成

+
+ +
+

親愛的 {job.user.display_name}

+ +

您的文件翻譯任務已成功完成!

+ +
+

📋 任務詳細資訊

+

檔案名稱: {job.original_filename}

+

任務編號: {job.job_uuid}

+

來源語言: {job.source_language}

+

目標語言: {', '.join(job.target_languages)}

+

處理時間: {processing_time}

+

完成時間: {job.completed_at.strftime('%Y-%m-%d %H:%M:%S') if job.completed_at else '未知'}

+ {f'

總成本: ${job.total_cost:.4f}

' if job.total_cost else ''} +
+ +
+

📥 下載翻譯檔案

+

請登入系統下載您的翻譯檔案:

+

{'
'.join(download_links)}

+

+ 注意: 翻譯檔案將在系統中保留 7 天,請及時下載。 +

+
+ +
+

感謝您使用 {self.app_name}!

+

如有任何問題,請聯繫系統管理員。

+
+
+ + +
+ + + """ + + # 純文字版本 + text_content = f""" + 翻譯任務完成通知 + + 親愛的 {job.user.display_name}, + + 您的文件翻譯任務已成功完成! + + 任務詳細資訊: + - 檔案名稱: {job.original_filename} + - 任務編號: {job.job_uuid} + - 來源語言: {job.source_language} + - 目標語言: {', '.join(job.target_languages)} + - 處理時間: {processing_time} + - 完成時間: {job.completed_at.strftime('%Y-%m-%d %H:%M:%S') if job.completed_at else '未知'} + + 請登入系統下載您的翻譯檔案。翻譯檔案將在系統中保留 7 天。 + + 感謝您使用 {self.app_name}! + + ---- + 此郵件由系統自動發送,請勿回覆。 + """ + + return self._send_email(job.user.email, subject, html_content, text_content) + + except Exception as e: + logger.error(f"Failed to send completion notification for job {job.job_uuid}: {str(e)}") + return False + + def send_job_failure_notification(self, job: TranslationJob) -> bool: + """發送任務失敗通知""" + try: + if not job.user or not job.user.email: + logger.warning(f"No email address for job {job.job_uuid}") + return False + + subject = f"⚠️ 翻譯失敗通知 - {job.original_filename}" + + html_content = f""" + + + + + + + +
+
+

❌ 翻譯任務失敗

+
+ +
+

親愛的 {job.user.display_name}

+ +

很抱歉,您的文件翻譯任務處理失敗。

+ +
+

📋 任務資訊

+

檔案名稱: {job.original_filename}

+

任務編號: {job.job_uuid}

+

重試次數: {job.retry_count}

+

錯誤訊息: {job.error_message or '未知錯誤'}

+

失敗時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

+
+ +
+

建議處理方式:

+
    +
  • 檢查檔案格式是否正確
  • +
  • 確認檔案沒有損壞
  • +
  • 稍後再次嘗試上傳
  • +
  • 如問題持續,請聯繫系統管理員
  • +
+
+ +
+

如需協助,請聯繫系統管理員。

+
+
+ + +
+ + + """ + + text_content = f""" + 翻譯任務失敗通知 + + 親愛的 {job.user.display_name}, + + 很抱歉,您的文件翻譯任務處理失敗。 + + 任務資訊: + - 檔案名稱: {job.original_filename} + - 任務編號: {job.job_uuid} + - 重試次數: {job.retry_count} + - 錯誤訊息: {job.error_message or '未知錯誤'} + + 建議處理方式: + 1. 檢查檔案格式是否正確 + 2. 確認檔案沒有損壞 + 3. 稍後再次嘗試上傳 + 4. 如問題持續,請聯繫系統管理員 + + 如需協助,請聯繫系統管理員。 + + ---- + 此郵件由 {self.app_name} 系統自動發送,請勿回覆。 + """ + + return self._send_email(job.user.email, subject, html_content, text_content) + + except Exception as e: + logger.error(f"Failed to send failure notification for job {job.job_uuid}: {str(e)}") + return False + + def send_admin_notification(self, subject: str, message: str, admin_emails: List[str] = None) -> bool: + """發送管理員通知""" + try: + if not admin_emails: + # 取得所有管理員郵件地址 + admin_users = User.get_admin_users() + admin_emails = [user.email for user in admin_users if user.email] + + if not admin_emails: + logger.warning("No admin email addresses found") + return False + + html_content = f""" + + + + + + + +
+
+

🔔 系統管理通知

+
+ +
+

系統管理員您好,

+ +
+

{subject}

+

{message}

+
+ +

發送時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

+
+ + +
+ + + """ + + success_count = 0 + for email in admin_emails: + if self._send_email(email, f"[管理通知] {subject}", html_content): + success_count += 1 + + return success_count > 0 + + except Exception as e: + logger.error(f"Failed to send admin notification: {str(e)}") + return False + + def test_smtp_connection(self) -> bool: + """測試 SMTP 連線""" + try: + server = self._create_smtp_connection() + if server: + server.quit() + return True + return False + except Exception as e: + logger.error(f"SMTP connection test failed: {str(e)}") + 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_completion_db_notification_direct(self, job: TranslationJob) -> Optional[Notification]: + """ + 直接發送任務完成的資料庫通知(不檢查狀態) + """ + try: + # 構建通知內容 + title = "翻譯任務完成" + message = f'您的文件「{job.original_filename}」已成功翻譯完成。' + + # 添加目標語言信息 + if job.target_languages: + languages = ', '.join(job.target_languages) + message += f" 目標語言: {languages}" + + message += " 您可以在任務列表中下載翻譯結果。" + + # 創建資料庫通知 + 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 \ No newline at end of file diff --git a/app/services/translation_service.py b/app/services/translation_service.py new file mode 100644 index 0000000..fa65283 --- /dev/null +++ b/app/services/translation_service.py @@ -0,0 +1,1227 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +翻譯服務 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import hashlib +import time +from pathlib import Path +from typing import List, Dict, Any, Optional, Tuple +from app.utils.logger import get_logger +from app.utils.exceptions import TranslationError, FileProcessingError +from app.services.dify_client import DifyClient +from app.services.document_processor import DocumentProcessor, Segment +from app.models.cache import TranslationCache +from app.models.job import TranslationJob +from app.utils.helpers import generate_filename, create_job_directory + +logger = get_logger(__name__) + + +class DocumentParser: + """文件解析器基類""" + + def __init__(self, file_path: str): + self.file_path = Path(file_path) + + if not self.file_path.exists(): + raise FileProcessingError(f"檔案不存在: {file_path}") + + def extract_text_segments(self) -> List[str]: + """提取文字片段""" + raise NotImplementedError + + def generate_translated_document(self, translations: Dict[str, List[str]], + target_language: str, output_dir: Path) -> str: + """生成翻譯後的文件""" + raise NotImplementedError + + +class DocxParser(DocumentParser): + """DOCX 文件解析器 - 使用增強的 DocumentProcessor""" + + def __init__(self, file_path: str): + super().__init__(file_path) + self.processor = DocumentProcessor() + + def extract_text_segments(self) -> List[str]: + """提取 DOCX 文件的文字片段 - 使用增強邏輯""" + try: + # 使用新的文檔處理器提取段落 + segments = self.processor.extract_docx_segments(str(self.file_path)) + + # 轉換為文字列表 + text_segments = [] + for seg in segments: + if seg.text.strip() and len(seg.text.strip()) > 3: + text_segments.append(seg.text) + + logger.info(f"Enhanced extraction: {len(text_segments)} text segments from DOCX") + return text_segments + + except Exception as e: + logger.error(f"Failed to extract text from DOCX: {str(e)}") + raise FileProcessingError(f"DOCX 文件解析失敗: {str(e)}") + + def extract_segments_with_context(self) -> List[Segment]: + """提取帶上下文的段落資訊""" + return self.processor.extract_docx_segments(str(self.file_path)) + + def generate_translated_document(self, translations: Dict[str, List[str]], + target_language: str, output_dir: Path) -> str: + """生成翻譯後的 DOCX 文件 - 使用增強的翻譯插入邏輯(從快取讀取)""" + try: + from sqlalchemy import text as sql_text + from app import db + + # 生成輸出檔名 + output_filename = generate_filename( + self.file_path.name, + 'translated', + 'translated', + target_language + ) + output_path = output_dir / output_filename + + # 提取段落資訊 + segments = self.extract_segments_with_context() + + # 建立翻譯映射 - 從快取讀取而非使用傳入的translations參數 + translation_map = {} + + logger.info(f"Building translation map for {len(segments)} segments in language {target_language}") + + for seg in segments: + # 從翻譯快取中查詢每個段落的翻譯 + result = db.session.execute(sql_text(""" + SELECT translated_text + FROM dt_translation_cache + WHERE source_text = :text AND target_language = :lang + ORDER BY created_at DESC + LIMIT 1 + """), {'text': seg.text, 'lang': target_language}) + + row = result.fetchone() + if row and row[0]: + translation_map[(target_language, seg.text)] = row[0] + logger.debug(f"Found translation for: {seg.text[:50]}...") + else: + logger.warning(f"No translation found for: {seg.text[:50]}...") + + logger.info(f"Translation map built with {len(translation_map)} mappings") + + # 使用增強的翻譯插入邏輯 + ok_count, skip_count = self.processor.insert_docx_translations( + str(self.file_path), + segments, + translation_map, + [target_language], + str(output_path) + ) + + logger.info(f"Enhanced translation: Generated {output_path} with {ok_count} insertions, {skip_count} skips") + return str(output_path) + + except Exception as e: + logger.error(f"Failed to generate translated DOCX: {str(e)}") + raise FileProcessingError(f"生成翻譯 DOCX 失敗: {str(e)}") + + +class DocParser(DocumentParser): + """DOC 文件解析器 - 需要先轉換為 DOCX""" + + def extract_text_segments(self) -> List[str]: + """提取 DOC 文件的文字片段 - 先轉換為 DOCX 再處理""" + try: + # 檢查是否有 Word COM 支援 + import tempfile + import os + + try: + import win32com.client as win32 + import pythoncom + _WIN32COM_AVAILABLE = True + except ImportError: + _WIN32COM_AVAILABLE = False + + if not _WIN32COM_AVAILABLE: + raise FileProcessingError("DOC 格式需要 Word COM 支援,請先手動轉換為 DOCX 格式或安裝 Microsoft Office") + + # 創建臨時 DOCX 文件 + temp_docx = None + try: + with tempfile.NamedTemporaryFile(suffix='.docx', delete=False) as tmp: + temp_docx = tmp.name + + # 使用 Word COM 轉換 DOC 到 DOCX (格式 16) + self._word_convert(str(self.file_path), temp_docx, 16) + + # 使用 DOCX 解析器處理轉換後的文件 + docx_parser = DocxParser(temp_docx) + segments = docx_parser.extract_text_segments() + + logger.info(f"Converted DOC to DOCX and extracted {len(segments)} segments") + return segments + + finally: + # 清理臨時文件 + if temp_docx and os.path.exists(temp_docx): + try: + os.remove(temp_docx) + except Exception: + pass + + except Exception as e: + logger.error(f"Failed to extract text from DOC file: {str(e)}") + raise FileProcessingError(f"DOC 文件解析失敗: {str(e)}") + + def _word_convert(self, input_path: str, output_path: str, target_format: int): + """使用 Word COM 轉換文件格式(移植自參考檔案)""" + try: + import win32com.client as win32 + import pythoncom + + pythoncom.CoInitialize() + try: + word = win32.Dispatch("Word.Application") + word.Visible = False + doc = word.Documents.Open(os.path.abspath(input_path)) + doc.SaveAs2(os.path.abspath(output_path), FileFormat=target_format) + doc.Close(False) + finally: + word.Quit() + pythoncom.CoUninitialize() + except Exception as e: + raise FileProcessingError(f"Word COM 轉換失敗: {str(e)}") + + def generate_translated_document(self, translations: Dict[str, List[str]], + target_language: str, output_dir: Path) -> str: + """生成翻譯後的 DOC 文件 - 先轉為 DOCX 處理後輸出為 DOCX""" + try: + import tempfile + import os + + # 先轉換為 DOCX,然後使用 DOCX 處理邏輯 + temp_docx = None + try: + with tempfile.NamedTemporaryFile(suffix='.docx', delete=False) as tmp: + temp_docx = tmp.name + + # 轉換 DOC 到 DOCX + self._word_convert(str(self.file_path), temp_docx, 16) + + # 使用 DOCX 解析器生成翻譯文檔 + docx_parser = DocxParser(temp_docx) + + # 注意:最終輸出為 DOCX 格式,因為 DOC 格式較難直接處理 + output_filename = f"{self.file_path.stem}_{target_language}_translated.docx" + output_path = output_dir / output_filename + + result_path = docx_parser.generate_translated_document(translations, target_language, output_dir) + + logger.info(f"Generated translated DOC file (as DOCX): {result_path}") + return result_path + + finally: + # 清理臨時文件 + if temp_docx and os.path.exists(temp_docx): + try: + os.remove(temp_docx) + except Exception: + pass + + except Exception as e: + logger.error(f"Failed to generate translated DOC file: {str(e)}") + raise FileProcessingError(f"DOC 翻譯檔生成失敗: {str(e)}") + + +class ExcelParser(DocumentParser): + """Excel 文件解析器(XLSX/XLS)- 移植自參考檔案""" + + def extract_text_segments(self) -> List[str]: + """提取 Excel 文件的文字片段""" + try: + import openpyxl + from openpyxl.utils.exceptions import InvalidFileException + + # 載入工作簿(移植自參考檔案邏輯) + try: + wb = openpyxl.load_workbook(str(self.file_path), data_only=False) + wb_vals = openpyxl.load_workbook(str(self.file_path), data_only=True) + except InvalidFileException: + if self.file_path.suffix.lower() == '.xls': + raise FileProcessingError("XLS 格式需要先轉換為 XLSX 格式") + raise + except Exception: + wb_vals = None + + # 提取文字段落(完全按照參考檔案的邏輯) + segs = [] + for ws in wb.worksheets: + ws_vals = wb_vals[ws.title] if wb_vals and ws.title in wb_vals.sheetnames else None + max_row, max_col = ws.max_row, ws.max_column + + for r in range(1, max_row + 1): + for c in range(1, max_col + 1): + src_text = self._get_display_text_for_translation(ws, ws_vals, r, c) + if not src_text: + continue + if not self._should_translate(src_text, 'auto'): + continue + segs.append(src_text) + + # 去重保持順序 + unique_segments = [] + seen = set() + for seg in segs: + if seg not in seen: + unique_segments.append(seg) + seen.add(seg) + + logger.info(f"Extracted {len(unique_segments)} unique text segments from Excel file") + return unique_segments + + except Exception as e: + logger.error(f"Failed to extract text from Excel file: {str(e)}") + raise FileProcessingError(f"Excel 文件解析失敗: {str(e)}") + + def _get_display_text_for_translation(self, ws, ws_vals, r: int, c: int) -> Optional[str]: + """取得儲存格用於翻譯的顯示文字(完全移植自參考檔案)""" + val = ws.cell(row=r, column=c).value + if isinstance(val, str) and val.startswith("="): + if ws_vals is not None: + shown = ws_vals.cell(row=r, column=c).value + return shown if isinstance(shown, str) and shown.strip() else None + return None + if isinstance(val, str) and val.strip(): + return val + if ws_vals is not None: + shown = ws_vals.cell(row=r, column=c).value + if isinstance(shown, str) and shown.strip(): + return shown + return None + + def _should_translate(self, text: str, src_lang: str) -> bool: + """判斷文字是否需要翻譯(只要有字就翻譯)""" + text = text.strip() + + # 只要有字就翻譯 - 最小長度設為1 + if len(text) < 1: + return False + + # Skip pure numbers, dates, etc. + import re + if re.match(r'^[\d\s\.\-\:\/]+$', text): + return False + + # For auto-detect, translate if has CJK or meaningful text + if src_lang.lower() in ('auto', 'auto-detect'): + return self._has_cjk(text) or len(text) > 5 + + return True + + def _has_cjk(self, text: str) -> bool: + """檢查是否包含中日韓文字(移植自參考檔案)""" + for char in text: + if '\u4e00' <= char <= '\u9fff' or \ + '\u3400' <= char <= '\u4dbf' or \ + '\u20000' <= char <= '\u2a6df' or \ + '\u3040' <= char <= '\u309f' or \ + '\u30a0' <= char <= '\u30ff' or \ + '\uac00' <= char <= '\ud7af': + return True + return False + + def generate_translated_document(self, translations: Dict[str, List[str]], + target_language: str, output_dir: Path) -> str: + """生成翻譯後的 Excel 文件(使用翻譯快取確保正確映射)""" + try: + import openpyxl + from openpyxl.styles import Alignment + from openpyxl.comments import Comment + from sqlalchemy import text as sql_text + from app import db + + # 載入原始工作簿 + wb = openpyxl.load_workbook(str(self.file_path), data_only=False) + try: + wb_vals = openpyxl.load_workbook(str(self.file_path), data_only=True) + except Exception: + wb_vals = None + + # 建立翻譯映射 - 改用翻譯快取查詢,確保正確對應 + original_segments = self.extract_text_segments() + tmap = {} + + logger.info(f"Building translation map for {len(original_segments)} segments in language {target_language}") + + for original_text in original_segments: + # 從翻譯快取中查詢每個原文的翻譯 + # 使用聯合查詢,優先使用最早的翻譯記錄(原始DIFY翻譯) + normalized_text = original_text.replace('\n', ' ').replace('\r', ' ').strip() + result = db.session.execute(sql_text(""" + SELECT translated_text, created_at, 'exact' as match_type + FROM dt_translation_cache + WHERE source_text = :exact_text AND target_language = :lang + + UNION ALL + + SELECT translated_text, created_at, 'normalized' as match_type + FROM dt_translation_cache + WHERE REPLACE(REPLACE(TRIM(source_text), '\n', ' '), '\r', ' ') = :norm_text + AND target_language = :lang + AND source_text != :exact_text + + ORDER BY created_at ASC + LIMIT 1 + """), {'exact_text': original_text, 'norm_text': normalized_text, 'lang': target_language}) + + row = result.fetchone() + if row and row[0]: + tmap[original_text] = row[0] + logger.debug(f"Cache hit for Excel: {original_text[:30]}... -> {row[0][:30]}...") + else: + logger.warning(f"No translation found in cache for: {original_text[:50]}...") + + logger.info(f"Translation map built with {len(tmap)} mappings from cache") + + # 處理每個工作表(加入詳細調試日誌) + translation_count = 0 + skip_count = 0 + + for ws in wb.worksheets: + logger.info(f"Processing worksheet: {ws.title}") + ws_vals = wb_vals[ws.title] if wb_vals and ws.title in wb_vals.sheetnames else None + max_row, max_col = ws.max_row, ws.max_column + + for r in range(1, max_row + 1): + for c in range(1, max_col + 1): + cell_name = f"{openpyxl.utils.get_column_letter(c)}{r}" + src_text = self._get_display_text_for_translation(ws, ws_vals, r, c) + + if not src_text: + continue + + # 檢查是否需要翻譯 + should_translate = self._should_translate(src_text, 'auto') + if not should_translate: + logger.debug(f"Skip {cell_name}: '{src_text[:30]}...' (should not translate)") + skip_count += 1 + continue + + # 檢查翻譯映射 + if src_text not in tmap: + logger.warning(f"No translation mapping for {cell_name}: '{src_text[:30]}...'") + skip_count += 1 + continue + + val = ws.cell(row=r, column=c).value + is_formula = isinstance(val, str) and val.startswith("=") + translated_text = tmap[src_text] + + cell = ws.cell(row=r, column=c) + + if is_formula: + # 公式儲存格:添加註解 + txt_comment = f"翻譯: {translated_text}" + exist = cell.comment + if not exist or exist.text.strip() != txt_comment: + cell.comment = Comment(txt_comment, "translator") + logger.debug(f"Added comment to {cell_name}: {translated_text[:30]}...") + translation_count += 1 + else: + # 一般儲存格:單語言檔案只保留翻譯文,不包含原文 + # 檢查是否已經是預期的格式 + current_text = str(cell.value) if cell.value else "" + if current_text.strip() == translated_text.strip(): + logger.debug(f"Skip {cell_name}: already translated") + continue + + cell.value = translated_text # 只保留翻譯文 + logger.info(f"Translated {cell_name}: '{src_text[:20]}...' -> '{translated_text[:20]}...'") + translation_count += 1 + + # 設定自動換行(移植自參考檔案) + try: + if cell.alignment: + cell.alignment = Alignment( + horizontal=cell.alignment.horizontal, + vertical=cell.alignment.vertical, + wrap_text=True + ) + else: + cell.alignment = Alignment(wrap_text=True) + except Exception: + cell.alignment = Alignment(wrap_text=True) + + # 儲存翻譯後的檔案 + output_filename = f"{self.file_path.stem}_{target_language}_translated.xlsx" + output_path = output_dir / output_filename + wb.save(str(output_path)) + + logger.info(f"Excel translation completed: {translation_count} translations, {skip_count} skips") + logger.info(f"Generated translated Excel file: {output_path}") + return str(output_path) + + except Exception as e: + logger.error(f"Failed to generate translated Excel file: {str(e)}") + raise FileProcessingError(f"Excel 翻譯檔生成失敗: {str(e)}") + + +class PdfParser(DocumentParser): + """PDF 文件解析器(只讀)""" + + def extract_text_segments(self) -> List[str]: + """提取 PDF 文件的文字片段""" + try: + from PyPDF2 import PdfReader + + reader = PdfReader(str(self.file_path)) + text_segments = [] + + for page in reader.pages: + text = page.extract_text() + + # 簡單的句子分割 + sentences = text.split('.') + for sentence in sentences: + sentence = sentence.strip() + if sentence and len(sentence) > 10: + text_segments.append(sentence) + + logger.info(f"Extracted {len(text_segments)} text segments from PDF") + return text_segments + + except Exception as e: + logger.error(f"Failed to extract text from PDF: {str(e)}") + raise FileProcessingError(f"PDF 文件解析失敗: {str(e)}") + + def generate_translated_document(self, translations: Dict[str, List[str]], + target_language: str, output_dir: Path) -> str: + """生成翻譯文字檔(PDF 不支援直接編輯)""" + try: + translated_texts = translations.get(target_language, []) + + # 生成純文字檔案 + output_filename = f"{self.file_path.stem}_{target_language}_translated.txt" + output_path = output_dir / output_filename + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(f"翻譯結果 - {target_language}\n") + f.write("=" * 50 + "\n\n") + + for i, text in enumerate(translated_texts): + f.write(f"{i+1}. {text}\n\n") + + logger.info(f"Generated translated text file: {output_path}") + return str(output_path) + + except Exception as e: + logger.error(f"Failed to generate translated text file: {str(e)}") + raise FileProcessingError(f"生成翻譯文字檔失敗: {str(e)}") + + +class TranslationService: + """翻譯服務""" + + def __init__(self): + self.dify_client = DifyClient() + self.document_processor = DocumentProcessor() + + # 文件解析器映射 + self.parsers = { + '.docx': DocxParser, + '.doc': DocParser, # 需要先轉換為 DOCX + '.xlsx': ExcelParser, + '.xls': ExcelParser, # Excel 處理器會自動處理 XLS 轉換 + '.pdf': PdfParser, + # 其他格式可以稍後添加 + } + + def get_document_parser(self, file_path: str) -> DocumentParser: + """取得文件解析器""" + file_ext = Path(file_path).suffix.lower() + + parser_class = self.parsers.get(file_ext) + if not parser_class: + raise FileProcessingError(f"不支援的檔案格式: {file_ext}") + + return parser_class(file_path) + + def split_text_into_sentences(self, text: str, language: str = 'auto') -> List[str]: + """將文字分割成句子 - 使用增強的分句邏輯""" + return self.document_processor.split_text_into_sentences(text, language) + + def translate_excel_cell(self, text: str, source_language: str, + target_language: str, user_id: int = None, + job_id: int = None) -> str: + """ + Excel儲存格翻譯 - 整個儲存格作為一個單位翻譯,不進行切片 + """ + if not text or not text.strip(): + return "" + + # 檢查快取 - 整個儲存格內容 + cached_translation = TranslationCache.get_translation(text, source_language, target_language) + if cached_translation: + logger.debug(f"Excel cell cache hit: {text[:30]}...") + return cached_translation + + # 直接翻譯整個儲存格內容,不進行任何切片 + try: + result = self.dify_client.translate_text( + text=text, + source_language=source_language, + target_language=target_language, + user_id=user_id, + job_id=job_id + ) + + translated_text = result['translated_text'] + + # 儲存整個儲存格的翻譯到快取 + TranslationCache.save_translation( + text, source_language, target_language, translated_text + ) + + return translated_text + + except Exception as e: + logger.error(f"Failed to translate Excel cell: {text[:30]}... Error: {str(e)}") + # 翻譯失敗時返回失敗標記 + return f"【翻譯失敗|{target_language}】{text}" + + def translate_word_table_cell(self, text: str, source_language: str, + target_language: str, user_id: int = None, + job_id: int = None) -> str: + """ + Word表格儲存格翻譯 - 整個儲存格內容作為一個單位翻譯,不進行段落切片 + """ + if not text or not text.strip(): + return "" + + # 檢查快取 - 整個儲存格內容 + cached_translation = TranslationCache.get_translation(text, source_language, target_language) + if cached_translation: + logger.debug(f"Word table cell cache hit: {text[:30]}...") + return cached_translation + + # 直接翻譯整個儲存格內容,不進行任何段落切片 + try: + result = self.dify_client.translate_text( + text=text, + source_language=source_language, + target_language=target_language, + user_id=user_id, + job_id=job_id + ) + + translated_text = result['translated_text'] + + # 儲存整個儲存格的翻譯到快取 + TranslationCache.save_translation( + text, source_language, target_language, translated_text + ) + + return translated_text + + except Exception as e: + logger.error(f"Failed to translate Word table cell: {text[:30]}... Error: {str(e)}") + return f"【翻譯失敗|{target_language}】{text}" + + def translate_segment_with_sentences(self, text: str, source_language: str, + target_language: str, user_id: int = None, + job_id: int = None) -> str: + """ + 按段落翻譯,模仿成功版本的 translate_block_sentencewise 邏輯 + 對多行文字進行逐行、逐句翻譯,並重新組合成完整段落 + 僅用於Word文檔,Excel請使用 translate_excel_cell + """ + if not text or not text.strip(): + return "" + + # 檢查快取 - 先檢查整個段落的快取 + cached_whole = TranslationCache.get_translation(text, source_language, target_language) + if cached_whole: + logger.debug(f"Whole paragraph cache hit: {text[:30]}...") + return cached_whole + + # 按行處理 + out_lines = [] + all_successful = True + + for raw_line in text.split('\n'): + if not raw_line.strip(): + out_lines.append("") + continue + + # 分句處理 + sentences = self.document_processor.split_text_into_sentences(raw_line, source_language) + if not sentences: + sentences = [raw_line] + + translated_parts = [] + for sentence in sentences: + sentence = sentence.strip() + if not sentence: + continue + + # 檢查句子級快取 + cached_sentence = TranslationCache.get_translation(sentence, source_language, target_language) + if cached_sentence: + translated_parts.append(cached_sentence) + continue + + # 呼叫 Dify API 翻譯句子 + try: + result = self.dify_client.translate_text( + text=sentence, + source_language=source_language, + target_language=target_language, + user_id=user_id, + job_id=job_id + ) + + translated_sentence = result['translated_text'] + + # 儲存句子級快取 + TranslationCache.save_translation( + sentence, source_language, target_language, translated_sentence + ) + + translated_parts.append(translated_sentence) + + except Exception as e: + logger.error(f"Failed to translate sentence: {sentence[:30]}... Error: {str(e)}") + translated_parts.append(f"【翻譯失敗|{target_language}】{sentence}") + all_successful = False + + # 重新組合句子為一行 + out_lines.append(" ".join(translated_parts)) + + # 重新組合所有行 + final_result = "\n".join(out_lines) + + # 如果全部成功,儲存整個段落的快取 + if all_successful: + TranslationCache.save_translation(text, source_language, target_language, final_result) + + return final_result + + def translate_text_with_cache(self, text: str, source_language: str, + target_language: str, user_id: int = None, + job_id: int = None) -> str: + """帶快取的文字翻譯""" + + # 檢查快取 + cached_translation = TranslationCache.get_translation( + text, source_language, target_language + ) + + if cached_translation: + logger.debug(f"Cache hit for translation: {text[:50]}...") + return cached_translation + + # 呼叫 Dify API + try: + result = self.dify_client.translate_text( + text=text, + source_language=source_language, + target_language=target_language, + user_id=user_id, + job_id=job_id + ) + + translated_text = result['translated_text'] + + # 儲存到快取 + TranslationCache.save_translation( + text, source_language, target_language, translated_text + ) + + return translated_text + + except Exception as e: + logger.error(f"Translation failed for text: {text[:50]}... Error: {str(e)}") + raise TranslationError(f"翻譯失敗: {str(e)}") + + def translate_document(self, job_uuid: str) -> Dict[str, Any]: + """翻譯文件(主要入口點)- 使用增強的文檔處理邏輯""" + try: + # 取得任務資訊 + job = TranslationJob.query.filter_by(job_uuid=job_uuid).first() + if not job: + raise TranslationError(f"找不到任務: {job_uuid}") + + logger.info(f"Starting enhanced document translation: {job_uuid}") + + # 更新任務狀態 + job.update_status('PROCESSING', progress=0) + + # 使用增強的文檔處理器直接提取段落 + file_ext = Path(job.file_path).suffix.lower() + + if file_ext in ['.docx', '.doc']: + # 使用增強的 DOCX 處理邏輯 + segments = self.document_processor.extract_docx_segments(job.file_path) + logger.info(f"Enhanced extraction: Found {len(segments)} segments to translate") + + if not segments: + raise TranslationError("文件中未找到可翻譯的文字段落") + + # 使用成功版本的翻譯邏輯 - 直接按段落翻譯,不做複雜分割 + translatable_segments = [] + for seg in segments: + if self.document_processor.should_translate_text(seg.text, job.source_language): + translatable_segments.append(seg) + + logger.info(f"Found {len(translatable_segments)} segments to translate") + + # 批次翻譯 - 直接按原始段落翻譯 + translation_map = {} # 格式: (target_language, source_text) -> translated_text + total_segments = len(translatable_segments) + + for target_language in job.target_languages: + logger.info(f"Translating to {target_language}") + + for i, seg in enumerate(translatable_segments): + try: + # 根據段落類型選擇適當的翻譯方法 + if seg.kind == "table_cell": + # 表格儲存格使用整個儲存格為單位的翻譯方法 + translated = self.translate_word_table_cell( + text=seg.text, + source_language=job.source_language, + target_language=target_language, + user_id=job.user_id, + job_id=job.id + ) + else: + # 一般段落使用原有的句子切片方法 + translated = self.translate_segment_with_sentences( + text=seg.text, + source_language=job.source_language, + target_language=target_language, + user_id=job.user_id, + job_id=job.id + ) + + # 直接以原始段落文字為鍵儲存翻譯結果 + translation_map[(target_language, seg.text)] = translated + + # 更新進度 + progress = (i + 1) / total_segments * 100 / len(job.target_languages) + current_lang_index = job.target_languages.index(target_language) + total_progress = (current_lang_index * 100 + progress) / len(job.target_languages) + job.update_status('PROCESSING', progress=total_progress) + + # 短暫延遲避免過快請求 + time.sleep(0.1) + + except Exception as e: + logger.error(f"Failed to translate segment: {seg.text[:50]}... Error: {str(e)}") + # 翻譯失敗時保留原文 + translation_map[(target_language, seg.text)] = f"[翻譯失敗] {seg.text}" + + # 生成翻譯文件 + logger.info("Generating translated documents with enhanced insertion") + output_dir = Path(job.file_path).parent + output_files = {} + + for target_language in job.target_languages: + try: + # 生成輸出檔名 + output_filename = generate_filename( + Path(job.file_path).name, + 'translated', + 'translated', + target_language + ) + output_path = output_dir / output_filename + + # 使用增強的翻譯插入邏輯 + ok_count, skip_count = self.document_processor.insert_docx_translations( + job.file_path, + segments, + translation_map, + [target_language], + str(output_path) + ) + + output_files[target_language] = str(output_path) + + # 記錄翻譯檔案到資料庫 + file_size = Path(output_path).stat().st_size + job.add_translated_file( + language_code=target_language, + filename=Path(output_path).name, + file_path=str(output_path), + file_size=file_size + ) + + logger.info(f"Generated {target_language}: {ok_count} insertions, {skip_count} skips") + + except Exception as e: + logger.error(f"Failed to generate translated document for {target_language}: {str(e)}") + raise TranslationError(f"生成 {target_language} 翻譯文件失敗: {str(e)}") + + # 生成組合多語言檔案 - 包含所有翻譯在一個文件中 + if len(job.target_languages) > 1: + try: + # 生成組合檔案的檔名 + combined_filename = generate_filename( + Path(job.file_path).name, + 'translated', + 'combined', + 'multilang' + ) + combined_output_path = output_dir / combined_filename + + # 使用新的組合翻譯插入方法 + combined_ok_count, combined_skip_count = self.document_processor.insert_docx_combined_translations( + job.file_path, + segments, + translation_map, + job.target_languages, + str(combined_output_path) + ) + + output_files['combined'] = str(combined_output_path) + + # 記錄組合翻譯檔案到資料庫 + file_size = Path(combined_output_path).stat().st_size + job.add_translated_file( + language_code='combined', + filename=Path(combined_output_path).name, + file_path=str(combined_output_path), + file_size=file_size + ) + + logger.info(f"Generated combined multi-language file: {combined_ok_count} insertions, {combined_skip_count} skips") + + except Exception as e: + logger.error(f"Failed to generate combined multi-language document: {str(e)}") + # 不要因為組合檔案失敗而讓整個任務失敗,只記錄警告 + logger.warning("Combined multi-language file generation failed, but individual files were successful") + + elif file_ext in ['.xlsx', '.xls']: + # Excel 文件使用儲存格為單位的翻譯邏輯 + logger.info(f"Using cell-based processing for Excel files") + parser = self.get_document_parser(job.file_path) + + # 提取儲存格文字內容(不進行句子切片) + cell_segments = parser.extract_text_segments() + + if not cell_segments: + raise TranslationError("Excel 文件中未找到可翻譯的文字") + + logger.info(f"Found {len(cell_segments)} cell segments to translate") + + # 批次翻譯 - 使用儲存格為單位的翻譯方法 + translation_results = {} + total_segments = len(cell_segments) + + for target_language in job.target_languages: + logger.info(f"Translating Excel cells to {target_language}") + translated_cells = [] + + for i, cell_text in enumerate(cell_segments): + try: + # 使用新的儲存格翻譯方法(整個儲存格作為單位) + translated = self.translate_excel_cell( + text=cell_text, + source_language=job.source_language, + target_language=target_language, + user_id=job.user_id, + job_id=job.id + ) + translated_cells.append(translated) + + # 更新進度 + progress = (i + 1) / total_segments * 100 / len(job.target_languages) + current_lang_index = job.target_languages.index(target_language) + total_progress = (current_lang_index * 100 + progress) / len(job.target_languages) + job.update_status('PROCESSING', progress=total_progress) + + time.sleep(0.1) + + except Exception as e: + logger.error(f"Failed to translate Excel cell: {cell_text[:50]}... Error: {str(e)}") + translated_cells.append(f"[翻譯失敗] {cell_text}") + + translation_results[target_language] = translated_cells + + # 生成翻譯文件 + output_dir = Path(job.file_path).parent + output_files = {} + + for target_language, translations in translation_results.items(): + translation_mapping = {target_language: translations} + + output_file = parser.generate_translated_document( + translations=translation_mapping, + target_language=target_language, + output_dir=output_dir + ) + + output_files[target_language] = output_file + + file_size = Path(output_file).stat().st_size + job.add_translated_file( + language_code=target_language, + filename=Path(output_file).name, + file_path=output_file, + file_size=file_size + ) + + # 生成組合多語言Excel檔案 + if len(job.target_languages) > 1: + try: + # 生成組合檔案的檔名 + combined_filename = generate_filename( + Path(job.file_path).name, + 'translated', + 'combined', + 'multilang' + ) + combined_output_path = output_dir / combined_filename + + # 為Excel組合檔案建立翻譯映射 + combined_translation_mapping = {} + for lang in job.target_languages: + combined_translation_mapping[lang] = translation_results[lang] + + # 使用修改過的generate_combined_excel_document方法 + combined_output_file = self._generate_combined_excel_document( + parser, + combined_translation_mapping, + job.target_languages, + combined_output_path + ) + + output_files['combined'] = combined_output_file + + # 記錄組合翻譯檔案到資料庫 + file_size = Path(combined_output_file).stat().st_size + job.add_translated_file( + language_code='combined', + filename=Path(combined_output_file).name, + file_path=combined_output_file, + file_size=file_size + ) + + logger.info(f"Generated combined multi-language Excel file") + + except Exception as e: + logger.error(f"Failed to generate combined multi-language Excel document: {str(e)}") + logger.warning("Combined multi-language Excel file generation failed, but individual files were successful") + + else: + # 對於其他文件格式,使用原有邏輯 + logger.info(f"Using legacy sentence-based processing for {file_ext} files") + parser = self.get_document_parser(job.file_path) + + # 提取文字片段 + text_segments = parser.extract_text_segments() + + if not text_segments: + raise TranslationError("文件中未找到可翻譯的文字") + + # 分割成句子 + all_sentences = [] + for segment in text_segments: + sentences = self.split_text_into_sentences(segment, job.source_language) + all_sentences.extend(sentences) + + # 去重複 + unique_sentences = list(dict.fromkeys(all_sentences)) + logger.info(f"Found {len(unique_sentences)} unique sentences to translate") + + # 批次翻譯 + translation_results = {} + total_sentences = len(unique_sentences) + + for target_language in job.target_languages: + logger.info(f"Translating to {target_language}") + translated_sentences = [] + + for i, sentence in enumerate(unique_sentences): + try: + translated = self.translate_text_with_cache( + text=sentence, + source_language=job.source_language, + target_language=target_language, + user_id=job.user_id, + job_id=job.id + ) + translated_sentences.append(translated) + + # 更新進度 + progress = (i + 1) / total_sentences * 100 / len(job.target_languages) + current_lang_index = job.target_languages.index(target_language) + total_progress = (current_lang_index * 100 + progress) / len(job.target_languages) + job.update_status('PROCESSING', progress=total_progress) + + time.sleep(0.1) + + except Exception as e: + logger.error(f"Failed to translate sentence: {sentence[:50]}... Error: {str(e)}") + translated_sentences.append(f"[翻譯失敗] {sentence}") + + translation_results[target_language] = translated_sentences + + # 生成翻譯文件 + output_dir = Path(job.file_path).parent + output_files = {} + + for target_language, translations in translation_results.items(): + translation_mapping = {target_language: translations} + + output_file = parser.generate_translated_document( + translations=translation_mapping, + target_language=target_language, + output_dir=output_dir + ) + + output_files[target_language] = output_file + + file_size = Path(output_file).stat().st_size + job.add_translated_file( + language_code=target_language, + filename=Path(output_file).name, + file_path=output_file, + file_size=file_size + ) + + # 計算總成本 + total_cost = self._calculate_job_cost(job.id) + + # 更新任務狀態為完成 + job.update_status('COMPLETED', progress=100) + job.total_cost = total_cost + # 計算實際使用的 token 數(從 API 使用統計中獲取) + from sqlalchemy import func + from app.models.stats import APIUsageStats + from app import db + + actual_tokens = db.session.query( + func.sum(APIUsageStats.total_tokens) + ).filter_by(job_id=job.id).scalar() + + job.total_tokens = int(actual_tokens) if actual_tokens else 0 + + db.session.commit() + + logger.info(f"Enhanced document translation completed: {job_uuid}") + + return { + 'success': True, + 'job_uuid': job_uuid, + 'output_files': output_files, + 'total_sentences': len(texts_to_translate) if 'texts_to_translate' in locals() else len(unique_sentences) if 'unique_sentences' in locals() else 0, + 'total_cost': float(total_cost), + 'target_languages': job.target_languages + } + + except TranslationError: + raise + except Exception as e: + logger.error(f"Enhanced document translation failed: {job_uuid}. Error: {str(e)}") + raise TranslationError(f"文件翻譯失敗: {str(e)}") + + def _calculate_job_cost(self, job_id: int) -> float: + """計算任務總成本""" + from app import db + from sqlalchemy import func + from app.models.stats import APIUsageStats + + total_cost = db.session.query( + func.sum(APIUsageStats.cost) + ).filter_by(job_id=job_id).scalar() + + return float(total_cost) if total_cost else 0.0 + + def _generate_combined_excel_document(self, parser, translation_mapping: Dict[str, List[str]], + target_languages: List[str], output_path: Path) -> str: + """生成包含所有翻譯語言的組合Excel檔案""" + try: + import openpyxl + from openpyxl.styles import Alignment, Font + from sqlalchemy import text as sql_text + from app import db + + # 載入原始工作簿 + wb = openpyxl.load_workbook(str(parser.file_path), data_only=False) + try: + wb_vals = openpyxl.load_workbook(str(parser.file_path), data_only=True) + except Exception: + wb_vals = None + + # 取得原始文字段落以建立翻譯映射 + original_segments = parser.extract_text_segments() + combined_tmap = {} + + logger.info(f"Building combined translation map for {len(original_segments)} segments") + + for original_text in original_segments: + # 從翻譯快取中查詢所有語言的翻譯 + for target_lang in target_languages: + result = db.session.execute(sql_text(""" + SELECT translated_text + FROM dt_translation_cache + WHERE source_text = :text AND target_language = :lang + ORDER BY created_at ASC + LIMIT 1 + """), {'text': original_text, 'lang': target_lang}) + + row = result.fetchone() + if row and row[0]: + combined_tmap[(target_lang, original_text)] = row[0] + + logger.info(f"Built combined translation map with {len(combined_tmap)} mappings") + + # 處理每個工作表,插入組合翻譯 + for ws in wb.worksheets: + logger.info(f"Processing combined worksheet: {ws.title}") + ws_vals = wb_vals[ws.title] if wb_vals and ws.title in wb_vals.sheetnames else None + max_row, max_col = ws.max_row, ws.max_column + + for r in range(1, max_row + 1): + for c in range(1, max_col + 1): + cell = ws.cell(row=r, column=c) + src_text = parser._get_display_text_for_translation(ws, ws_vals, r, c) + + if not src_text or not parser._should_translate(src_text, 'auto'): + continue + + # 收集所有語言的翻譯 + translations = [] + for target_lang in target_languages: + if (target_lang, src_text) in combined_tmap: + translations.append(combined_tmap[(target_lang, src_text)]) + else: + translations.append(f"【翻譯缺失|{target_lang}】") + + # 組合翻譯文字:原文\n英文\n越南文 + if translations: + combined_text = src_text + '\n' + '\n'.join(translations) + + # 設置儲存格值 + cell.value = combined_text + cell.alignment = Alignment(wrap_text=True, vertical='top') + cell.font = Font(size=10) + + # 儲存組合檔案 + wb.save(str(output_path)) + + logger.info(f"Generated combined Excel file: {output_path}") + return str(output_path) + + except Exception as e: + logger.error(f"Failed to generate combined Excel document: {str(e)}") + raise FileProcessingError(f"組合 Excel 檔案生成失敗: {str(e)}") \ No newline at end of file diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py new file mode 100644 index 0000000..94f238c --- /dev/null +++ b/app/tasks/__init__.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Celery 任務模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from .translation import process_translation_job, cleanup_old_files + +__all__ = [ + 'process_translation_job', + 'cleanup_old_files' +] \ No newline at end of file diff --git a/app/tasks/translation.py b/app/tasks/translation.py new file mode 100644 index 0000000..5feb47b --- /dev/null +++ b/app/tasks/translation.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +翻譯相關 Celery 任務 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import os +import shutil +from datetime import datetime, timedelta +from pathlib import Path +from celery import Celery, current_task +from celery.schedules import crontab +from app import create_app, db + +logger = None + +def get_celery_instance(): + """取得 Celery 實例""" + app = create_app() + return app.celery + +# 建立 Celery 實例 +celery = get_celery_instance() + +# 初始化 logger +from app.utils.logger import get_logger +logger = get_logger(__name__) + +from app.models.job import TranslationJob +from app.models.log import SystemLog +from app.services.translation_service import TranslationService +from app.services.notification_service import NotificationService +from app.utils.exceptions import TranslationError + + +@celery.task(bind=True, max_retries=3) +def process_translation_job(self, job_id: int): + """處理翻譯任務""" + app = create_app() + + with app.app_context(): + try: + # 取得任務資訊 + job = TranslationJob.query.get(job_id) + if not job: + raise ValueError(f"Job {job_id} not found") + + logger.info(f"Starting translation job processing: {job.job_uuid}") + + # 記錄任務開始 + SystemLog.info( + 'tasks.translation', + f'Translation job started: {job.job_uuid}', + user_id=job.user_id, + job_id=job.id, + extra_data={ + 'filename': job.original_filename, + 'target_languages': job.target_languages, + 'retry_count': self.request.retries + } + ) + + # 建立翻譯服務 + translation_service = TranslationService() + + # 執行翻譯 + result = translation_service.translate_document(job.job_uuid) + + if result['success']: + logger.info(f"Translation job completed successfully: {job.job_uuid}") + + # 重新獲取任務以確保狀態是最新的 + db.session.refresh(job) + + # 發送完成通知 + try: + notification_service = NotificationService() + # 發送郵件通知 + notification_service.send_job_completion_notification(job) + # 發送資料庫通知 - 跳過狀態檢查,直接發送 + notification_service.send_job_completion_db_notification_direct(job) + except Exception as e: + logger.warning(f"Failed to send completion notification: {str(e)}") + + # 記錄完成日誌 + SystemLog.info( + 'tasks.translation', + f'Translation job completed: {job.job_uuid}', + user_id=job.user_id, + job_id=job.id, + extra_data={ + 'total_cost': result.get('total_cost', 0), + 'total_sentences': result.get('total_sentences', 0), + 'output_files': list(result.get('output_files', {}).keys()) + } + ) + else: + raise TranslationError(result.get('error', 'Unknown translation error')) + + except Exception as exc: + logger.error(f"Translation job failed: {job.job_uuid}. Error: {str(exc)}") + + with app.app_context(): + # 更新任務狀態 + job = TranslationJob.query.get(job_id) + if job: + job.error_message = str(exc) + job.retry_count = self.request.retries + 1 + + if self.request.retries < self.max_retries: + # 準備重試 + job.update_status('RETRY') + + # 計算重試延遲:30s, 60s, 120s + countdown = [30, 60, 120][self.request.retries] + + SystemLog.warning( + 'tasks.translation', + f'Translation job retry scheduled: {job.job_uuid} (attempt {self.request.retries + 2})', + user_id=job.user_id, + job_id=job.id, + extra_data={ + 'error': str(exc), + 'retry_count': self.request.retries + 1, + 'countdown': countdown + } + ) + + logger.info(f"Retrying translation job in {countdown}s: {job.job_uuid}") + raise self.retry(exc=exc, countdown=countdown) + + else: + # 重試次數用盡,標記失敗 + 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}', + user_id=job.user_id, + job_id=job.id, + extra_data={ + 'error': str(exc), + 'total_retries': self.request.retries + } + ) + + # 發送失敗通知 + try: + notification_service = NotificationService() + notification_service.send_job_failure_notification(job) + except Exception as e: + logger.warning(f"Failed to send failure notification: {str(e)}") + + logger.error(f"Translation job failed permanently: {job.job_uuid}") + + raise exc + + +@celery.task +def cleanup_old_files(): + """清理舊檔案(定期任務)""" + app = create_app() + + with app.app_context(): + try: + logger.info("Starting file cleanup task") + + upload_folder = Path(app.config.get('UPLOAD_FOLDER')) + retention_days = app.config.get('FILE_RETENTION_DAYS', 7) + cutoff_date = datetime.utcnow() - timedelta(days=retention_days) + + if not upload_folder.exists(): + logger.warning(f"Upload folder does not exist: {upload_folder}") + return + + deleted_files = 0 + deleted_dirs = 0 + total_size_freed = 0 + + # 遍歷上傳目錄中的所有 UUID 目錄 + for item in upload_folder.iterdir(): + if not item.is_dir(): + continue + + try: + # 檢查目錄的修改時間 + dir_mtime = datetime.fromtimestamp(item.stat().st_mtime) + + if dir_mtime < cutoff_date: + # 計算目錄大小 + dir_size = sum(f.stat().st_size for f in item.rglob('*') if f.is_file()) + + # 檢查是否還有相關的資料庫記錄 + job_uuid = item.name + job = TranslationJob.query.filter_by(job_uuid=job_uuid).first() + + if job: + # 檢查任務是否已完成且超過保留期 + if job.completed_at and job.completed_at < cutoff_date: + # 刪除目錄 + shutil.rmtree(item) + deleted_dirs += 1 + total_size_freed += dir_size + + logger.info(f"Cleaned up job directory: {job_uuid}") + + # 記錄清理日誌 + SystemLog.info( + 'tasks.cleanup', + f'Cleaned up files for completed job: {job_uuid}', + user_id=job.user_id, + job_id=job.id, + extra_data={ + 'files_size_mb': dir_size / (1024 * 1024), + 'retention_days': retention_days + } + ) + else: + # 沒有對應的資料庫記錄,直接刪除 + shutil.rmtree(item) + deleted_dirs += 1 + total_size_freed += dir_size + + logger.info(f"Cleaned up orphaned directory: {job_uuid}") + + except Exception as e: + logger.error(f"Failed to process directory {item}: {str(e)}") + continue + + # 記錄清理結果 + cleanup_result = { + 'deleted_directories': deleted_dirs, + 'total_size_freed_mb': total_size_freed / (1024 * 1024), + 'retention_days': retention_days, + 'cutoff_date': cutoff_date.isoformat() + } + + SystemLog.info( + 'tasks.cleanup', + f'File cleanup completed: {deleted_dirs} directories, {total_size_freed / (1024 * 1024):.2f} MB freed', + extra_data=cleanup_result + ) + + logger.info(f"File cleanup completed: {cleanup_result}") + + return cleanup_result + + except Exception as e: + logger.error(f"File cleanup task failed: {str(e)}") + + SystemLog.error( + 'tasks.cleanup', + f'File cleanup task failed: {str(e)}', + extra_data={'error': str(e)} + ) + + raise e + + +@celery.task +def send_daily_admin_report(): + """發送每日管理員報告""" + app = create_app() + + with app.app_context(): + try: + logger.info("Generating daily admin report") + + from app.models.stats import APIUsageStats + from app.services.notification_service import NotificationService + + # 取得昨日統計 + yesterday = datetime.utcnow() - timedelta(days=1) + daily_stats = APIUsageStats.get_daily_statistics(days=1) + + # 取得系統錯誤摘要 + error_summary = SystemLog.get_error_summary(days=1) + + # 準備報告內容 + if daily_stats: + yesterday_data = daily_stats[0] + subject = f"每日系統報告 - {yesterday_data['date']}" + + message = f""" + 昨日系統使用狀況: + • 翻譯任務: {yesterday_data['total_calls']} 個 + • 成功任務: {yesterday_data['successful_calls']} 個 + • 失敗任務: {yesterday_data['failed_calls']} 個 + • 總成本: ${yesterday_data['total_cost']:.4f} + • 總 Token 數: {yesterday_data['total_tokens']} + + 系統錯誤摘要: + • 錯誤數量: {error_summary['total_errors']} + + 請查看管理後台了解詳細資訊。 + """ + else: + subject = f"每日系統報告 - {yesterday.strftime('%Y-%m-%d')}" + message = "昨日無翻譯任務記錄。" + + # 發送管理員通知 + notification_service = NotificationService() + result = notification_service.send_admin_notification(subject, message) + + if result: + logger.info("Daily admin report sent successfully") + else: + logger.warning("Failed to send daily admin report") + + return result + + except Exception as e: + logger.error(f"Daily admin report task failed: {str(e)}") + raise e + + +# 定期任務設定 +@celery.on_after_configure.connect +def setup_periodic_tasks(sender, **kwargs): + """設定定期任務""" + + # 每日凌晨 2 點執行檔案清理 + sender.add_periodic_task( + crontab(hour=2, minute=0), + cleanup_old_files.s(), + name='cleanup-old-files-daily' + ) + + # 每日早上 8 點發送管理員報告 + sender.add_periodic_task( + crontab(hour=8, minute=0), + send_daily_admin_report.s(), + name='daily-admin-report' + ) + + diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..719cd69 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +工具模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from .decorators import login_required, admin_required +from .validators import validate_file, validate_languages +from .helpers import generate_filename, format_file_size +from .exceptions import ( + DocumentTranslatorError, + AuthenticationError, + ValidationError, + TranslationError, + FileProcessingError +) + +__all__ = [ + 'login_required', + 'admin_required', + 'validate_file', + 'validate_languages', + 'generate_filename', + 'format_file_size', + 'DocumentTranslatorError', + 'AuthenticationError', + 'ValidationError', + 'TranslationError', + 'FileProcessingError' +] \ No newline at end of file diff --git a/app/utils/decorators.py b/app/utils/decorators.py new file mode 100644 index 0000000..f699d7a --- /dev/null +++ b/app/utils/decorators.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +裝飾器模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +from functools import wraps +from flask import session, jsonify, g, current_app +from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt + + +def login_required(f): + """登入驗證裝飾器""" + @wraps(f) + def decorated_function(*args, **kwargs): + from app.utils.logger import get_logger + from flask import request + logger = get_logger(__name__) + + user_id = session.get('user_id') + + # 調試:記錄 session 檢查 + logger.info(f"🔐 [Session Check] Endpoint: {request.endpoint}, Method: {request.method}, URL: {request.url}") + logger.info(f"🔐 [Session Data] UserID: {user_id}, SessionData: {dict(session)}, SessionID: {session.get('_id', 'unknown')}") + + if not user_id: + logger.warning(f"❌ [Auth Failed] No user_id in session for {request.endpoint}") + return jsonify({ + 'success': False, + 'error': 'AUTHENTICATION_REQUIRED', + 'message': '請先登入' + }), 401 + + # 取得使用者資訊並設定到 g 物件 + from app.models import User + user = User.query.get(user_id) + if not user: + # 清除無效的 session + session.clear() + return jsonify({ + 'success': False, + 'error': 'USER_NOT_FOUND', + 'message': '使用者不存在' + }), 401 + + g.current_user = user + g.current_user_id = user.id + g.is_admin = user.is_admin + + return f(*args, **kwargs) + + return decorated_function + + +def jwt_login_required(f): + """JWT 登入驗證裝飾器""" + @wraps(f) + @jwt_required() + def decorated_function(*args, **kwargs): + from app.utils.logger import get_logger + from flask import request + logger = get_logger(__name__) + + try: + username = get_jwt_identity() + claims = get_jwt() + + # 設定到 g 物件供其他地方使用 + g.current_user_username = username + g.current_user_id = claims.get('user_id') + g.is_admin = claims.get('is_admin', False) + + logger.info(f"🔑 [JWT Auth] User: {username}, UserID: {claims.get('user_id')}, Admin: {claims.get('is_admin')}") + + except Exception as e: + logger.error(f"❌ [JWT Auth] JWT validation failed: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'AUTHENTICATION_REQUIRED', + 'message': '認證失效,請重新登入' + }), 401 + + return f(*args, **kwargs) + + return decorated_function + + +def admin_required(f): + """管理員權限裝飾器(使用JWT認證)""" + @wraps(f) + @jwt_required() + def decorated_function(*args, **kwargs): + from app.utils.logger import get_logger + from flask import request + logger = get_logger(__name__) + + try: + username = get_jwt_identity() + claims = get_jwt() + + # 設定到 g 物件供其他地方使用 + g.current_user_username = username + g.current_user_id = claims.get('user_id') + g.is_admin = claims.get('is_admin', False) + + logger.info(f"🔑 [JWT Admin Auth] User: {username}, UserID: {claims.get('user_id')}, Admin: {claims.get('is_admin')}") + + # 檢查管理員權限 + if not claims.get('is_admin', False): + logger.warning(f"❌ [Admin Auth] Permission denied for user: {username}") + return jsonify({ + 'success': False, + 'error': 'PERMISSION_DENIED', + 'message': '權限不足,需要管理員權限' + }), 403 + + # 驗證用戶是否存在且仍為管理員 + from app.models import User + user = User.query.get(claims.get('user_id')) + if not user: + logger.error(f"❌ [Admin Auth] User not found: {claims.get('user_id')}") + return jsonify({ + 'success': False, + 'error': 'USER_NOT_FOUND', + 'message': '使用者不存在' + }), 401 + + if not user.is_admin: + logger.warning(f"❌ [Admin Auth] User no longer admin: {username}") + return jsonify({ + 'success': False, + 'error': 'PERMISSION_DENIED', + 'message': '權限不足,需要管理員權限' + }), 403 + + # 設定完整用戶資訊 + g.current_user = user + + except Exception as e: + logger.error(f"❌ [Admin Auth] JWT validation failed: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'AUTHENTICATION_REQUIRED', + 'message': '認證失效,請重新登入' + }), 401 + + return f(*args, **kwargs) + + return decorated_function + + +def validate_json(required_fields=None): + """JSON 驗證裝飾器""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + from flask import request + + if not request.is_json: + return jsonify({ + 'success': False, + 'error': 'INVALID_CONTENT_TYPE', + 'message': '請求必須為 JSON 格式' + }), 400 + + data = request.get_json() + if not data: + return jsonify({ + 'success': False, + 'error': 'INVALID_JSON', + 'message': 'JSON 資料格式錯誤' + }), 400 + + # 檢查必要欄位 + if required_fields: + missing_fields = [field for field in required_fields if field not in data] + if missing_fields: + return jsonify({ + 'success': False, + 'error': 'MISSING_FIELDS', + 'message': f'缺少必要欄位: {", ".join(missing_fields)}' + }), 400 + + return f(*args, **kwargs) + + return decorated_function + return decorator + + +def rate_limit(max_requests=100, per_seconds=3600): + """簡單的速率限制裝飾器""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + from flask import request + import redis + import time + + try: + # 使用 Redis 進行速率限制 + redis_client = redis.from_url(current_app.config['REDIS_URL']) + + # 使用 IP 地址作為 key + client_id = request.remote_addr + key = f"rate_limit:{f.__name__}:{client_id}" + + current_time = int(time.time()) + window_start = current_time - per_seconds + + # 清理過期的請求記錄 + redis_client.zremrangebyscore(key, 0, window_start) + + # 取得當前窗口內的請求數 + current_requests = redis_client.zcard(key) + + if current_requests >= max_requests: + return jsonify({ + 'success': False, + 'error': 'RATE_LIMIT_EXCEEDED', + 'message': '請求過於頻繁,請稍後再試' + }), 429 + + # 記錄當前請求 + redis_client.zadd(key, {str(current_time): current_time}) + redis_client.expire(key, per_seconds) + + except Exception: + # 如果 Redis 不可用,不阻擋請求 + pass + + return f(*args, **kwargs) + + return decorated_function + return decorator \ No newline at end of file diff --git a/app/utils/exceptions.py b/app/utils/exceptions.py new file mode 100644 index 0000000..3fbf3f7 --- /dev/null +++ b/app/utils/exceptions.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +自定義例外模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + + +class DocumentTranslatorError(Exception): + """文件翻譯系統基礎例外""" + def __init__(self, message, error_code=None): + self.message = message + self.error_code = error_code + super().__init__(self.message) + + +class AuthenticationError(DocumentTranslatorError): + """認證相關例外""" + pass + + +class ValidationError(DocumentTranslatorError): + """驗證相關例外""" + pass + + +class TranslationError(DocumentTranslatorError): + """翻譯相關例外""" + pass + + +class FileProcessingError(DocumentTranslatorError): + """檔案處理相關例外""" + pass + + +class APIError(DocumentTranslatorError): + """API 相關例外""" + pass + + +class ConfigurationError(DocumentTranslatorError): + """配置相關例外""" + pass + + +class DatabaseError(DocumentTranslatorError): + """資料庫相關例外""" + pass \ No newline at end of file diff --git a/app/utils/helpers.py b/app/utils/helpers.py new file mode 100644 index 0000000..ba0816f --- /dev/null +++ b/app/utils/helpers.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +輔助工具模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import os +import uuid +import shutil +from pathlib import Path +from datetime import datetime +from werkzeug.utils import secure_filename +from flask import current_app + + +def generate_filename(original_filename, job_uuid, file_type='original', language_code=None): + """生成安全的檔案名稱""" + # 取得檔案副檔名 + file_ext = Path(original_filename).suffix.lower() + + # 清理原始檔名 + clean_name = Path(original_filename).stem + clean_name = secure_filename(clean_name)[:50] # 限制長度 + + if file_type == 'original': + return f"original_{clean_name}_{job_uuid[:8]}{file_ext}" + elif file_type == 'translated': + return f"translated_{clean_name}_{language_code}_{job_uuid[:8]}{file_ext}" + else: + return f"{file_type}_{clean_name}_{job_uuid[:8]}{file_ext}" + + +def create_job_directory(job_uuid): + """建立任務專用目錄""" + upload_folder = current_app.config.get('UPLOAD_FOLDER') + job_dir = Path(upload_folder) / job_uuid + + # 建立目錄 + job_dir.mkdir(parents=True, exist_ok=True) + + return job_dir + + +def save_uploaded_file(file_obj, job_uuid): + """儲存上傳的檔案""" + try: + # 建立任務目錄 + job_dir = create_job_directory(job_uuid) + + # 生成檔案名稱 + filename = generate_filename(file_obj.filename, job_uuid, 'original') + file_path = job_dir / filename + + # 儲存檔案 + file_obj.save(str(file_path)) + + # 取得檔案大小 + file_size = file_path.stat().st_size + + return { + 'success': True, + 'filename': filename, + 'file_path': str(file_path), + 'file_size': file_size + } + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + +def cleanup_job_directory(job_uuid): + """清理任務目錄""" + try: + upload_folder = current_app.config.get('UPLOAD_FOLDER') + job_dir = Path(upload_folder) / job_uuid + + if job_dir.exists() and job_dir.is_dir(): + shutil.rmtree(job_dir) + return True + + return False + + except Exception: + return False + + +def format_file_size(size_bytes): + """格式化檔案大小""" + if size_bytes == 0: + return "0 B" + + size_names = ["B", "KB", "MB", "GB", "TB"] + i = 0 + while size_bytes >= 1024 and i < len(size_names) - 1: + size_bytes /= 1024.0 + i += 1 + + return f"{size_bytes:.1f} {size_names[i]}" + + +def get_file_icon(file_extension): + """根據副檔名取得檔案圖示""" + icon_map = { + '.docx': 'file-word', + '.doc': 'file-word', + '.pptx': 'file-powerpoint', + '.ppt': 'file-powerpoint', + '.xlsx': 'file-excel', + '.xls': 'file-excel', + '.pdf': 'file-pdf' + } + + return icon_map.get(file_extension.lower(), 'file') + + +def calculate_processing_time(start_time, end_time=None): + """計算處理時間""" + if not start_time: + return None + + if not end_time: + end_time = datetime.utcnow() + + if isinstance(start_time, str): + start_time = datetime.fromisoformat(start_time.replace('Z', '+00:00')) + + if isinstance(end_time, str): + end_time = datetime.fromisoformat(end_time.replace('Z', '+00:00')) + + duration = end_time - start_time + + # 轉換為秒 + total_seconds = int(duration.total_seconds()) + + if total_seconds < 60: + return f"{total_seconds}秒" + elif total_seconds < 3600: + minutes = total_seconds // 60 + seconds = total_seconds % 60 + return f"{minutes}分{seconds}秒" + else: + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + return f"{hours}小時{minutes}分" + + +def generate_download_token(job_uuid, language_code, user_id): + """生成下載令牌""" + import hashlib + import time + + # 組合資料 + data = f"{job_uuid}:{language_code}:{user_id}:{int(time.time())}" + + # 加上應用程式密鑰 + secret_key = current_app.config.get('SECRET_KEY', 'default_secret') + data_with_secret = f"{data}:{secret_key}" + + # 生成 hash + token = hashlib.sha256(data_with_secret.encode()).hexdigest() + + return token + + +def verify_download_token(token, job_uuid, language_code, user_id, max_age=3600): + """驗證下載令牌""" + import time + + try: + # 取得當前時間戳 + current_time = int(time.time()) + + # 在有效時間範圍內嘗試匹配令牌 + for i in range(max_age): + timestamp = current_time - i + expected_token = generate_download_token_with_timestamp( + job_uuid, language_code, user_id, timestamp + ) + + if token == expected_token: + return True + + return False + + except Exception: + return False + + +def generate_download_token_with_timestamp(job_uuid, language_code, user_id, timestamp): + """使用指定時間戳生成下載令牌""" + import hashlib + + data = f"{job_uuid}:{language_code}:{user_id}:{timestamp}" + secret_key = current_app.config.get('SECRET_KEY', 'default_secret') + data_with_secret = f"{data}:{secret_key}" + + return hashlib.sha256(data_with_secret.encode()).hexdigest() + + +def get_supported_languages(): + """取得支援的語言列表""" + return { + 'auto': '自動偵測', + 'zh-CN': '簡體中文', + 'zh-TW': '繁體中文', + 'en': '英文', + 'ja': '日文', + 'ko': '韓文', + 'vi': '越南文', + 'th': '泰文', + 'id': '印尼文', + 'ms': '馬來文', + 'es': '西班牙文', + 'fr': '法文', + 'de': '德文', + 'ru': '俄文' + } + + +def parse_json_field(json_str): + """安全解析JSON欄位""" + import json + + if not json_str: + return None + + try: + if isinstance(json_str, str): + return json.loads(json_str) + return json_str + except (json.JSONDecodeError, TypeError): + return None + + +def format_datetime(dt, format_type='full'): + """格式化日期時間""" + if not dt: + return None + + if isinstance(dt, str): + try: + dt = datetime.fromisoformat(dt.replace('Z', '+00:00')) + except ValueError: + return dt + + if format_type == 'date': + return dt.strftime('%Y-%m-%d') + elif format_type == 'time': + return dt.strftime('%H:%M:%S') + elif format_type == 'short': + return dt.strftime('%Y-%m-%d %H:%M') + else: # full + return dt.strftime('%Y-%m-%d %H:%M:%S') + + +def create_response(success=True, data=None, message=None, error=None, error_code=None): + """建立統一的API回應格式""" + response = { + 'success': success + } + + if data is not None: + response['data'] = data + + if message: + response['message'] = message + + if error: + response['error'] = error_code or 'ERROR' + if not message: + response['message'] = error + + return response \ No newline at end of file diff --git a/app/utils/ldap_auth.py b/app/utils/ldap_auth.py new file mode 100644 index 0000000..ad1735e --- /dev/null +++ b/app/utils/ldap_auth.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LDAP 認證服務 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import time +from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES +from flask import current_app +from .logger import get_logger +from .exceptions import AuthenticationError + +logger = get_logger(__name__) + + +class LDAPAuthService: + """LDAP 認證服務""" + + def __init__(self): + self.config = current_app.config + self.server_url = self.config.get('LDAP_SERVER') + self.port = self.config.get('LDAP_PORT', 389) + self.use_ssl = self.config.get('LDAP_USE_SSL', False) + self.bind_user_dn = self.config.get('LDAP_BIND_USER_DN') + self.bind_password = self.config.get('LDAP_BIND_USER_PASSWORD') + self.search_base = self.config.get('LDAP_SEARCH_BASE') + self.login_attr = self.config.get('LDAP_USER_LOGIN_ATTR', 'userPrincipalName') + + def create_connection(self, retries=3): + """建立 LDAP 連線(帶重試機制)""" + for attempt in range(retries): + try: + server = Server( + self.server_url, + port=self.port, + use_ssl=self.use_ssl, + get_info=ALL_ATTRIBUTES + ) + + conn = Connection( + server, + user=self.bind_user_dn, + password=self.bind_password, + auto_bind=True, + raise_exceptions=True + ) + + logger.info("LDAP connection established successfully") + return conn + + except Exception as e: + logger.error(f"LDAP connection attempt {attempt + 1} failed: {str(e)}") + if attempt == retries - 1: + raise AuthenticationError(f"LDAP connection failed: {str(e)}") + time.sleep(1) + + return None + + def authenticate_user(self, username, password): + """驗證使用者憑證""" + try: + conn = self.create_connection() + if not conn: + raise AuthenticationError("Unable to connect to LDAP server") + + # 搜尋使用者 + search_filter = f"(&(objectClass=person)(objectCategory=person)({self.login_attr}={username}))" + + conn.search( + self.search_base, + search_filter, + SUBTREE, + attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName', 'department'] + ) + + if not conn.entries: + logger.warning(f"User not found: {username}") + raise AuthenticationError("帳號不存在") + + user_entry = conn.entries[0] + user_dn = user_entry.entry_dn + + # 驗證使用者密碼 + try: + user_conn = Connection( + conn.server, + user=user_dn, + password=password, + auto_bind=True, + raise_exceptions=True + ) + user_conn.unbind() + + # 返回使用者資訊 + user_info = { + 'username': str(user_entry.sAMAccountName) if user_entry.sAMAccountName else username, + 'display_name': str(user_entry.displayName) if user_entry.displayName else username, + 'email': str(user_entry.mail) if user_entry.mail else f"{username}@panjit.com.tw", + 'department': str(user_entry.department) if hasattr(user_entry, 'department') and user_entry.department else None, + 'user_principal_name': str(user_entry.userPrincipalName) if user_entry.userPrincipalName else username + } + + logger.info(f"User authenticated successfully: {username}") + return user_info + + except Exception as e: + logger.warning(f"Authentication failed for user {username}: {str(e)}") + raise AuthenticationError("密碼錯誤") + + except AuthenticationError: + raise + except Exception as e: + logger.error(f"LDAP authentication error: {str(e)}") + raise AuthenticationError(f"認證服務錯誤: {str(e)}") + + finally: + if 'conn' in locals() and conn: + conn.unbind() + + def search_users(self, search_term, limit=20): + """搜尋使用者""" + try: + conn = self.create_connection() + if not conn: + return [] + + # 建構搜尋過濾器 + search_filter = f"""(& + (objectClass=person) + (objectCategory=person) + (!(userAccountControl:1.2.840.113556.1.4.803:=2)) + (| + (displayName=*{search_term}*) + (mail=*{search_term}*) + (sAMAccountName=*{search_term}*) + (userPrincipalName=*{search_term}*) + ) + )""" + + # 移除多餘空白 + search_filter = ' '.join(search_filter.split()) + + conn.search( + self.search_base, + search_filter, + SUBTREE, + attributes=['sAMAccountName', 'displayName', 'mail', 'department'], + size_limit=limit + ) + + results = [] + for entry in conn.entries: + results.append({ + 'username': str(entry.sAMAccountName) if entry.sAMAccountName else '', + 'display_name': str(entry.displayName) if entry.displayName else '', + 'email': str(entry.mail) if entry.mail else '', + 'department': str(entry.department) if hasattr(entry, 'department') and entry.department else '' + }) + + logger.info(f"LDAP search found {len(results)} results for term: {search_term}") + return results + + except Exception as e: + logger.error(f"LDAP search error: {str(e)}") + return [] + finally: + if 'conn' in locals() and conn: + conn.unbind() + + def get_user_info(self, username): + """取得使用者詳細資訊""" + try: + conn = self.create_connection() + if not conn: + return None + + # 支援 sAMAccountName 和 userPrincipalName 格式 + if '@' in username: + search_filter = f"""(& + (objectClass=person) + (| + (userPrincipalName={username}) + (mail={username}) + ) + )""" + else: + search_filter = f"(&(objectClass=person)(sAMAccountName={username}))" + + # 移除多餘空白 + search_filter = ' '.join(search_filter.split()) + + conn.search( + self.search_base, + search_filter, + SUBTREE, + attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName', 'department'] + ) + + if not conn.entries: + return None + + entry = conn.entries[0] + return { + 'username': str(entry.sAMAccountName) if entry.sAMAccountName else username, + 'display_name': str(entry.displayName) if entry.displayName else username, + 'email': str(entry.mail) if entry.mail else f"{username}@panjit.com.tw", + 'department': str(entry.department) if hasattr(entry, 'department') and entry.department else None, + 'user_principal_name': str(entry.userPrincipalName) if entry.userPrincipalName else '' + } + + except Exception as e: + logger.error(f"Error getting user info for {username}: {str(e)}") + return None + finally: + if 'conn' in locals() and conn: + conn.unbind() + + def test_connection(self): + """測試 LDAP 連線(健康檢查用)""" + try: + conn = self.create_connection(retries=1) + if conn: + conn.unbind() + return True + return False + except Exception as e: + logger.error(f"LDAP connection test failed: {str(e)}") + return False \ No newline at end of file diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..97132c8 --- /dev/null +++ b/app/utils/logger.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +日誌管理模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import logging +import os +from pathlib import Path +from logging.handlers import RotatingFileHandler +from flask import current_app, has_request_context, request, g + + +def get_logger(name): + """取得指定名稱的日誌器""" + logger = logging.getLogger(name) + + # 避免重複設定 handler + if not logger.handlers: + setup_logger(logger) + + return logger + + +def setup_logger(logger): + """設定日誌器""" + if has_request_context() and current_app: + log_level = current_app.config.get('LOG_LEVEL', 'INFO') + log_file = current_app.config.get('LOG_FILE', 'logs/app.log') + else: + log_level = os.environ.get('LOG_LEVEL', 'INFO') + log_file = os.environ.get('LOG_FILE', 'logs/app.log') + + # 確保日誌目錄存在 + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + + # 設定日誌等級 + logger.setLevel(getattr(logging, log_level.upper())) + + # 建立格式化器 + formatter = logging.Formatter( + '%(asctime)s [%(levelname)s] %(name)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + # 檔案處理器(使用輪轉) + file_handler = RotatingFileHandler( + log_file, + maxBytes=10*1024*1024, # 10MB + backupCount=5, + encoding='utf-8' + ) + file_handler.setLevel(getattr(logging, log_level.upper())) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + # 控制台處理器 + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + +class DatabaseLogHandler(logging.Handler): + """資料庫日誌處理器""" + + def emit(self, record): + """發送日誌記錄到資料庫""" + try: + from app.models.log import SystemLog + + # 取得使用者和任務資訊(如果有的話) + user_id = None + job_id = None + extra_data = {} + + if has_request_context(): + user_id = g.get('current_user_id') + extra_data.update({ + 'method': request.method, + 'endpoint': request.endpoint, + 'url': request.url, + 'ip_address': request.remote_addr, + 'user_agent': request.headers.get('User-Agent') + }) + + # 儲存到資料庫 + SystemLog.log( + level=record.levelname, + module=record.name, + message=record.getMessage(), + user_id=user_id, + job_id=job_id, + extra_data=extra_data if extra_data else None + ) + + except Exception: + # 避免日誌記錄失敗影響主程序 + pass + + +def init_logging(app): + """初始化應用程式日誌""" + # 設定根日誌器 + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + + # 添加資料庫日誌處理器(僅對重要日誌) + if app.config.get('SQLALCHEMY_DATABASE_URI'): + db_handler = DatabaseLogHandler() + db_handler.setLevel(logging.WARNING) # 只記錄警告以上等級到資料庫 + root_logger.addHandler(db_handler) + + # 設定 Flask 應用日誌 + if not app.logger.handlers: + setup_logger(app.logger) + + # 設定第三方庫日誌等級 + logging.getLogger('werkzeug').setLevel(logging.WARNING) + logging.getLogger('urllib3').setLevel(logging.WARNING) + logging.getLogger('requests').setLevel(logging.WARNING) \ No newline at end of file diff --git a/app/utils/response.py b/app/utils/response.py new file mode 100644 index 0000000..7dc46d0 --- /dev/null +++ b/app/utils/response.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +API 響應處理工具 + +Author: PANJIT IT Team +Created: 2025-09-02 +""" + +from datetime import datetime +from typing import Dict, Any, List, Union +from app.utils.timezone import to_taiwan_time, format_taiwan_time + + +def convert_datetime_to_taiwan(data: Union[Dict, List, Any]) -> Union[Dict, List, Any]: + """遞迴轉換資料中的 datetime 欄位為台灣時間 + + Args: + data: 要轉換的資料(字典、列表或其他) + + Returns: + 轉換後的資料 + """ + if isinstance(data, dict): + result = {} + for key, value in data.items(): + if isinstance(value, datetime): + # 將 datetime 轉換為台灣時間的 ISO 字符串 + taiwan_dt = to_taiwan_time(value) + result[key] = taiwan_dt.isoformat() + elif key in ['created_at', 'updated_at', 'completed_at', 'processing_started_at', 'last_login', 'timestamp']: + # 特定的時間欄位 + if isinstance(value, str): + try: + # 嘗試解析 ISO 格式的時間字符串 + dt = datetime.fromisoformat(value.replace('Z', '+00:00')) + taiwan_dt = to_taiwan_time(dt) + result[key] = taiwan_dt.isoformat() + except: + result[key] = value + else: + result[key] = convert_datetime_to_taiwan(value) + else: + result[key] = convert_datetime_to_taiwan(value) + return result + elif isinstance(data, list): + return [convert_datetime_to_taiwan(item) for item in data] + else: + return data + + +def create_taiwan_response(success: bool = True, data: Any = None, message: str = '', + error: str = '', **kwargs) -> Dict[str, Any]: + """創建包含台灣時區轉換的 API 響應 + + Args: + success: 是否成功 + data: 響應資料 + message: 成功訊息 + error: 錯誤訊息 + **kwargs: 其他參數 + + Returns: + 包含台灣時區的響應字典 + """ + response = { + 'success': success, + 'timestamp': format_taiwan_time(datetime.now(), "%Y-%m-%d %H:%M:%S") + } + + if data is not None: + response['data'] = convert_datetime_to_taiwan(data) + + if message: + response['message'] = message + + if error: + response['error'] = error + + # 加入其他參數 + for key, value in kwargs.items(): + response[key] = convert_datetime_to_taiwan(value) + + return response \ No newline at end of file diff --git a/app/utils/timezone.py b/app/utils/timezone.py new file mode 100644 index 0000000..c000ae6 --- /dev/null +++ b/app/utils/timezone.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +時區工具函數 + +Author: PANJIT IT Team +Created: 2025-09-02 +""" + +from datetime import datetime, timezone, timedelta +from typing import Optional + +# 台灣時區 UTC+8 +TAIWAN_TZ = timezone(timedelta(hours=8)) + + +def now_taiwan() -> datetime: + """取得當前台灣時間(UTC+8)""" + return datetime.now(TAIWAN_TZ) + + +def now_utc() -> datetime: + """取得當前 UTC 時間""" + return datetime.now(timezone.utc) + + +def to_taiwan_time(dt: datetime) -> datetime: + """將 datetime 轉換為台灣時間 + + Args: + dt: datetime 物件(可能是 naive 或 aware) + + Returns: + 台灣時區的 datetime 物件 + """ + if dt is None: + return None + + # 如果是 naive datetime,假設為 UTC + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + # 轉換為台灣時區 + return dt.astimezone(TAIWAN_TZ) + + +def to_utc_time(dt: datetime) -> datetime: + """將 datetime 轉換為 UTC 時間 + + Args: + dt: datetime 物件(可能是 naive 或 aware) + + Returns: + UTC 時區的 datetime 物件 + """ + if dt is None: + return None + + # 如果是 naive datetime,假設為台灣時間 + if dt.tzinfo is None: + dt = dt.replace(tzinfo=TAIWAN_TZ) + + # 轉換為 UTC + return dt.astimezone(timezone.utc) + + +def format_taiwan_time(dt: datetime, format_str: str = "%Y-%m-%d %H:%M:%S") -> str: + """格式化台灣時間為字符串 + + Args: + dt: datetime 物件 + format_str: 格式化字符串 + + Returns: + 格式化後的時間字符串 + """ + if dt is None: + return "" + + taiwan_dt = to_taiwan_time(dt) + return taiwan_dt.strftime(format_str) + + +def parse_taiwan_time(time_str: str, format_str: str = "%Y-%m-%d %H:%M:%S") -> datetime: + """解析台灣時間字符串為 datetime + + Args: + time_str: 時間字符串 + format_str: 解析格式 + + Returns: + 台灣時區的 datetime 物件 + """ + naive_dt = datetime.strptime(time_str, format_str) + return naive_dt.replace(tzinfo=TAIWAN_TZ) + + +# 為了向後兼容,提供替代 datetime.utcnow() 的函數 +def utcnow() -> datetime: + """取得當前 UTC 時間(替代 datetime.utcnow()) + + 注意:新代碼建議使用 now_taiwan() 或 now_utc() + """ + return now_utc().replace(tzinfo=None) # 返回 naive UTC datetime 以保持兼容性 \ No newline at end of file diff --git a/app/utils/validators.py b/app/utils/validators.py new file mode 100644 index 0000000..d57dea1 --- /dev/null +++ b/app/utils/validators.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +驗證工具模組 + +Author: PANJIT IT Team +Created: 2024-01-28 +Modified: 2024-01-28 +""" + +import os +from pathlib import Path +from flask import current_app +from .exceptions import ValidationError + + +def validate_file(file_obj): + """驗證上傳的檔案""" + if not file_obj: + raise ValidationError("未選擇檔案", "NO_FILE") + + if not file_obj.filename: + raise ValidationError("檔案名稱為空", "NO_FILENAME") + + # 檢查檔案副檔名 + file_ext = Path(file_obj.filename).suffix.lower() + allowed_extensions = current_app.config.get('ALLOWED_EXTENSIONS', {'.docx', '.doc', '.pptx', '.xlsx', '.xls', '.pdf'}) + + if file_ext not in allowed_extensions: + raise ValidationError( + f"不支援的檔案類型: {file_ext},支援的格式: {', '.join(allowed_extensions)}", + "INVALID_FILE_TYPE" + ) + + # 檢查檔案大小 + max_size = current_app.config.get('MAX_CONTENT_LENGTH', 26214400) # 25MB + + # 取得檔案大小 + file_obj.seek(0, os.SEEK_END) + file_size = file_obj.tell() + file_obj.seek(0) + + if file_size > max_size: + raise ValidationError( + f"檔案大小超過限制 ({format_file_size(max_size)})", + "FILE_TOO_LARGE" + ) + + if file_size == 0: + raise ValidationError("檔案為空", "EMPTY_FILE") + + return { + 'filename': file_obj.filename, + 'file_extension': file_ext, + 'file_size': file_size, + 'valid': True + } + + +def validate_languages(source_language, target_languages): + """驗證語言設定""" + # 支援的語言列表 + supported_languages = { + 'auto': '自動偵測', + 'zh-CN': '簡體中文', + 'zh-TW': '繁體中文', + 'en': '英文', + 'ja': '日文', + 'ko': '韓文', + 'vi': '越南文', + 'th': '泰文', + 'id': '印尼文', + 'ms': '馬來文', + 'es': '西班牙文', + 'fr': '法文', + 'de': '德文', + 'ru': '俄文' + } + + # 驗證來源語言 + if source_language and source_language not in supported_languages: + raise ValidationError( + f"不支援的來源語言: {source_language}", + "INVALID_SOURCE_LANGUAGE" + ) + + # 驗證目標語言 + if not target_languages or not isinstance(target_languages, list): + raise ValidationError("必須指定至少一個目標語言", "NO_TARGET_LANGUAGES") + + if len(target_languages) == 0: + raise ValidationError("必須指定至少一個目標語言", "NO_TARGET_LANGUAGES") + + if len(target_languages) > 10: # 限制最多10個目標語言 + raise ValidationError("目標語言數量過多,最多支援10個", "TOO_MANY_TARGET_LANGUAGES") + + invalid_languages = [lang for lang in target_languages if lang not in supported_languages] + if invalid_languages: + raise ValidationError( + f"不支援的目標語言: {', '.join(invalid_languages)}", + "INVALID_TARGET_LANGUAGE" + ) + + # 檢查來源語言和目標語言是否有重疊 + if source_language and source_language != 'auto' and source_language in target_languages: + raise ValidationError( + "目標語言不能包含來源語言", + "SOURCE_TARGET_OVERLAP" + ) + + return { + 'source_language': source_language or 'auto', + 'target_languages': target_languages, + 'supported_languages': supported_languages, + 'valid': True + } + + +def validate_job_uuid(job_uuid): + """驗證任務UUID格式""" + import uuid + + if not job_uuid: + raise ValidationError("任務UUID不能為空", "INVALID_UUID") + + try: + uuid.UUID(job_uuid) + return True + except ValueError: + raise ValidationError("任務UUID格式錯誤", "INVALID_UUID") + + +def validate_pagination(page, per_page): + """驗證分頁參數""" + try: + page = int(page) if page else 1 + per_page = int(per_page) if per_page else 20 + except (ValueError, TypeError): + raise ValidationError("分頁參數必須為數字", "INVALID_PAGINATION") + + if page < 1: + raise ValidationError("頁數必須大於0", "INVALID_PAGE") + + if per_page < 1 or per_page > 100: + raise ValidationError("每頁項目數必須在1-100之間", "INVALID_PER_PAGE") + + return page, per_page + + +def format_file_size(size_bytes): + """格式化檔案大小顯示""" + if size_bytes == 0: + return "0 B" + + size_names = ["B", "KB", "MB", "GB", "TB"] + i = 0 + while size_bytes >= 1024 and i < len(size_names) - 1: + size_bytes /= 1024.0 + i += 1 + + return f"{size_bytes:.1f} {size_names[i]}" + + +def sanitize_filename(filename): + """清理檔案名稱,移除不安全字元""" + import re + + # 保留檔案名稱和副檔名 + name = Path(filename).stem + ext = Path(filename).suffix + + # 移除或替換不安全字元 + safe_name = re.sub(r'[^\w\s.-]', '_', name) + safe_name = re.sub(r'\s+', '_', safe_name) # 空白替換為底線 + safe_name = safe_name.strip('._') # 移除開頭結尾的點和底線 + + # 限制長度 + if len(safe_name) > 100: + safe_name = safe_name[:100] + + return f"{safe_name}{ext}" + + +def validate_date_range(start_date, end_date): + """驗證日期範圍""" + from datetime import datetime + + if start_date: + try: + start_date = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + except ValueError: + raise ValidationError("開始日期格式錯誤", "INVALID_START_DATE") + + if end_date: + try: + end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + except ValueError: + raise ValidationError("結束日期格式錯誤", "INVALID_END_DATE") + + if start_date and end_date and start_date > end_date: + raise ValidationError("開始日期不能晚於結束日期", "INVALID_DATE_RANGE") + + return start_date, end_date \ No newline at end of file diff --git a/app/websocket.py b/app/websocket.py new file mode 100644 index 0000000..48aebea --- /dev/null +++ b/app/websocket.py @@ -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 \ No newline at end of file diff --git a/celery_app.py b/celery_app.py new file mode 100644 index 0000000..9fd6c2d --- /dev/null +++ b/celery_app.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Celery Worker 入口點 +""" + +import os +import sys +from pathlib import Path + +# 添加專案根目錄到 Python 路徑 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +# 導入應用和創建Celery實例 +from app import create_app + +# 創建應用實例 +flask_app = create_app() + +# 導出Celery實例供worker使用 +celery = flask_app.celery + +# 重要:導入任務模組以確保任務被註冊 +from app.tasks import translation + +# 確保可以通過celery -A celery_app訪問 +__all__ = ['celery'] + +if __name__ == "__main__": + print("Celery app created successfully") + print(f"Flask app: {flask_app}") + print(f"Celery instance: {celery}") + print(f"Available tasks: {list(celery.tasks.keys())}") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..39eb271 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,85 @@ +services: + # Redis 服務 (Celery 後端和緩存) + redis: + image: redis:7-alpine + container_name: panjit-translator-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + command: redis-server --appendonly yes + + # 主應用服務 + app: + build: + context: . + dockerfile: Dockerfile + container_name: panjit-translator-app + ports: + - "12010:12010" + volumes: + - ./uploads:/app/uploads + - ./cache:/app/cache + - ./logs:/app/logs + depends_on: + - redis + environment: + - REDIS_URL=redis://redis:6379/0 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:12010/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Celery Worker 服務 + celery-worker: + build: + context: . + dockerfile: Dockerfile + container_name: panjit-translator-worker + volumes: + - ./uploads:/app/uploads + - ./cache:/app/cache + - ./logs:/app/logs + depends_on: + - redis + - app + environment: + - REDIS_URL=redis://redis:6379/0 + restart: unless-stopped + command: celery -A celery_app worker --loglevel=info --concurrency=4 + healthcheck: + test: ["CMD", "celery", "-A", "celery_app", "inspect", "ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Celery Beat 調度服務 (可選,如果需要定期任務) + celery-beat: + build: + context: . + dockerfile: Dockerfile + container_name: panjit-translator-beat + volumes: + - ./uploads:/app/uploads + - ./cache:/app/cache + - ./logs:/app/logs + depends_on: + - redis + - app + environment: + - REDIS_URL=redis://redis:6379/0 + restart: unless-stopped + command: celery -A celery_app beat --loglevel=info + +volumes: + redis_data: + driver: local + +networks: + default: + name: panjit-translator-network \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..35a6ada --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,18 @@ +# PANJIT Document Translator Frontend - Environment Template +# Copy this file to .env and modify the values as needed + +# Application Settings +VITE_APP_TITLE=PANJIT Document Translator +VITE_APP_VERSION=1.0.0 + +# API Configuration (Update these for production) +VITE_API_BASE_URL=http://127.0.0.1:5000/api/v1 +VITE_WS_BASE_URL=ws://127.0.0.1:5000 + +# File Upload Settings +VITE_MAX_FILE_SIZE=26214400 +VITE_ALLOWED_FILE_TYPES=.doc,.docx,.ppt,.pptx,.xls,.xlsx,.pdf + +# Development Settings +VITE_DEV_MODE=true +VITE_MOCK_API=false \ No newline at end of file diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..a1169fb --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,58 @@ +module.exports = { + root: true, + env: { + node: true, + browser: true, + es2022: true + }, + extends: [ + 'plugin:vue/vue3-essential', + 'eslint:recommended', + '@vue/eslint-config-prettier' + ], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module' + }, + plugins: [ + 'vue' + ], + rules: { + // Vue 相關規則 + 'vue/multi-word-component-names': 'off', + 'vue/no-unused-vars': 'error', + 'vue/component-name-in-template-casing': ['error', 'PascalCase', { + 'registeredComponentsOnly': false + }], + 'vue/component-definition-name-casing': ['error', 'PascalCase'], + 'vue/attribute-hyphenation': ['error', 'always'], + 'vue/v-on-event-hyphenation': ['error', 'always'], + + // JavaScript 規則 + 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', + 'no-unused-vars': ['error', { + 'vars': 'all', + 'args': 'after-used', + 'ignoreRestSiblings': false + }], + 'prefer-const': 'error', + 'no-var': 'error', + 'object-shorthand': 'error', + 'prefer-template': 'error', + + // 程式碼品質 + 'eqeqeq': ['error', 'always'], + 'curly': ['error', 'all'], + 'brace-style': ['error', '1tbs'], + 'comma-dangle': ['error', 'never'], + 'quotes': ['error', 'single', { 'avoidEscape': true }], + 'semi': ['error', 'never'] + }, + globals: { + defineProps: 'readonly', + defineEmits: 'readonly', + defineExpose: 'readonly', + withDefaults: 'readonly' + } +} \ No newline at end of file diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..1f20091 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,14 @@ +{ + "semi": false, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "none", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "lf", + "vueIndentScriptAndStyle": false +} \ No newline at end of file diff --git a/frontend/auto-imports.d.ts b/frontend/auto-imports.d.ts new file mode 100644 index 0000000..4ab159f --- /dev/null +++ b/frontend/auto-imports.d.ts @@ -0,0 +1,89 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] + const axios: typeof import('axios')['default'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const createPinia: typeof import('pinia')['createPinia'] + const customRef: typeof import('vue')['customRef'] + const default: typeof import('axios')['default'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const defineStore: typeof import('pinia')['defineStore'] + const effectScope: typeof import('vue')['effectScope'] + const getActivePinia: typeof import('pinia')['getActivePinia'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const mapActions: typeof import('pinia')['mapActions'] + const mapGetters: typeof import('pinia')['mapGetters'] + const mapState: typeof import('pinia')['mapState'] + const mapStores: typeof import('pinia')['mapStores'] + const mapWritableState: typeof import('pinia')['mapWritableState'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] + const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const setActivePinia: typeof import('pinia')['setActivePinia'] + const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const storeToRefs: typeof import('pinia')['storeToRefs'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useId: typeof import('vue')['useId'] + const useLink: typeof import('vue-router')['useLink'] + const useModel: typeof import('vue')['useModel'] + const useRoute: typeof import('vue-router')['useRoute'] + const useRouter: typeof import('vue-router')['useRouter'] + const useSlots: typeof import('vue')['useSlots'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..51f4e6c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,46 @@ + + + + + + + PANJIT Document Translator + + + + + +
+
+
+
+
+ + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ddc0c3e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "panjit-document-translator-frontend", + "private": true, + "version": "1.0.0", + "description": "PANJIT Document Translator Web System Frontend", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --ext vue,js,jsx,cjs,mjs,ts,tsx,cts,mts --fix", + "format": "prettier --write src/", + "serve": "vite preview" + }, + "dependencies": { + "vue": "^3.3.4", + "vue-router": "^4.2.4", + "pinia": "^2.1.6", + "element-plus": "^2.3.8", + "@element-plus/icons-vue": "^2.1.0", + "axios": "^1.4.0", + "socket.io-client": "^4.7.2", + "echarts": "^5.4.3", + "vue-echarts": "^6.6.0", + "dayjs": "^1.11.9", + "file-saver": "^2.0.5", + "nprogress": "^0.2.0", + "js-cookie": "^3.0.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.2.3", + "vite": "^4.4.0", + "sass": "^1.64.1", + "unplugin-auto-import": "^0.16.6", + "unplugin-vue-components": "^0.25.1", + "unplugin-element-plus": "^0.7.1", + "eslint": "^8.45.0", + "eslint-plugin-vue": "^9.15.1", + "eslint-config-prettier": "^8.8.0", + "@vue/eslint-config-prettier": "^8.0.0", + "prettier": "^3.0.0", + "vite-plugin-eslint": "^1.8.1" + } +} \ No newline at end of file diff --git a/frontend/public/panjit-logo.png b/frontend/public/panjit-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b12aa06778a9c3939cb80f9ad17b0b43ca7dd501 GIT binary patch literal 7006 zcmeHMc{JPYmq!<>wY+U|&3l_Ps?Yu~n6BXkWFjDT*MrkXlpq z5~W0KQA@OlB`HB9mXJ)|-#Ig9&iT!pGxP7v{Bh57&U5bfKKFa?=ehTDzu%V@W`=yc zBD@?N9DK$`238y#obbPe=OlZlzw-1K_HycxkwX{T9 z_ge3}Cfo%7Uiy;3iJAC_0Lf9q02sZ7D}e{o+s?0ZUi!Gs#s8Q8^)bj1 zctU=9t1xP@&foP*p-SdHrzYKBL{ku$f64=N7vz)H*H77-1sCc1W zg&;i!gAKvpxtU@!$@}WQR`@EQ4@`TV&J2$ZD3hhBRguTD@)aePEy3Vydd1i-my4cq zMw?kY=z^B^$~IdIC2b0_1hMcK^g(}hUAQ(eSDoPSfEstmJq!jNHc%vvI&j)s-*s^_ zYM}7VA0)XrXhD2dMZ;Ome693x_pW>{DQHPX+XhqFD^(U68>GW=RsF*>-~)B-b$42) ztQ%*~V+ywpZy9!f=FnI)dbsnv0q45-Os_FSa^okN%Ue(nS8jH+T!xf1BOQD(E_n1@ zp|auigheP=uzO7^$t5c}Db4KK3)|X`h-MK)s9?}HRb@9ZJ>Qnjm`-1ml%u}T_|$}d zI z^G?Bu!|9KL9?oHBX#Uq)BdyNePsor-YBSF4YlVvWCb1nMPmB;wnO)>G>%W*kJx8b) zP=-3LY}cUSc_tp>6Mgn~y6D>tk*$}f0~9_4*@K_X?S4gLRHg%dH!bLI($GlA51TN`7DjBo$gMzKcR{=Cmp!OYsLZ=G$wxyp#Zqg z$|rro?xBPR8TB)9OFn&!ib?e(CNchC!1n?9&D!`GYS7F6V-(uZg{MCes-{VuqXYILP1-A-r5Y68Lo*sCI-+Ca1+{zc6rpCUQKR`Q~l`N zTO(p%3r@HZnI|WErpn(oS9TVy7u5(Vl;E!kY4pxt%Kv6=PpWQ(rg zZChLgU*7D^dR|TbKgA5R9QeREP|5?vh^gX7%O|brkz(LdKQj5g$5OV9H4A`jt~p!ac7?sSaCaPJNBv_H=ud!{l+as zNuM7g8L{h1X%y zu@f|P)8n^Xc$gt*38{N4Ldd3Qg_*Dt=w(@U z2#wzj!Y3nWH64X6tnR%06E!+KH+I91RhRTteF~{WC1;}RoWPuFDT0n0%}QENiadqJ z`>WM=3N>0LCM=qZGSN3E$3*pbZqTD8=%j_Rzn{yK$J_S|%tTJsTPxLHmR6Z3L-a3y z;PqcZ{2T}CPej`iv{4AIzs~9@!=~jD8Y|ft*iX^+S*-cA-9n6&$`OpV%nOQuo9h^` zFXNaqADHa4IX+Y)sKe8#{2WI-TH6^-$k!;XE`hIKWXJ5v3pcI8WkHi}YdbIvq7f4v zLIc+GiV(Fk$Jp3mqlEss!im7m^`_zB7a|`^N-`ohbk1_#6uHT7X`HwfVs0P7hqr%HLQh}eRgemb*U^Kc>k3#uqpfQ$Y^^NF)q3NqpI;Hm z0QXs{EyvaY-62r$=9U6{?4glR_}K@2VoRCl4}NE&tl2S&77U_khQ(6_j*3XTv1D#7 z5YsTuyMrQ$Im(CLy1uOJnP6IzC=vz7J!3oUCI3dbrvB&JqDqIl$>9B+6&!1Wv@OYi z6q;{RFgq@^bC@)arh-Wz7xO5`Xad@VIOJv@kg0#H& zbWu?9xAK9H>7cPvf6^j1hN2Y>WP;*;;qUu#=bQ4-&}-~FWTc9^v&)zHO3Klzu(j3! z45y4tQ;u3fG`rGJX7ThH?ePGkn^Bt&k{8Wnq{n;IY;;qfPK@+IYS?Hy{@wNlC5$!4 z`a#}`r*OLhyONufSbKGc-7ba)A+AT!v=Gceb8}(wx^&XPk1T~sxQGH99dz5;JG&tg zN9iuZSw(IjiQKn!End0`4%HM&iHnnmuHiCK( zysV#Ltj>e!kVEgfm6)XoyR>7TnXj$^SswGO_0HnmtTb)>Z_<>P3ZP=A5^Nj17=@9c9 zzKg(!`h8M*yj#sy_XYxA{IPksJ&x)S~8!> zNN0U+B~jyd&FMuiM)p!z2M_)T5fJ?{gqRET5*jxW4{p1p8vmL?nnc#Yv*xpRScI1$ zLkCBA79$@!F(MHEyPaEH3#_`fa@v*YNNXx)HDB0R+!voebgHJnXim|t48I5GuJwJg z%vy(^b$rN;C`tDBO%}2v5T5uM7pYJKh2(viq+;gg;~Z7cT6~>|PL&C4ab0*H;*SiIVs5l?~Pzrfy!jz8KTsI7Ko*Y6=%sBHT^=)GI~>ePj4lSh-tC= zoQ?kLIeYiQ*3>Lb=BkuQhL|=T$E5aQ>ZlOIar!;)zq%z_+ZL2)RMu*MMyVN#L%>{}7u<;Q8>{_kk5eanAN(vE=3ns!|PLBe0 zst)$)k_>_1`(AJ_MOgrWD7r7a%E&_p+l|`rnUWTXTn9BzTUHwkfoWqa4e(LTFpU^j zC%CvMMNuDWRCynBrz~6?Cc&EN;+ROsY*QPb!E~PA(pI( z_cKK2_-{=L(weDYHMUQJg#;clrOBVE$*M^FQvjgmPT`UWRmK)6ZrELJ16p zBB38k5eWX>iZN`Hpw~lQRw#DblJ$kYbn<6Xy+x9*z=3gn#aCbVuHKrCm+!;PZMsZbt*;yOC@svytSo z`mee>^$0}MJ|4df{op`w610fz03Tl(ee`w0%6bT>pci(7?MBV~kA)Y?kdctz<`tA? zWIjqx;EnWiLPpH2l71*L9%LU0v(e)Scwe1A`sG&xpeHccBJ~PiHw2gBqILzJWPwQZ z9$s*IZo=c1>U|~`+L{(NIzS2tzB(@QO4Ms1Ns!AP!`=JG*!kb#M=Oq9KG$ZM;nPqf z~sWY9I(vI0EkfR)Tu2*GX9KjRHrljcgJ2>I5@ZD=5&yc=m`1h0r1?l5A zk*K(7W_X_FP6!(#i|qL%7;?wo+4c5p7T02EN^uI%W#q#t-XT_8_2MQ$if!FYM}pL5 zXDh^R!tW}4)O%dih5rTbf*(SZhZ=2|+_{2h$D)=O!H(l&cKLobd45C1di{&BYNhHG zJD~~OOSX^fl+y-gWK{-t00m8&boH`dZN`v!`8eq4*e3y}rX_wS{l~GBAIPRxkJr1@ zoxk%lYvTlpbC*98{~Wm_2z&?sWWvK)1JStU7SeZHJIKodDE_Mrr`I|v??cJNmn8{) zR{JWwjq=^@bkKdTPy!GQcGOl|EzepB+6ZXVqFXo<^8`O%8+c@_@3boiJt~8q^Mh54 zc`;5v3dU(oN~v?jX1JXgOZlXYZ+mmAeFw|F*@q-zlPkv^@G|=%*Etdb5tEM$sHuyyUL-mwST)p(9NEJicjpuAr?3 zNQ|e1KO^V*;55OJ9Q6_Gpz_Eh{m;Zhk$Xxns$%#w7~J`%Q!)Kn)LqYjRbz;2KUUN3 zQ1ixG{P||gP|F}?t$cx6wJppmAVU~Ky$%S*Jzv15hruM2o{&}pi5yOzR_rT-Ec97H zX$C3AzNI%h&=Hzq)dQl1bB^PdM4gWL0~*omX%4!jOM|kN(Waf(u<#{ZCe-ta#jb-6#bOJlC^El#zkVtTS=uFlD- z#C{Nz30aIG$WyPjhFwdiRYGfasNPK5nNMR)&p=vLWYK^apW>w(^)nzZQETqKD$b{^ zjfaF;`828uCS{1R9~~U|V=|QS+o5ht+ed!{l!7*=e1`c0>UD zfgAqJc1S<(Ck0zmU%lcAED_-{51XcvH@g z<412!u_?0OaKoT`eF{}Vy%b3F$xF7W8}c=$s)xzg{Rw8~YdET?pex1KC^9JlO_}@6 z9i98m3er2F9j?moQm{CxI=fK@1C&chBbt#NE#23fi$H_AIBebZ$ee7)I@p@} zrfQqz@t91QSAwASf#&9}J1TtGZf`}cQ8@_nsq#e=d95rMB<1Vq z*7g+Bg*p^rsl~1_Fm^Yxa4rD_cBWY_MPxM{xeV%?m44-PGTX3^Hoc08EJZ1DQ`8ZS zS9|=u@(KClw9bc>^wE)of?Lwwzv?SZc+{LUp7~FtRo56DNL2qeYCCRX1Gb82lY2b8 zO^vd0i9c=CwcLgOYXDn!JFM>Tx8w~*W9iw%p~l_BS^ z%i4&w=smh_*xPBiV*WFRd>AtcKMb@z1;01;%{7-=zK{t}ap0F!E|ji1%hh%bGZb-@ z6;ll@bl)-N?b!VBrulZyKER{yh+1_yVRVJLrPPCEf79xHW6Y;6Jg3txn{Sc2$xM1- zhJT+Vcry=hgl>KLm~#B1S@BBxs-Q?G^=h;B*-FP&+2i4;fwE-LUcPK>z zzuq7$awDCg=XU1D@@2(3kLF{a09br&3|0 z2$wNAo>;sc%xnz%G+Nqo9k=h-9}(G%&1L)KI8|;IhW-n7@2$9rR6bUsFID(QvFZ! z*R+doeYDJ8*I2dZ%Pih1Q5Jc=-KxhEq4UF`$XwAHN3wq<^hUHPYhNBy<(eB=LyE%2 zjXRzR+f?7ZWZkkW4Cu(%$)g?_Qd)%nD71U`UVLYEw=ueF&80PZzXR`rY>();*Ha^9 zvhuaMVApZkCxL#s(U3}4Qkb@})g{mJdaOMvCwON`v5NRD zSq!b~EAW^LXn_r5+eSz^c@uEggXG!iV7Fhxr4xtZ*4gVd2cCUEoptDpa7)wOU>K-; zxih9fF(a&1Mk&28t2lQCK0Jk~7#PcvT=q~aHjnzI&L&pRC)~)Lu_IoJDWv@iFCv{V zaf;{__l80kBgES~|6s#pW?G)!Eg!`0MVJ-hN)sX}q{?PNMUW{oC!%u~7ZBn%&!cL$!^UBf5si zCAwgrK0>^?zv_o8JF^^#A>NQAK@N8|z5I9T{%;ii|JVN;F=0W(G(-x5R%1VSlDI literal 0 HcmV?d00001 diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..496625d --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,95 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue new file mode 100644 index 0000000..02aed18 --- /dev/null +++ b/frontend/src/layouts/MainLayout.vue @@ -0,0 +1,407 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..1f717f8 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,49 @@ +import { createApp, nextTick } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import 'element-plus/theme-chalk/dark/css-vars.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' + +import App from './App.vue' +import router from './router' +import './style/main.scss' + +// 創建應用實例 +const app = createApp(App) + +// 註冊 Element Plus 圖標 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +// 使用插件 +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { + locale: zhCn +}) + +// 全局錯誤處理 +app.config.errorHandler = (err, vm, info) => { + console.error('全局錯誤處理:', err, info) +} + +// 隱藏載入畫面 +const hideLoading = () => { + const loading = document.getElementById('loading') + if (loading) { + loading.style.display = 'none' + } +} + +// 掛載應用 +app.mount('#app') + +// 應用載入完成後隱藏載入畫面 +nextTick(() => { + hideLoading() +}) + +export default app \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..102fa93 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,174 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' +import { ElMessage } from 'element-plus' +import NProgress from 'nprogress' +import 'nprogress/nprogress.css' + +// 配置 NProgress +NProgress.configure({ + showSpinner: false, + minimum: 0.1, + speed: 200 +}) + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/LoginView.vue'), + meta: { + title: '登入', + requiresAuth: false, + hideLayout: true + } + }, + { + path: '/', + name: 'Layout', + component: () => import('@/layouts/MainLayout.vue'), + redirect: '/home', + meta: { + requiresAuth: true + }, + children: [ + { + path: '/home', + name: 'Home', + component: () => import('@/views/HomeView.vue'), + meta: { + title: '首頁', + icon: 'House', + showInMenu: true + } + }, + { + path: '/upload', + name: 'Upload', + component: () => import('@/views/UploadView.vue'), + meta: { + title: '檔案上傳', + icon: 'Upload', + showInMenu: true + } + }, + { + path: '/jobs', + name: 'Jobs', + component: () => import('@/views/JobListView.vue'), + meta: { + title: '任務列表', + icon: 'List', + showInMenu: true + } + }, + { + path: '/history', + name: 'History', + component: () => import('@/views/HistoryView.vue'), + meta: { + title: '歷史記錄', + icon: 'Clock', + showInMenu: true + } + }, + { + path: '/profile', + name: 'Profile', + component: () => import('@/views/ProfileView.vue'), + meta: { + title: '個人設定', + icon: 'User' + } + }, + { + path: '/admin', + name: 'Admin', + component: () => import('@/views/AdminView.vue'), + meta: { + title: '管理後台', + icon: 'Setting', + requiresAdmin: true, + showInMenu: true + } + }, + { + path: '/admin/jobs', + name: 'AdminJobs', + component: () => import('@/views/AdminJobsView.vue'), + meta: { + title: '全部任務', + requiresAdmin: true + } + } + ] + }, + { + path: '/job/:uuid', + name: 'JobDetail', + component: () => import('@/views/JobDetailView.vue'), + meta: { + title: '任務詳情', + requiresAuth: true, + hideLayout: false + } + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/NotFoundView.vue'), + meta: { + title: '頁面不存在', + hideLayout: true + } + } +] + +const router = createRouter({ + history: createWebHistory(), + routes, + scrollBehavior(to, from, savedPosition) { + if (savedPosition) { + return savedPosition + } else { + return { top: 0 } + } + } +}) + +// 路由守衛 +router.beforeEach(async (to, from, next) => { + NProgress.start() + + const authStore = useAuthStore() + + // 設置頁面標題 + document.title = to.meta.title ? `${to.meta.title} - PANJIT Document Translator` : 'PANJIT Document Translator' + + // 檢查是否需要認證 + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + ElMessage.warning('請先登入') + next('/login') + return + } + + // 檢查管理員權限 + if (to.meta.requiresAdmin && !authStore.isAdmin) { + ElMessage.error('無權限存取此頁面') + next('/home') + return + } + + // 如果已經登入且訪問登入頁面,重定向到首頁 + if (to.path === '/login' && authStore.isAuthenticated) { + next('/home') + return + } + + next() +}) + +router.afterEach(() => { + NProgress.done() +}) + +export default router \ No newline at end of file diff --git a/frontend/src/services/admin.js b/frontend/src/services/admin.js new file mode 100644 index 0000000..10d3e86 --- /dev/null +++ b/frontend/src/services/admin.js @@ -0,0 +1,139 @@ +import { request } from '@/utils/request' + +/** + * 管理員相關 API + */ +export const adminAPI = { + /** + * 取得系統統計資訊 + * @param {string} period - 統計週期 (day/week/month/year) + */ + getStats(period = 'month') { + return request.get('/admin/stats', { params: { period } }) + }, + + /** + * 取得所有使用者任務 + * @param {Object} params - 查詢參數 + */ + getAllJobs(params = {}) { + const defaultParams = { + page: 1, + per_page: 50, + user_id: 'all', + status: 'all' + } + return request.get('/admin/jobs', { params: { ...defaultParams, ...params } }) + }, + + /** + * 取得使用者列表 + */ + getUsers() { + return request.get('/admin/users') + }, + + /** + * 取得使用者詳細資訊 + * @param {number} userId - 使用者 ID + */ + getUserDetail(userId) { + return request.get(`/admin/users/${userId}`) + }, + + /** + * 更新使用者狀態 + * @param {number} userId - 使用者 ID + * @param {Object} data - 更新資料 + */ + updateUser(userId, data) { + return request.put(`/admin/users/${userId}`, data) + }, + + /** + * 取得 API 使用統計 + * @param {Object} params - 查詢參數 + */ + getApiUsageStats(params = {}) { + return request.get('/admin/api-usage', { params }) + }, + + /** + * 取得系統日誌 + * @param {Object} params - 查詢參數 + */ + getSystemLogs(params = {}) { + const defaultParams = { + page: 1, + per_page: 100, + level: 'all' + } + return request.get('/admin/logs', { params: { ...defaultParams, ...params } }) + }, + + /** + * 取得成本報表 + * @param {Object} params - 查詢參數 + */ + getCostReport(params = {}) { + return request.get('/admin/cost-report', { params }) + }, + + /** + * 匯出報表 + * @param {string} type - 報表類型 + * @param {Object} params - 查詢參數 + */ + exportReport(type, params = {}) { + return request.get(`/admin/export/${type}`, { + params, + responseType: 'blob' + }) + }, + + /** + * 系統健康檢查 + */ + getSystemHealth() { + return request.get('/admin/health') + }, + + /** + * 取得系統指標 + */ + getSystemMetrics() { + return request.get('/admin/metrics') + }, + + /** + * 清理舊檔案 + * @param {Object} options - 清理選項 + */ + cleanupOldFiles(options = {}) { + const defaultOptions = { + cleanup_files: true, + cleanup_logs: false, + cleanup_cache: false, + files_days: 7, + logs_days: 30, + cache_days: 90 + } + return request.post('/admin/maintenance/cleanup', { ...defaultOptions, ...options }) + }, + + /** + * 管理員取消任務 + * @param {string} jobUuid - 任務 UUID + */ + adminCancelJob(jobUuid) { + return request.post(`/admin/jobs/${jobUuid}/cancel`) + }, + + /** + * 管理員刪除任務 + * @param {string} jobUuid - 任務 UUID + */ + adminDeleteJob(jobUuid) { + return request.delete(`/admin/jobs/${jobUuid}`) + } +} \ No newline at end of file diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js new file mode 100644 index 0000000..39e4036 --- /dev/null +++ b/frontend/src/services/auth.js @@ -0,0 +1,44 @@ +import { request } from '@/utils/request' + +/** + * 認證相關 API + */ +export const authAPI = { + /** + * 使用者登入 + * @param {Object} credentials - 登入憑證 + * @param {string} credentials.username - AD 帳號 + * @param {string} credentials.password - 密碼 + */ + login(credentials) { + return request.post('/auth/login', credentials) + }, + + /** + * 使用者登出 + */ + logout() { + return request.post('/auth/logout') + }, + + /** + * 取得當前使用者資訊 + */ + getCurrentUser() { + return request.get('/auth/me') + }, + + /** + * 檢查認證狀態 + */ + checkAuth() { + return request.get('/auth/check') + }, + + /** + * 刷新認證狀態 + */ + refresh() { + return request.post('/auth/refresh') + } +} \ No newline at end of file diff --git a/frontend/src/services/jobs.js b/frontend/src/services/jobs.js new file mode 100644 index 0000000..6616813 --- /dev/null +++ b/frontend/src/services/jobs.js @@ -0,0 +1,113 @@ +import { request, uploadRequest } from '@/utils/request' + +/** + * 任務相關 API + */ +export const jobsAPI = { + /** + * 上傳檔案 + * @param {FormData} formData - 包含檔案和設定的表單資料 + */ + uploadFile(formData) { + return uploadRequest.post('/files/upload', formData, { + onUploadProgress: (progressEvent) => { + // 上傳進度回調在外部處理 + if (formData.onUploadProgress) { + formData.onUploadProgress(progressEvent) + } + } + }) + }, + + /** + * 取得使用者任務列表 + * @param {Object} params - 查詢參數 + * @param {number} params.page - 頁數 + * @param {number} params.per_page - 每頁數量 + * @param {string} params.status - 任務狀態篩選 + */ + getJobs(params = {}) { + const defaultParams = { + page: 1, + per_page: 20, + status: 'all' + } + return request.get('/jobs', { params: { ...defaultParams, ...params } }) + }, + + /** + * 取得任務詳細資訊 + * @param {string} jobUuid - 任務 UUID + */ + getJobDetail(jobUuid) { + return request.get(`/jobs/${jobUuid}`) + }, + + /** + * 重試失敗任務 + * @param {string} jobUuid - 任務 UUID + */ + retryJob(jobUuid) { + return request.post(`/jobs/${jobUuid}/retry`) + }, + + /** + * 取消任務 + * @param {string} jobUuid - 任務 UUID + */ + cancelJob(jobUuid) { + return request.post(`/jobs/${jobUuid}/cancel`) + }, + + /** + * 刪除任務 + * @param {string} jobUuid - 任務 UUID + */ + deleteJob(jobUuid) { + return request.delete(`/jobs/${jobUuid}`) + } +} + +/** + * 檔案相關 API + */ +export const filesAPI = { + /** + * 下載翻譯檔案 + * @param {string} jobUuid - 任務 UUID + * @param {string} languageCode - 語言代碼 + */ + downloadFile(jobUuid, languageCode) { + return request.get(`/files/${jobUuid}/download/${languageCode}`, { + responseType: 'blob' + }) + }, + + /** + * 批量下載檔案 + * @param {string} jobUuid - 任務 UUID + */ + downloadAllFiles(jobUuid) { + return request.get(`/files/${jobUuid}/download/batch`, { + responseType: 'blob' + }) + }, + + /** + * 下載合併檔案 + * @param {string} jobUuid - 任務 UUID + */ + downloadCombineFile(jobUuid) { + return request.get(`/files/${jobUuid}/download/combine`, { + responseType: 'blob' + }) + }, + + /** + * 取得檔案資訊 + * @param {string} jobUuid - 任務 UUID + */ + getFileInfo(jobUuid) { + return request.get(`/files/${jobUuid}/info`) + } +} \ No newline at end of file diff --git a/frontend/src/services/notification.js b/frontend/src/services/notification.js new file mode 100644 index 0000000..9d94bb0 --- /dev/null +++ b/frontend/src/services/notification.js @@ -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.post(`/notifications/${notificationId}/read`) + }, + + /** + * 標記所有通知為已讀 + */ + markAllAsRead() { + return request.post('/notifications/read-all') + }, + + /** + * 刪除通知 + * @param {string} notificationId - 通知ID + */ + deleteNotification(notificationId) { + return request.delete(`/notifications/${notificationId}`) + }, + + /** + * 清空所有已讀通知 + */ + clearNotifications() { + return request.delete('/notifications/clear') + }, + + /** + * 創建測試通知(開發用) + */ + createTestNotification() { + return request.post('/notifications/test') + } +} \ No newline at end of file diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js new file mode 100644 index 0000000..513d097 --- /dev/null +++ b/frontend/src/stores/admin.js @@ -0,0 +1,311 @@ +import { defineStore } from 'pinia' +import { adminAPI } from '@/services/admin' +import { ElMessage } from 'element-plus' + +export const useAdminStore = defineStore('admin', { + state: () => ({ + stats: null, + users: [], + allJobs: [], + systemLogs: [], + apiUsageStats: [], + costReport: null, + systemHealth: null, + systemMetrics: null, + loading: false, + pagination: { + page: 1, + per_page: 50, + total: 0, + pages: 0 + } + }), + + getters: { + // 系統概覽統計 + overviewStats: (state) => state.stats?.overview || {}, + + // 每日統計資料 + dailyStats: (state) => state.stats?.daily_stats || [], + + // 使用者排名 + userRankings: (state) => state.stats?.user_rankings || [], + + // 活躍使用者數量 + activeUsersCount: (state) => state.stats?.overview?.active_users_today || 0, + + // 總成本 + totalCost: (state) => state.stats?.overview?.total_cost || 0, + + // 系統是否健康 + isSystemHealthy: (state) => { + const status = state.systemHealth?.status + return status === 'healthy' || status === 'warning' + } + }, + + actions: { + /** + * 取得系統統計資訊 + * @param {string} period - 統計週期 + */ + async fetchStats(period = 'month') { + try { + this.loading = true + + const response = await adminAPI.getStats(period) + + if (response.success) { + this.stats = response.data + return response.data + } + } catch (error) { + console.error('取得統計資訊失敗:', error) + ElMessage.error('載入統計資訊失敗') + } finally { + this.loading = false + } + }, + + /** + * 取得所有使用者任務 + * @param {Object} params - 查詢參數 + */ + async fetchAllJobs(params = {}) { + try { + this.loading = true + + const response = await adminAPI.getAllJobs(params) + + if (response.success) { + this.allJobs = response.data.jobs + this.pagination = response.data.pagination + return response.data + } + } catch (error) { + console.error('取得所有任務失敗:', error) + ElMessage.error('載入任務資料失敗') + } finally { + this.loading = false + } + }, + + /** + * 取得使用者列表 + */ + async fetchUsers() { + try { + const response = await adminAPI.getUsers() + + if (response.success) { + this.users = response.data.users + return response.data + } + } catch (error) { + console.error('取得使用者列表失敗:', error) + ElMessage.error('載入使用者資料失敗') + } + }, + + /** + * 更新使用者狀態 + * @param {number} userId - 使用者 ID + * @param {Object} data - 更新資料 + */ + async updateUser(userId, data) { + try { + const response = await adminAPI.updateUser(userId, data) + + if (response.success) { + // 更新本地使用者資料 + const userIndex = this.users.findIndex(user => user.id === userId) + if (userIndex !== -1) { + this.users[userIndex] = { ...this.users[userIndex], ...response.data } + } + + ElMessage.success('使用者資料更新成功') + return response.data + } + } catch (error) { + console.error('更新使用者失敗:', error) + ElMessage.error('更新使用者失敗') + } + }, + + /** + * 取得 API 使用統計 + * @param {Object} params - 查詢參數 + */ + async fetchApiUsageStats(params = {}) { + try { + const response = await adminAPI.getApiUsageStats(params) + + if (response.success) { + this.apiUsageStats = response.data.stats + return response.data + } + } catch (error) { + console.error('取得 API 使用統計失敗:', error) + ElMessage.error('載入 API 統計失敗') + } + }, + + /** + * 取得系統日誌 + * @param {Object} params - 查詢參數 + */ + async fetchSystemLogs(params = {}) { + try { + this.loading = true + + const response = await adminAPI.getSystemLogs(params) + + if (response.success) { + this.systemLogs = response.data.logs + return response.data + } + } catch (error) { + console.error('取得系統日誌失敗:', error) + ElMessage.error('載入系統日誌失敗') + } finally { + this.loading = false + } + }, + + /** + * 取得成本報表 + * @param {Object} params - 查詢參數 + */ + async fetchCostReport(params = {}) { + try { + const response = await adminAPI.getCostReport(params) + + if (response.success) { + this.costReport = response.data + return response.data + } + } catch (error) { + console.error('取得成本報表失敗:', error) + ElMessage.error('載入成本報表失敗') + } + }, + + /** + * 匯出報表 + * @param {string} type - 報表類型 + * @param {Object} params - 查詢參數 + */ + async exportReport(type, params = {}) { + try { + const response = await adminAPI.exportReport(type, params) + + // 下載檔案 + const blob = new Blob([response], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${type}_report_${new Date().toISOString().slice(0, 10)}.xlsx` + link.click() + + window.URL.revokeObjectURL(url) + ElMessage.success('報表匯出成功') + } catch (error) { + console.error('匯出報表失敗:', error) + ElMessage.error('匯出報表失敗') + } + }, + + /** + * 取得系統健康狀態 + */ + async fetchSystemHealth() { + try { + console.log('開始獲取系統健康狀態...') + const response = await adminAPI.getSystemHealth() + console.log('健康檢查響應:', response) + + // 處理響應數據格式 + if (response.success && response.data) { + this.systemHealth = response.data + console.log('健康狀態設定為:', this.systemHealth) + } else if (response.status) { + // 直接返回狀態數據 + this.systemHealth = response + console.log('直接設定健康狀態為:', this.systemHealth) + } else { + // 預設為異常狀態 + this.systemHealth = { status: 'unhealthy', error: 'Invalid response format' } + console.log('設定預設異常狀態') + } + + return response + } catch (error) { + console.error('取得系統健康狀態失敗:', error) + console.error('錯誤詳情:', error.response?.data || error.message) + + // 根據錯誤類型設定不同狀態 + if (error.response?.status === 403) { + this.systemHealth = { status: 'unhealthy', error: '權限不足' } + } else if (error.response?.status === 401) { + this.systemHealth = { status: 'unhealthy', error: '需要登入' } + } else { + this.systemHealth = { status: 'unhealthy', error: '連接失敗' } + } + } + }, + + /** + * 取得系統指標 + */ + async fetchSystemMetrics() { + try { + const response = await adminAPI.getSystemMetrics() + + if (response.success && response.data) { + this.systemMetrics = response.data + return response.data + } else if (response.jobs) { + // 兼容舊格式 + this.systemMetrics = response + return response + } + } catch (error) { + console.error('取得系統指標失敗:', error) + } + }, + + /** + * 清理舊檔案 + */ + async cleanupOldFiles() { + try { + const response = await adminAPI.cleanupOldFiles() + + if (response.success) { + ElMessage.success('檔案清理完成') + return response.data + } + } catch (error) { + console.error('清理檔案失敗:', error) + ElMessage.error('清理檔案失敗') + } + }, + + /** + * 重置管理員資料 + */ + resetAdminData() { + this.stats = null + this.users = [] + this.allJobs = [] + this.systemLogs = [] + this.apiUsageStats = [] + this.costReport = null + this.systemHealth = null + this.systemMetrics = null + } + } +}) \ No newline at end of file diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..8e8f513 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,193 @@ +import { defineStore } from 'pinia' +import { authAPI } from '@/services/auth' +import { ElMessage } from 'element-plus' +import Cookies from 'js-cookie' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + user: null, + isAuthenticated: false, + token: null, + refreshToken: null, + loading: false + }), + + getters: { + isAdmin: (state) => state.user?.is_admin || false, + userName: (state) => state.user?.display_name || '', + userEmail: (state) => state.user?.email || '', + department: (state) => state.user?.department || '' + }, + + actions: { + /** + * 使用者登入 + * @param {Object} credentials - 登入憑證 + */ + async login(credentials) { + try { + this.loading = true + console.log('🔑 [Auth] 開始登入流程', credentials.username) + + const response = await authAPI.login(credentials) + console.log('🔑 [Auth] 登入 API 回應', response) + + if (response.success) { + this.user = response.data.user + this.token = response.data.access_token // 改為使用 access_token + this.refreshToken = response.data.refresh_token // 儲存 refresh_token + this.isAuthenticated = true + + console.log('🔑 [Auth] 設定認證狀態', { + user: this.user, + token: this.token ? `${this.token.substring(0, 20)}...` : null, + isAuthenticated: this.isAuthenticated + }) + + // 儲存認證資訊到 localStorage + localStorage.setItem('auth_user', JSON.stringify(response.data.user)) + localStorage.setItem('auth_token', this.token) + localStorage.setItem('auth_refresh_token', this.refreshToken) + localStorage.setItem('auth_authenticated', 'true') + + // JWT 不需要 cookie,移除 cookie 設定 + + console.log('🔑 [Auth] 登入成功,JWT tokens 已儲存') + ElMessage.success(response.message || '登入成功') + return response.data + } else { + throw new Error(response.message || '登入失敗') + } + } catch (error) { + console.error('❌ [Auth] 登入錯誤:', error) + this.clearAuth() + throw error + } finally { + this.loading = false + } + }, + + /** + * 使用者登出 + * @param {boolean} showMessage - 是否顯示登出訊息(預設為 true) + * @param {boolean} isAutoLogout - 是否為自動登出(預設為 false) + */ + async logout(showMessage = true, isAutoLogout = false) { + try { + console.log('🚪 [Auth] 開始登出流程', { showMessage, isAutoLogout }) + + // 只有手動登出時才呼叫 API + if (!isAutoLogout) { + await authAPI.logout() + console.log('🚪 [Auth] 登出 API 完成') + } + } catch (error) { + // 登出 API 失敗不影響本地清除動作,且不顯示錯誤 + console.error('❌ [Auth] 登出 API 錯誤(已忽略):', error) + } finally { + console.log('🚪 [Auth] 清除認證資料') + this.clearAuth() + + // 只在需要時顯示訊息 + if (showMessage && !isAutoLogout) { + ElMessage.success('已安全登出') + } + } + }, + + /** + * 檢查認證狀態 + */ + async checkAuth() { + try { + // 先檢查 localStorage 中的認證資訊 + const authUser = localStorage.getItem('auth_user') + const authToken = localStorage.getItem('auth_token') + const authRefreshToken = localStorage.getItem('auth_refresh_token') + const authAuthenticated = localStorage.getItem('auth_authenticated') + + if (!authUser || !authToken || authAuthenticated !== 'true') { + return false + } + + // 恢復認證狀態 + this.user = JSON.parse(authUser) + this.token = authToken + this.refreshToken = authRefreshToken + this.isAuthenticated = true + + console.log('🔑 [Auth] 從 localStorage 恢復認證狀態', { + user: this.user, + hasToken: !!this.token, + hasRefreshToken: !!this.refreshToken + }) + + return true + + } catch (error) { + console.error('❌ [Auth] 認證檢查失敗:', error) + this.clearAuth() + return false + } + }, + + /** + * 刷新用戶資訊 + */ + async refreshUser() { + try { + const response = await authAPI.getCurrentUser() + + if (response.success && response.data.user) { + this.user = response.data.user + } + } catch (error) { + console.error('刷新用戶資訊失敗:', error) + this.clearAuth() + } + }, + + /** + * 清除認證資訊 + */ + clearAuth() { + console.log('🧡 [Auth] 清除認證資料前', { + user: this.user, + token: this.token, + refreshToken: this.refreshToken, + isAuthenticated: this.isAuthenticated + }) + + this.user = null + this.token = null + this.refreshToken = null + this.isAuthenticated = false + this.loading = false + + // 清除所有認證相關的存儲 + localStorage.removeItem('auth_user') + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_refresh_token') + localStorage.removeItem('auth_authenticated') + + console.log('🧡 [Auth] JWT 認證資料已清除') + }, + + /** + * 更新用戶資訊 + * @param {Object} userData - 用戶資料 + */ + updateUser(userData) { + if (this.user) { + this.user = { ...this.user, ...userData } + } + } + }, + + // 持久化設定(可選) + persist: { + key: 'auth_store', + storage: localStorage, + paths: ['user', 'isAuthenticated'] // 只持久化這些欄位 + } +}) \ No newline at end of file diff --git a/frontend/src/stores/jobs.js b/frontend/src/stores/jobs.js new file mode 100644 index 0000000..4270dbf --- /dev/null +++ b/frontend/src/stores/jobs.js @@ -0,0 +1,411 @@ +import { defineStore } from 'pinia' +import { jobsAPI, filesAPI } from '@/services/jobs' +import { ElMessage, ElNotification } from 'element-plus' +import { saveAs } from 'file-saver' + +export const useJobsStore = defineStore('jobs', { + state: () => ({ + jobs: [], + currentJob: null, + pagination: { + page: 1, + per_page: 20, + total: 0, + pages: 0 + }, + loading: false, + uploadProgress: 0, + filters: { + status: 'all', + search: '' + }, + // 輪詢管理 + pollingIntervals: new Map() // 存儲每個任務的輪詢間隔 ID + }), + + getters: { + // 按狀態分組的任務 + pendingJobs: (state) => state.jobs.filter(job => job.status === 'PENDING'), + processingJobs: (state) => state.jobs.filter(job => job.status === 'PROCESSING'), + completedJobs: (state) => state.jobs.filter(job => job.status === 'COMPLETED'), + failedJobs: (state) => state.jobs.filter(job => job.status === 'FAILED'), + retryJobs: (state) => state.jobs.filter(job => job.status === 'RETRY'), + + // 根據 UUID 查找任務 + getJobByUuid: (state) => (uuid) => { + return state.jobs.find(job => job.job_uuid === uuid) + }, + + // 統計資訊 + jobStats: (state) => ({ + total: state.jobs.length, + pending: state.jobs.filter(job => job.status === 'PENDING').length, + processing: state.jobs.filter(job => job.status === 'PROCESSING').length, + completed: state.jobs.filter(job => job.status === 'COMPLETED').length, + failed: state.jobs.filter(job => job.status === 'FAILED').length + }) + }, + + actions: { + /** + * 取得任務列表 + * @param {Object} options - 查詢選項 + */ + async fetchJobs(options = {}) { + try { + this.loading = true + + const params = { + page: options.page || this.pagination.page, + per_page: options.per_page || this.pagination.per_page, + status: options.status || this.filters.status + } + + const response = await jobsAPI.getJobs(params) + + if (response.success) { + this.jobs = response.data.jobs + this.pagination = response.data.pagination + return response.data + } + } catch (error) { + console.error('取得任務列表失敗:', error) + ElMessage.error('載入任務列表失敗') + } finally { + this.loading = false + } + }, + + /** + * 上傳檔案 + * @param {FormData} formData - 表單資料 + * @param {Function} onProgress - 進度回調 + */ + async uploadFile(formData, onProgress) { + try { + this.uploadProgress = 0 + + // 設定進度回調 + if (onProgress) { + formData.onUploadProgress = (progressEvent) => { + const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total) + this.uploadProgress = progress + onProgress(progress) + } + } + + const response = await jobsAPI.uploadFile(formData) + + if (response.success) { + // 將新任務添加到列表頂部 + const newJob = response.data + this.jobs.unshift(newJob) + + ElMessage.success('檔案上傳成功,已加入翻譯佇列') + return newJob + } + } catch (error) { + console.error('檔案上傳失敗:', error) + throw error + } finally { + this.uploadProgress = 0 + } + }, + + /** + * 取得任務詳情 + * @param {string} jobUuid - 任務 UUID + */ + async fetchJobDetail(jobUuid) { + try { + const response = await jobsAPI.getJobDetail(jobUuid) + + if (response && response.success) { + this.currentJob = response.data.job + return response.data + } else { + console.error('API 響應格式錯誤:', response) + throw new Error('API 響應格式錯誤') + } + } catch (error) { + console.error('取得任務詳情失敗:', error) + ElMessage.error('載入任務詳情失敗') + throw error + } + }, + + /** + * 重試失敗任務 + * @param {string} jobUuid - 任務 UUID + */ + async retryJob(jobUuid) { + try { + const response = await jobsAPI.retryJob(jobUuid) + + if (response.success) { + // 更新本地任務狀態 + const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid) + if (jobIndex !== -1) { + this.jobs[jobIndex] = { ...this.jobs[jobIndex], ...response.data } + } + + ElMessage.success('任務已重新加入佇列') + return response.data + } + } catch (error) { + console.error('重試任務失敗:', error) + ElMessage.error('重試任務失敗') + } + }, + + /** + * 取消任務 + * @param {string} jobUuid - 任務 UUID + */ + async cancelJob(jobUuid) { + try { + const response = await jobsAPI.cancelJob(jobUuid) + + if (response.success) { + const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid) + if (jobIndex !== -1) { + this.jobs[jobIndex] = { + ...this.jobs[jobIndex], + status: 'FAILED', + error_message: '使用者取消任務' + } + } + + ElMessage.success('任務已取消') + } + } catch (error) { + console.error('取消任務失敗:', error) + ElMessage.error('取消任務失敗') + } + }, + + /** + * 刪除任務 + * @param {string} jobUuid - 任務 UUID + */ + async deleteJob(jobUuid) { + try { + const response = await jobsAPI.deleteJob(jobUuid) + + if (response.success) { + // 先停止輪詢 + this.unsubscribeFromJobUpdates(jobUuid) + + // 從列表中移除任務 + const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid) + if (jobIndex !== -1) { + this.jobs.splice(jobIndex, 1) + } + + ElMessage.success('任務已刪除') + } + } catch (error) { + console.error('刪除任務失敗:', error) + ElMessage.error('刪除任務失敗') + } + }, + + /** + * 下載檔案 + * @param {string} jobUuid - 任務 UUID + * @param {string} languageCode - 語言代碼 + * @param {string} filename - 檔案名稱 + */ + async downloadFile(jobUuid, languageCode, filename) { + try { + const response = await filesAPI.downloadFile(jobUuid, languageCode) + + // 使用 FileSaver.js 下載檔案 + const blob = new Blob([response], { type: 'application/octet-stream' }) + saveAs(blob, filename) + + ElMessage.success('檔案下載完成') + } catch (error) { + console.error('下載檔案失敗:', error) + ElMessage.error('檔案下載失敗') + } + }, + + /** + * 批量下載檔案 + * @param {string} jobUuid - 任務 UUID + * @param {string} filename - 壓縮檔名稱 + */ + async downloadAllFiles(jobUuid, filename) { + try { + const response = await filesAPI.downloadAllFiles(jobUuid) + + const blob = new Blob([response], { type: 'application/zip' }) + saveAs(blob, filename || `${jobUuid}.zip`) + + ElMessage.success('檔案打包下載完成') + } catch (error) { + console.error('批量下載失敗:', error) + ElMessage.error('批量下載失敗') + } + }, + + /** + * 更新任務狀態(用於 WebSocket 即時更新) + * @param {string} jobUuid - 任務 UUID + * @param {Object} statusUpdate - 狀態更新資料 + */ + updateJobStatus(jobUuid, statusUpdate) { + const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid) + + if (jobIndex !== -1) { + this.jobs[jobIndex] = { ...this.jobs[jobIndex], ...statusUpdate } + + // 如果是當前查看的任務詳情,也要更新 + if (this.currentJob && this.currentJob.job_uuid === jobUuid) { + this.currentJob = { ...this.currentJob, ...statusUpdate } + } + + // 任務完成時顯示通知 + if (statusUpdate.status === 'COMPLETED') { + ElNotification({ + title: '翻譯完成', + message: `檔案「${this.jobs[jobIndex].original_filename}」翻譯完成`, + type: 'success', + duration: 5000 + }) + } else if (statusUpdate.status === 'FAILED') { + ElNotification({ + title: '翻譯失敗', + message: `檔案「${this.jobs[jobIndex].original_filename}」翻譯失敗`, + type: 'error', + duration: 5000 + }) + } + } + }, + + /** + * 設定篩選條件 + * @param {Object} filters - 篩選條件 + */ + setFilters(filters) { + this.filters = { ...this.filters, ...filters } + }, + + /** + * 訂閱任務更新 (輪詢機制) + * @param {string} jobUuid - 任務 UUID + */ + subscribeToJobUpdates(jobUuid) { + // 如果已經在輪詢這個任務,先停止舊的輪詢 + if (this.pollingIntervals.has(jobUuid)) { + this.unsubscribeFromJobUpdates(jobUuid) + } + + console.log(`[DEBUG] 開始訂閱任務更新: ${jobUuid}`) + + const pollInterval = setInterval(async () => { + try { + const job = await this.fetchJobDetail(jobUuid) + + if (job) { + // 任務存在,更新本地狀態 + const existingJobIndex = this.jobs.findIndex(j => j.job_uuid === jobUuid) + if (existingJobIndex !== -1) { + // 更新現有任務 + this.jobs[existingJobIndex] = { ...this.jobs[existingJobIndex], ...job } + } + + // 檢查任務是否已完成 + if (['COMPLETED', 'FAILED'].includes(job.status)) { + console.log(`[DEBUG] 任務 ${jobUuid} 已完成 (${job.status}),停止輪詢`) + this.unsubscribeFromJobUpdates(jobUuid) + + // 顯示完成通知 + if (job.status === 'COMPLETED') { + ElNotification({ + title: '翻譯完成', + message: `檔案 "${job.original_filename}" 翻譯完成`, + type: 'success', + duration: 5000 + }) + } + } + } else { + // 任務不存在(可能被刪除),停止輪詢 + console.log(`[DEBUG] 任務 ${jobUuid} 不存在,停止輪詢`) + this.unsubscribeFromJobUpdates(jobUuid) + + // 從本地列表中移除任務 + const existingJobIndex = this.jobs.findIndex(j => j.job_uuid === jobUuid) + if (existingJobIndex !== -1) { + this.jobs.splice(existingJobIndex, 1) + } + } + } catch (error) { + console.error(`輪詢任務 ${jobUuid} 狀態失敗:`, error) + + // 檢查是否是 404 錯誤(任務不存在) + if (error.response?.status === 404) { + console.log(`[DEBUG] 任務 ${jobUuid} 已被刪除,停止輪詢`) + this.unsubscribeFromJobUpdates(jobUuid) + + // 從本地列表中移除任務 + const existingJobIndex = this.jobs.findIndex(j => j.job_uuid === jobUuid) + if (existingJobIndex !== -1) { + this.jobs.splice(existingJobIndex, 1) + } + } else { + // 其他錯誤,繼續輪詢但記錄錯誤 + console.warn(`輪詢任務 ${jobUuid} 時發生錯誤,將繼續重試:`, error.message) + } + } + }, 3000) // 每 3 秒檢查一次 + + // 儲存輪詢間隔 ID + this.pollingIntervals.set(jobUuid, pollInterval) + }, + + /** + * 取消訂閱任務更新 + * @param {string} jobUuid - 任務 UUID + */ + unsubscribeFromJobUpdates(jobUuid) { + const intervalId = this.pollingIntervals.get(jobUuid) + if (intervalId) { + clearInterval(intervalId) + this.pollingIntervals.delete(jobUuid) + console.log(`[DEBUG] 已取消任務 ${jobUuid} 的輪詢訂閱`) + } + }, + + /** + * 停止所有輪詢 + */ + stopAllPolling() { + for (const [jobUuid, intervalId] of this.pollingIntervals) { + clearInterval(intervalId) + console.log(`[DEBUG] 已停止任務 ${jobUuid} 的輪詢`) + } + this.pollingIntervals.clear() + }, + + /** + * 重置任務列表 + */ + resetJobs() { + // 先停止所有輪詢 + this.stopAllPolling() + + this.jobs = [] + this.currentJob = null + this.pagination = { + page: 1, + per_page: 20, + total: 0, + pages: 0 + } + } + } +}) \ No newline at end of file diff --git a/frontend/src/stores/notification.js b/frontend/src/stores/notification.js new file mode 100644 index 0000000..2144dec --- /dev/null +++ b/frontend/src/stores/notification.js @@ -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' + } + } +}) \ No newline at end of file diff --git a/frontend/src/style/components.scss b/frontend/src/style/components.scss new file mode 100644 index 0000000..21def24 --- /dev/null +++ b/frontend/src/style/components.scss @@ -0,0 +1,325 @@ +// 組件樣式 + +// 狀態標籤樣式 +.status-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: $border-radius-base; + font-size: $font-size-small; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + + &.pending { + background-color: map-get($status-colors, 'PENDING'); + color: white; + } + + &.processing { + background-color: map-get($status-colors, 'PROCESSING'); + color: white; + } + + &.completed { + background-color: map-get($status-colors, 'COMPLETED'); + color: white; + } + + &.failed { + background-color: map-get($status-colors, 'FAILED'); + color: white; + } + + &.retry { + background-color: map-get($status-colors, 'RETRY'); + color: white; + } +} + +// 檔案圖示樣式 +.file-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: $border-radius-base; + color: white; + font-size: $font-size-small; + font-weight: bold; + + &.docx, &.doc { + background-color: map-get($file-type-colors, 'docx'); + } + + &.pptx, &.ppt { + background-color: map-get($file-type-colors, 'pptx'); + } + + &.xlsx, &.xls { + background-color: map-get($file-type-colors, 'xlsx'); + } + + &.pdf { + background-color: map-get($file-type-colors, 'pdf'); + } +} + +// 進度條樣式 +.progress-bar { + width: 100%; + height: 6px; + background-color: $border-color-lighter; + border-radius: 3px; + overflow: hidden; + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, $primary-color, lighten($primary-color, 10%)); + border-radius: 3px; + transition: width 0.3s ease; + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: linear-gradient( + -45deg, + rgba(255, 255, 255, 0.2) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.2) 50%, + rgba(255, 255, 255, 0.2) 75%, + transparent 75%, + transparent + ); + background-size: 20px 20px; + animation: progress-stripes 1s linear infinite; + } + } +} + +@keyframes progress-stripes { + 0% { background-position: 0 0; } + 100% { background-position: 20px 0; } +} + +// 上傳區域樣式 +.upload-area { + border: 2px dashed $border-color; + border-radius: $border-radius-base; + background-color: $bg-color-light; + transition: all $transition-duration-base; + + &:hover, &.dragover { + border-color: $primary-color; + background-color: rgba($primary-color, 0.05); + } + + &.disabled { + border-color: $border-color-lighter; + background-color: $border-color-extra-light; + cursor: not-allowed; + + * { + pointer-events: none; + } + } +} + +// 任務卡片樣式 +.job-card { + @include card-style; + margin-bottom: $spacing-md; + cursor: pointer; + position: relative; + + &:hover { + border-color: $primary-color; + transform: translateY(-1px); + } + + .job-header { + @include flex-between; + margin-bottom: $spacing-sm; + + .job-title { + font-weight: 600; + color: $text-color-primary; + @include text-ellipsis; + max-width: 60%; + } + + .job-actions { + display: flex; + gap: $spacing-xs; + } + } + + .job-info { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $spacing-sm; + font-size: $font-size-small; + color: $text-color-secondary; + + @include respond-to(sm) { + grid-template-columns: 1fr; + } + } + + .job-progress { + margin-top: $spacing-sm; + + .progress-text { + @include flex-between; + font-size: $font-size-small; + color: $text-color-secondary; + margin-bottom: $spacing-xs; + } + } + + .job-footer { + @include flex-between; + margin-top: $spacing-sm; + padding-top: $spacing-sm; + border-top: 1px solid $border-color-lighter; + + .job-time { + font-size: $font-size-small; + color: $text-color-secondary; + } + } +} + +// 統計卡片樣式 +.stat-card { + @include card-style($spacing-lg); + text-align: center; + + .stat-icon { + width: 48px; + height: 48px; + margin: 0 auto $spacing-sm; + border-radius: 50%; + @include flex-center; + + &.primary { background-color: rgba($primary-color, 0.1); color: $primary-color; } + &.success { background-color: rgba($success-color, 0.1); color: $success-color; } + &.warning { background-color: rgba($warning-color, 0.1); color: $warning-color; } + &.danger { background-color: rgba($danger-color, 0.1); color: $danger-color; } + &.info { background-color: rgba($info-color, 0.1); color: $info-color; } + } + + .stat-value { + font-size: $font-size-extra-large; + font-weight: bold; + color: $text-color-primary; + margin-bottom: $spacing-xs; + } + + .stat-label { + font-size: $font-size-small; + color: $text-color-secondary; + margin-bottom: $spacing-sm; + } + + .stat-change { + font-size: $font-size-small; + + &.positive { color: $success-color; } + &.negative { color: $danger-color; } + } +} + +// 空狀態樣式 +.empty-state { + text-align: center; + padding: $spacing-xxl * 2; + color: $text-color-secondary; + + .empty-icon { + font-size: 64px; + color: $border-color; + margin-bottom: $spacing-lg; + } + + .empty-title { + font-size: $font-size-large; + color: $text-color-primary; + margin-bottom: $spacing-sm; + } + + .empty-description { + font-size: $font-size-base; + line-height: 1.6; + margin-bottom: $spacing-lg; + } +} + +// 語言標籤樣式 +.language-tag { + display: inline-block; + padding: 2px 6px; + margin: 2px; + background-color: $primary-color; + color: white; + border-radius: $border-radius-small; + font-size: $font-size-small; + + &:last-child { + margin-right: 0; + } +} + +// 載入覆蓋層 +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(2px); + @include flex-center; + z-index: $z-index-modal; + + .loading-content { + text-align: center; + + .loading-spinner { + @include loading-spinner(32px); + margin: 0 auto $spacing-md; + } + + .loading-text { + color: $text-color-secondary; + font-size: $font-size-base; + } + } +} + +// 工具提示樣式覆蓋 +.custom-tooltip { + &.el-popper { + max-width: 300px; + + .el-popper__arrow::before { + border-color: rgba(0, 0, 0, 0.8); + } + } + + .el-tooltip__content { + background-color: rgba(0, 0, 0, 0.8); + color: white; + border-radius: $border-radius-base; + padding: $spacing-sm $spacing-md; + font-size: $font-size-small; + line-height: 1.4; + } +} \ No newline at end of file diff --git a/frontend/src/style/layouts.scss b/frontend/src/style/layouts.scss new file mode 100644 index 0000000..f0b63f0 --- /dev/null +++ b/frontend/src/style/layouts.scss @@ -0,0 +1,461 @@ +// 布局樣式 + +// 主要布局容器 +.app-layout { + display: flex; + height: 100vh; + overflow: hidden; + + // 側邊欄 + .layout-sidebar { + width: 240px; + background-color: $sidebar-bg; + color: $sidebar-text-color; + display: flex; + flex-direction: column; + transition: width $transition-duration-base; + z-index: $z-index-top; + + &.collapsed { + width: 64px; + } + + @include respond-to(md) { + position: fixed; + top: 0; + left: 0; + bottom: 0; + transform: translateX(-100%); + + &.mobile-show { + transform: translateX(0); + } + } + + .sidebar-header { + padding: $spacing-lg; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + @include flex-center; + + .logo { + display: flex; + align-items: center; + color: white; + font-size: $font-size-large; + font-weight: bold; + text-decoration: none; + + .logo-icon { + width: 32px; + height: 32px; + margin-right: $spacing-sm; + background: linear-gradient(45deg, $primary-color, lighten($primary-color, 10%)); + border-radius: $border-radius-base; + @include flex-center; + color: white; + } + + .logo-text { + transition: opacity $transition-duration-base; + + .collapsed & { + opacity: 0; + width: 0; + overflow: hidden; + } + } + } + } + + .sidebar-menu { + flex: 1; + padding: $spacing-lg 0; + overflow-y: auto; + @include custom-scrollbar(rgba(255, 255, 255, 0.3), transparent, 4px); + + .menu-item { + display: block; + padding: $spacing-md $spacing-lg; + color: $sidebar-text-color; + text-decoration: none; + transition: all $transition-duration-fast; + position: relative; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + color: white; + } + + &.active { + background-color: rgba($primary-color, 0.2); + color: $primary-color; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background-color: $primary-color; + } + } + + .menu-icon { + width: 20px; + margin-right: $spacing-sm; + text-align: center; + transition: margin-right $transition-duration-base; + + .collapsed & { + margin-right: 0; + } + } + + .menu-text { + transition: opacity $transition-duration-base; + + .collapsed & { + opacity: 0; + width: 0; + overflow: hidden; + } + } + } + } + + .sidebar-footer { + padding: $spacing-lg; + border-top: 1px solid rgba(255, 255, 255, 0.1); + + .collapse-toggle { + width: 100%; + padding: $spacing-sm; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: $border-radius-base; + color: $sidebar-text-color; + cursor: pointer; + transition: all $transition-duration-fast; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.3); + } + } + } + } + + // 主要內容區 + .layout-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background-color: $bg-color-page; + + // 頂部導航欄 + .layout-header { + height: 60px; + background-color: $header-bg; + border-bottom: 1px solid $border-color-lighter; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + @include flex-between; + padding: 0 $spacing-lg; + z-index: $z-index-normal; + + @include respond-to(md) { + padding: 0 $spacing-md; + } + + .header-left { + display: flex; + align-items: center; + + .menu-toggle { + display: none; + padding: $spacing-sm; + background: transparent; + border: none; + cursor: pointer; + margin-right: $spacing-md; + + @include respond-to(md) { + display: block; + } + } + + .breadcrumb { + display: flex; + align-items: center; + font-size: $font-size-base; + color: $text-color-secondary; + + .breadcrumb-item { + &:not(:last-child)::after { + content: '/'; + margin: 0 $spacing-sm; + color: $text-color-placeholder; + } + + &:last-child { + color: $text-color-primary; + font-weight: 500; + } + } + } + } + + .header-right { + display: flex; + align-items: center; + gap: $spacing-md; + + .notification-bell { + position: relative; + cursor: pointer; + padding: $spacing-sm; + border-radius: $border-radius-base; + transition: background-color $transition-duration-fast; + + &:hover { + background-color: $bg-color-light; + } + + .badge { + position: absolute; + top: 2px; + right: 2px; + width: 8px; + height: 8px; + background-color: $danger-color; + border-radius: 50%; + } + } + + .user-avatar { + cursor: pointer; + + .avatar-button { + display: flex; + align-items: center; + padding: $spacing-sm; + border-radius: $border-radius-base; + transition: background-color $transition-duration-fast; + + &:hover { + background-color: $bg-color-light; + } + + .avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(45deg, $primary-color, lighten($primary-color, 10%)); + @include flex-center; + color: white; + font-weight: bold; + margin-right: $spacing-sm; + + @include respond-to(sm) { + margin-right: 0; + } + } + + .user-info { + @include respond-to(sm) { + display: none; + } + + .user-name { + font-size: $font-size-base; + font-weight: 500; + color: $text-color-primary; + line-height: 1.2; + } + + .user-role { + font-size: $font-size-small; + color: $text-color-secondary; + line-height: 1.2; + } + } + } + } + } + } + + // 內容區域 + .layout-content { + flex: 1; + overflow: hidden; + position: relative; + + .content-wrapper { + height: 100%; + overflow: auto; + padding: $spacing-lg; + + @include respond-to(md) { + padding: $spacing-md; + } + + @include respond-to(sm) { + padding: $spacing-sm; + } + } + } + } +} + +// 移動設備遮罩 +.mobile-mask { + display: none; + + @include respond-to(md) { + display: block; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: $z-index-top - 1; + opacity: 0; + visibility: hidden; + transition: all $transition-duration-base; + + &.show { + opacity: 1; + visibility: visible; + } + } +} + +// 登入頁面布局 +.login-layout { + min-height: 100vh; + background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%); + @include flex-center; + padding: $spacing-lg; + + .login-container { + width: 100%; + max-width: 400px; + background: white; + border-radius: $border-radius-base * 2; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); + overflow: hidden; + + .login-header { + background: linear-gradient(45deg, #1a1a2e, #16213e); + padding: $spacing-xxl; + text-align: center; + color: white; + + .login-logo { + width: 200px; + height: 80px; + margin: 0 auto $spacing-lg; + @include flex-center; + + img { + width: 100%; + height: 100%; + object-fit: contain; + // 移除濾鏡,讓白色 LOGO 在深色背景上自然顯示 + } + } + + .login-title { + font-size: $font-size-extra-large; + font-weight: bold; + margin-bottom: $spacing-sm; + } + + .login-subtitle { + font-size: $font-size-base; + opacity: 0.9; + } + } + + .login-body { + padding: $spacing-xxl; + } + + .login-footer { + padding: $spacing-lg $spacing-xxl; + background-color: $bg-color-light; + text-align: center; + color: $text-color-secondary; + font-size: $font-size-small; + } + } +} + +// 頁面標題區域 +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: $spacing-lg; + padding-bottom: $spacing-md; + border-bottom: 1px solid $border-color-lighter; + + @include respond-to(sm) { + flex-direction: column; + align-items: flex-start; + gap: $spacing-md; + } + + .page-title { + font-size: $font-size-extra-large; + font-weight: bold; + color: $text-color-primary; + margin: 0; + } + + .page-actions { + display: flex; + gap: $spacing-sm; + } +} + +// 內容卡片 +.content-card { + @include card-style; + + &:not(:last-child) { + margin-bottom: $spacing-lg; + } + + .card-header { + @include flex-between; + margin-bottom: $spacing-lg; + padding-bottom: $spacing-md; + border-bottom: 1px solid $border-color-lighter; + + .card-title { + font-size: $font-size-large; + font-weight: 600; + color: $text-color-primary; + margin: 0; + } + + .card-actions { + display: flex; + gap: $spacing-sm; + } + } + + .card-body { + // 內容樣式由具體組件定義 + } + + .card-footer { + margin-top: $spacing-lg; + padding-top: $spacing-md; + border-top: 1px solid $border-color-lighter; + @include flex-between; + } +} \ No newline at end of file diff --git a/frontend/src/style/main.scss b/frontend/src/style/main.scss new file mode 100644 index 0000000..68a1332 --- /dev/null +++ b/frontend/src/style/main.scss @@ -0,0 +1,187 @@ +// 主要樣式文件 +@import './variables.scss'; +@import './mixins.scss'; +@import './components.scss'; +@import './layouts.scss'; + +// 全局重置樣式 +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + height: 100%; + font-size: 14px; +} + +body { + height: 100%; + font-family: $font-family; + background-color: var(--el-bg-color-page); + color: var(--el-text-color-primary); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#app { + height: 100%; +} + +// 滾動條樣式 +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--el-fill-color-lighter); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: var(--el-border-color); + border-radius: 4px; + + &:hover { + background: var(--el-border-color-darker); + } +} + +// Firefox 滾動條 +* { + scrollbar-width: thin; + scrollbar-color: var(--el-border-color) var(--el-fill-color-lighter); +} + +// 文字選擇顏色 +::selection { + background: var(--el-color-primary-light-8); + color: var(--el-color-primary); +} + +::-moz-selection { + background: var(--el-color-primary-light-8); + color: var(--el-color-primary); +} + +// 通用輔助類別 +.text-center { text-align: center; } +.text-left { text-align: left; } +.text-right { text-align: right; } + +.flex { display: flex; } +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} +.flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} +.flex-column { + display: flex; + flex-direction: column; +} + +// 間距輔助類別 +@for $i from 1 through 10 { + .m-#{$i} { margin: #{$i * 4}px; } + .mt-#{$i} { margin-top: #{$i * 4}px; } + .mr-#{$i} { margin-right: #{$i * 4}px; } + .mb-#{$i} { margin-bottom: #{$i * 4}px; } + .ml-#{$i} { margin-left: #{$i * 4}px; } + .mx-#{$i} { + margin-left: #{$i * 4}px; + margin-right: #{$i * 4}px; + } + .my-#{$i} { + margin-top: #{$i * 4}px; + margin-bottom: #{$i * 4}px; + } + + .p-#{$i} { padding: #{$i * 4}px; } + .pt-#{$i} { padding-top: #{$i * 4}px; } + .pr-#{$i} { padding-right: #{$i * 4}px; } + .pb-#{$i} { padding-bottom: #{$i * 4}px; } + .pl-#{$i} { padding-left: #{$i * 4}px; } + .px-#{$i} { + padding-left: #{$i * 4}px; + padding-right: #{$i * 4}px; + } + .py-#{$i} { + padding-top: #{$i * 4}px; + padding-bottom: #{$i * 4}px; + } +} + +// 響應式斷點 +.hidden-xs { + @include respond-to(xs) { display: none !important; } +} +.hidden-sm { + @include respond-to(sm) { display: none !important; } +} +.hidden-md { + @include respond-to(md) { display: none !important; } +} +.hidden-lg { + @include respond-to(lg) { display: none !important; } +} + +// 動畫類別 +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.3s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.slide-enter-active, +.slide-leave-active { + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.5, 1); +} + +.slide-enter-from { + transform: translateX(-20px); + opacity: 0; +} + +.slide-leave-to { + transform: translateX(20px); + opacity: 0; +} + +// 卡片陰影 +.card-shadow { + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); +} + +.card-hover-shadow { + transition: box-shadow 0.3s ease; + + &:hover { + box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15); + } +} + +// 載入狀態 +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; +} \ No newline at end of file diff --git a/frontend/src/style/mixins.scss b/frontend/src/style/mixins.scss new file mode 100644 index 0000000..7f70e34 --- /dev/null +++ b/frontend/src/style/mixins.scss @@ -0,0 +1,272 @@ +// SCSS Mixins 混合器 + +// 響應式斷點混合器 +@mixin respond-to($breakpoint) { + @if $breakpoint == xs { + @media (max-width: #{$breakpoint-xs - 1px}) { @content; } + } + @if $breakpoint == sm { + @media (max-width: #{$breakpoint-sm - 1px}) { @content; } + } + @if $breakpoint == md { + @media (max-width: #{$breakpoint-md - 1px}) { @content; } + } + @if $breakpoint == lg { + @media (max-width: #{$breakpoint-lg - 1px}) { @content; } + } + @if $breakpoint == xl { + @media (min-width: $breakpoint-xl) { @content; } + } +} + +// 最小寬度斷點 +@mixin respond-above($breakpoint) { + @if $breakpoint == xs { + @media (min-width: $breakpoint-xs) { @content; } + } + @if $breakpoint == sm { + @media (min-width: $breakpoint-sm) { @content; } + } + @if $breakpoint == md { + @media (min-width: $breakpoint-md) { @content; } + } + @if $breakpoint == lg { + @media (min-width: $breakpoint-lg) { @content; } + } +} + +// Flexbox 輔助混合器 +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +@mixin flex-start { + display: flex; + align-items: center; + justify-content: flex-start; +} + +@mixin flex-end { + display: flex; + align-items: center; + justify-content: flex-end; +} + +@mixin flex-column { + display: flex; + flex-direction: column; +} + +@mixin flex-column-center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +// 文字省略號 +@mixin text-ellipsis { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +@mixin multi-line-ellipsis($lines: 2) { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: $lines; + -webkit-box-orient: vertical; + text-overflow: ellipsis; +} + +// 清除浮動 +@mixin clearfix { + &::after { + content: ''; + display: table; + clear: both; + } +} + +// 隱藏滾動條 +@mixin hide-scrollbar { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE 10+ */ + + &::-webkit-scrollbar { + display: none; /* Chrome Safari */ + } +} + +// 自定義滾動條 +@mixin custom-scrollbar($thumb-color: $border-color, $track-color: transparent, $size: 6px) { + &::-webkit-scrollbar { + width: $size; + height: $size; + } + + &::-webkit-scrollbar-track { + background: $track-color; + border-radius: $size / 2; + } + + &::-webkit-scrollbar-thumb { + background: $thumb-color; + border-radius: $size / 2; + + &:hover { + background: darken($thumb-color, 10%); + } + } +} + +// 絕對定位置中 +@mixin absolute-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +@mixin absolute-center-x { + position: absolute; + left: 50%; + transform: translateX(-50%); +} + +@mixin absolute-center-y { + position: absolute; + top: 50%; + transform: translateY(-50%); +} + +// 固定比例容器 +@mixin aspect-ratio($width: 16, $height: 9) { + position: relative; + overflow: hidden; + + &::before { + content: ''; + display: block; + width: 100%; + padding-top: ($height / $width) * 100%; + } + + > * { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} + +// 過渡動畫 +@mixin transition($property: all, $duration: $transition-duration-base, $timing-function: ease-in-out) { + transition: $property $duration $timing-function; +} + +@mixin hover-lift { + transition: transform $transition-duration-fast ease-out, box-shadow $transition-duration-fast ease-out; + + &:hover { + transform: translateY(-2px); + box-shadow: $box-shadow-dark; + } +} + +// 按鈕樣式混合器 +@mixin button-variant($color, $background, $border: $background) { + color: $color; + background-color: $background; + border-color: $border; + + &:hover, + &:focus { + color: $color; + background-color: lighten($background, 5%); + border-color: lighten($border, 5%); + } + + &:active { + color: $color; + background-color: darken($background, 5%); + border-color: darken($border, 5%); + } +} + +// 狀態標籤樣式 +@mixin status-badge($color) { + display: inline-block; + padding: 2px 8px; + font-size: $font-size-small; + font-weight: 500; + color: white; + background-color: $color; + border-radius: $border-radius-base; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +// 卡片樣式 +@mixin card-style($padding: $spacing-lg, $border-radius: $border-radius-base) { + background: $bg-color; + border: 1px solid $border-color-lighter; + border-radius: $border-radius; + box-shadow: $box-shadow-light; + padding: $padding; + transition: box-shadow $transition-duration-base; + + &:hover { + box-shadow: $box-shadow-dark; + } +} + +// 表單輸入樣式 +@mixin form-input { + display: block; + width: 100%; + padding: 8px 12px; + font-size: $font-size-base; + line-height: $line-height-base; + color: $text-color-primary; + background-color: $bg-color; + border: 1px solid $border-color; + border-radius: $border-radius-base; + transition: border-color $transition-duration-fast, box-shadow $transition-duration-fast; + + &:focus { + outline: none; + border-color: $primary-color; + box-shadow: 0 0 0 2px rgba($primary-color, 0.2); + } + + &:disabled { + background-color: $bg-color-light; + color: $text-color-placeholder; + cursor: not-allowed; + } +} + +// Loading 動畫 +@mixin loading-spinner($size: 20px, $color: $primary-color) { + width: $size; + height: $size; + border: 2px solid transparent; + border-top-color: $color; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/frontend/src/style/variables.scss b/frontend/src/style/variables.scss new file mode 100644 index 0000000..90c47bc --- /dev/null +++ b/frontend/src/style/variables.scss @@ -0,0 +1,106 @@ +// SCSS 變數定義 + +// 顏色系統 - 調整為深色系 +$primary-color: #2c3e50; +$success-color: #27ae60; +$warning-color: #f39c12; +$danger-color: #e74c3c; +$info-color: #34495e; + +// 文字顏色 +$text-color-primary: #303133; +$text-color-regular: #606266; +$text-color-secondary: #909399; +$text-color-placeholder: #c0c4cc; + +// 背景顏色 +$bg-color-page: #f2f3f5; +$bg-color: #ffffff; +$bg-color-light: #fafafa; + +// 邊框顏色 +$border-color: #dcdfe6; +$border-color-light: #e4e7ed; +$border-color-lighter: #ebeef5; +$border-color-extra-light: #f2f6fc; + +// 字體 +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; +$font-size-extra-large: 20px; +$font-size-large: 18px; +$font-size-medium: 16px; +$font-size-base: 14px; +$font-size-small: 13px; +$font-size-extra-small: 12px; + +// 行高 +$line-height-base: 1.5; + +// 間距 +$spacing-base: 4px; +$spacing-xs: 4px; +$spacing-sm: 8px; +$spacing-md: 12px; +$spacing-lg: 16px; +$spacing-xl: 20px; +$spacing-xxl: 24px; + +// 邊框半徑 +$border-radius-base: 4px; +$border-radius-small: 2px; +$border-radius-round: 20px; +$border-radius-circle: 50%; + +// 陰影 +$box-shadow-base: 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04); +$box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, .1); +$box-shadow-dark: 0 4px 20px 0 rgba(0, 0, 0, .15); + +// z-index 層級 +$z-index-normal: 1; +$z-index-top: 1000; +$z-index-popper: 2000; +$z-index-modal: 3000; + +// 斷點 +$breakpoint-xs: 480px; +$breakpoint-sm: 768px; +$breakpoint-md: 992px; +$breakpoint-lg: 1200px; +$breakpoint-xl: 1920px; + +// 動畫持續時間 +$transition-duration-fast: 0.2s; +$transition-duration-base: 0.3s; +$transition-duration-slow: 0.5s; + +// 動畫緩動函數 +$ease-in-out-circ: cubic-bezier(0.78, 0.14, 0.15, 0.86); +$ease-out-back: cubic-bezier(0.12, 0.4, 0.29, 1.46); +$ease-in-out-back: cubic-bezier(0.71, -0.46, 0.29, 1.46); + +// 組件特定顏色 +$header-bg: #fff; +$sidebar-bg: #1a1a2e; +$sidebar-text-color: #bfcbd9; +$sidebar-active-color: #3498db; + +// 狀態顏色映射 +$status-colors: ( + 'PENDING': #7f8c8d, + 'PROCESSING': #3498db, + 'COMPLETED': #27ae60, + 'FAILED': #e74c3c, + 'RETRY': #f39c12 +); + +// 檔案類型圖示顏色 +$file-type-colors: ( + 'docx': #2b579a, + 'doc': #2b579a, + 'pptx': #d24726, + 'ppt': #d24726, + 'xlsx': #207245, + 'xls': #207245, + 'pdf': #ff0000 +); \ No newline at end of file diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js new file mode 100644 index 0000000..8d13ca0 --- /dev/null +++ b/frontend/src/utils/request.js @@ -0,0 +1,203 @@ +import axios from 'axios' +import { ElMessage, ElMessageBox } from 'element-plus' +import { useAuthStore } from '@/stores/auth' +import router from '@/router' +import NProgress from 'nprogress' + +// 創建 axios 實例 +const service = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:12010/api/v1', + timeout: 30000, // 30秒超時 + headers: { + 'Content-Type': 'application/json' + } +}) + +// 請求攔截器 +service.interceptors.request.use( + config => { + NProgress.start() + + + // JWT 認證:添加 Authorization header + const authStore = useAuthStore() + if (authStore.token) { + config.headers.Authorization = `Bearer ${authStore.token}` + } + + return config + }, + error => { + NProgress.done() + console.error('❌ [Request Error]:', error) + return Promise.reject(error) + } +) + +// 用於防止重複處理 401 錯誤 +let isHandling401 = false + +// 回應攔截器 +service.interceptors.response.use( + response => { + NProgress.done() + + console.log('✅ [API Response]', { + status: response.status, + statusText: response.statusText, + url: response.config.url, + method: response.config.method.toUpperCase(), + data: response.data, + headers: response.headers, + timestamp: new Date().toISOString() + }) + + const { data } = response + + // 後端統一回應格式處理 + if (data && typeof data === 'object') { + if (data.success === false) { + // 業務錯誤處理 + const message = data.message || '操作失敗' + console.warn('⚠️ [Business Error]:', message) + ElMessage.error(message) + return Promise.reject(new Error(message)) + } + + return data + } + + return response + }, + error => { + NProgress.done() + + const { response } = error + const authStore = useAuthStore() + + if (response) { + const { status, data } = response + + switch (status) { + case 401: + // 避免在登入頁面或登入過程中觸發自動登出 + const requestUrl = error.config?.url || '' + const currentPath = router.currentRoute.value.path + + console.error('🔐 [401 Unauthorized]', { + requestUrl, + currentPath, + isLoginPage: currentPath === '/login', + isLoginRequest: requestUrl.includes('/auth/login'), + isHandling401, + willTriggerLogout: currentPath !== '/login' && !requestUrl.includes('/auth/login') && !isHandling401, + timestamp: new Date().toISOString() + }) + + // 防止重複處理 + if (!isHandling401 && currentPath !== '/login' && !requestUrl.includes('/auth/login')) { + isHandling401 = true + console.error('🚪 [Auto Logout] 認證失效,觸發自動登出') + + // 只顯示一次訊息 + ElMessage.error('認證已過期,請重新登入') + + // 使用自動登出模式,不顯示額外訊息 + authStore.logout(false, true).finally(() => { + router.push('/login').then(() => { + // 導航完成後重置標記 + setTimeout(() => { + isHandling401 = false + }, 1000) + }) + }) + } else if (isHandling401) { + console.log('🔐 [401 Skipped] 已在處理其他 401 錯誤') + } else { + console.log('🔐 [401 Ignored] 在登入頁面或登入請求') + } + break + + case 403: + ElMessage.error('無權限存取此資源') + break + + case 404: + ElMessage.error('請求的資源不存在') + break + + case 422: + // 表單驗證錯誤 + const message = data.message || '輸入資料格式錯誤' + ElMessage.error(message) + break + + case 429: + ElMessage.error('請求過於頻繁,請稍後再試') + break + + case 500: + ElMessage.error('伺服器內部錯誤') + break + + case 502: + case 503: + case 504: + ElMessage.error('伺服器暫時無法存取,請稍後再試') + break + + default: + const errorMessage = data?.message || error.message || '網路錯誤' + ElMessage.error(errorMessage) + } + } else if (error.code === 'ECONNABORTED') { + ElMessage.error('請求超時,請檢查網路連線') + } else { + ElMessage.error('網路連線失敗,請檢查網路設定') + } + + return Promise.reject(error) + } +) + +// 檔案上傳專用請求實例 +export const uploadRequest = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:12010/api/v1', + timeout: 120000, // 2分鐘超時 + headers: { + 'Content-Type': 'multipart/form-data' + } +}) + +// 為上傳請求添加攔截器 +uploadRequest.interceptors.request.use( + config => { + // JWT 認證:添加 Authorization header + const authStore = useAuthStore() + if (authStore.token) { + config.headers.Authorization = `Bearer ${authStore.token}` + } + return config + }, + error => Promise.reject(error) +) + +uploadRequest.interceptors.response.use( + response => response.data, + error => { + const message = error.response?.data?.message || '檔案上傳失敗' + ElMessage.error(message) + return Promise.reject(error) + } +) + +// 常用請求方法封裝 +export const request = { + get: (url, config = {}) => service.get(url, config), + post: (url, data = {}, config = {}) => service.post(url, data, config), + put: (url, data = {}, config = {}) => service.put(url, data, config), + delete: (url, config = {}) => service.delete(url, config), + patch: (url, data = {}, config = {}) => service.patch(url, data, config) +} + +export default service \ No newline at end of file diff --git a/frontend/src/utils/websocket.js b/frontend/src/utils/websocket.js new file mode 100644 index 0000000..d281d1e --- /dev/null +++ b/frontend/src/utils/websocket.js @@ -0,0 +1,412 @@ +import { io } from 'socket.io-client' +import { useJobsStore } from '@/stores/jobs' +import { useNotificationStore } from '@/stores/notification' +import { ElMessage, ElNotification } from 'element-plus' + +/** + * WebSocket 服務類 + */ +class WebSocketService { + constructor() { + this.socket = null + this.isConnected = false + this.reconnectAttempts = 0 + this.maxReconnectAttempts = 5 + this.reconnectInterval = 5000 + this.jobSubscriptions = new Set() + } + + /** + * 初始化並連接 WebSocket + */ + connect() { + if (this.socket) { + return + } + + try { + // 建立 Socket.IO 連接 + const wsUrl = import.meta.env.VITE_WS_BASE_URL || 'http://127.0.0.1:12010' + console.log('🔌 [WebSocket] 嘗試連接到:', wsUrl) + + this.socket = io(wsUrl, { + path: '/socket.io/', + transports: ['polling'], + upgrade: false, + rememberUpgrade: false, + autoConnect: true, + forceNew: false, + reconnection: true, + reconnectionDelay: this.reconnectInterval, + reconnectionAttempts: this.maxReconnectAttempts + }) + + this.setupEventHandlers() + } catch (error) { + console.error('WebSocket 連接失敗:', error) + } + } + + /** + * 設定事件處理器 + */ + setupEventHandlers() { + if (!this.socket) return + + // 連接成功 + this.socket.on('connect', () => { + console.log('WebSocket 已連接') + this.isConnected = true + this.reconnectAttempts = 0 + + // 重新訂閱所有任務 + this.resubscribeJobs() + }) + + // 連接失敗 + this.socket.on('connect_error', (error) => { + console.error('WebSocket 連接錯誤:', error) + this.isConnected = false + }) + + // 斷線 + this.socket.on('disconnect', (reason) => { + console.log('WebSocket 已斷線:', reason) + this.isConnected = false + + if (reason === 'io server disconnect') { + // 服務器主動斷線,需要重新連接 + this.socket.connect() + } + }) + + // 任務狀態更新 + this.socket.on('job_status', (data) => { + this.handleJobStatusUpdate(data) + }) + + // 系統通知 + 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) => { + console.log('WebSocket 連接確認:', data) + }) + + // 訂閱成功回應 + this.socket.on('subscribed', (data) => { + console.log('任務訂閱成功:', data.job_uuid) + }) + + // 取消訂閱成功回應 + this.socket.on('unsubscribed', (data) => { + console.log('任務取消訂閱成功:', data.job_uuid) + }) + + // 錯誤處理 + this.socket.on('error', (error) => { + console.error('WebSocket 錯誤:', error) + ElMessage.error(error.message || 'WebSocket 連接錯誤') + }) + } + + /** + * 處理任務狀態更新 + * @param {Object} data - 狀態更新資料 + */ + handleJobStatusUpdate(data) { + try { + if (data.type === 'job_status' && data.data) { + const jobsStore = useJobsStore() + const { job_uuid, ...statusUpdate } = data.data + + // 更新任務狀態 + jobsStore.updateJobStatus(job_uuid, statusUpdate) + + console.log('任務狀態已更新:', job_uuid, statusUpdate) + } + } catch (error) { + console.error('處理任務狀態更新失敗:', error) + } + } + + /** + * 處理系統通知 + * @param {Object} data - 通知資料 + */ + handleSystemNotification(data) { + const { type, message, title, level } = data + + switch (level) { + case 'success': + ElNotification.success({ + title: title || '系統通知', + message: message, + duration: 5000 + }) + break + + case 'warning': + ElNotification.warning({ + title: title || '系統警告', + message: message, + duration: 8000 + }) + break + + case 'error': + ElNotification.error({ + title: title || '系統錯誤', + message: message, + duration: 10000 + }) + break + + default: + ElNotification({ + title: title || '系統消息', + message: message, + duration: 5000 + }) + } + } + + /** + * 處理新通知推送 + * @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 + */ + subscribeToJob(jobUuid) { + if (!this.socket || !this.isConnected) { + // 靜默處理,避免控制台警告 + return + } + + if (this.jobSubscriptions.has(jobUuid)) { + return // 已經訂閱過 + } + + this.socket.emit('subscribe_job', { job_uuid: jobUuid }) + this.jobSubscriptions.add(jobUuid) + } + + /** + * 取消訂閱任務狀態更新 + * @param {string} jobUuid - 任務 UUID + */ + unsubscribeFromJob(jobUuid) { + if (!this.socket || !this.isConnected) { + return + } + + this.socket.emit('unsubscribe_job', { job_uuid: jobUuid }) + this.jobSubscriptions.delete(jobUuid) + } + + /** + * 重新訂閱所有任務 + */ + resubscribeJobs() { + if (!this.isConnected) return + + this.jobSubscriptions.forEach(jobUuid => { + this.socket.emit('subscribe_job', { job_uuid: jobUuid }) + }) + } + + /** + * 批量訂閱任務 + * @param {string[]} jobUuids - 任務 UUID 陣列 + */ + subscribeToJobs(jobUuids) { + jobUuids.forEach(jobUuid => { + this.subscribeToJob(jobUuid) + }) + } + + /** + * 批量取消訂閱任務 + * @param {string[]} jobUuids - 任務 UUID 陣列 + */ + unsubscribeFromJobs(jobUuids) { + jobUuids.forEach(jobUuid => { + this.unsubscribeFromJob(jobUuid) + }) + } + + /** + * 發送自定義事件 + * @param {string} event - 事件名稱 + * @param {Object} data - 事件資料 + */ + emit(event, data) { + if (this.socket && this.isConnected) { + this.socket.emit(event, data) + } + } + + /** + * 監聽自定義事件 + * @param {string} event - 事件名稱 + * @param {Function} callback - 回調函數 + */ + on(event, callback) { + if (this.socket) { + this.socket.on(event, callback) + } + } + + /** + * 取消監聽事件 + * @param {string} event - 事件名稱 + * @param {Function} callback - 回調函數 + */ + off(event, callback) { + if (this.socket) { + this.socket.off(event, callback) + } + } + + /** + * 斷開連接 + */ + disconnect() { + if (this.socket) { + this.jobSubscriptions.clear() + this.socket.disconnect() + this.socket = null + this.isConnected = false + console.log('WebSocket 已主動斷開') + } + } + + /** + * 重新連接 + */ + reconnect() { + this.disconnect() + setTimeout(() => { + this.connect() + }, 1000) + } + + /** + * 取得連接狀態 + */ + getConnectionStatus() { + return { + isConnected: this.isConnected, + socket: this.socket, + subscriptions: Array.from(this.jobSubscriptions) + } + } +} + +// 創建全局實例 +export const websocketService = new WebSocketService() + +// 自動連接(在需要時) +export const initWebSocket = () => { + websocketService.connect() +} + +// 清理連接(在登出時) +export const cleanupWebSocket = () => { + websocketService.disconnect() +} + +export default websocketService \ No newline at end of file diff --git a/frontend/src/views/AdminJobsView.vue b/frontend/src/views/AdminJobsView.vue new file mode 100644 index 0000000..03a0a96 --- /dev/null +++ b/frontend/src/views/AdminJobsView.vue @@ -0,0 +1,538 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue new file mode 100644 index 0000000..5d7d3ad --- /dev/null +++ b/frontend/src/views/AdminView.vue @@ -0,0 +1,1035 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue new file mode 100644 index 0000000..d46fda3 --- /dev/null +++ b/frontend/src/views/HistoryView.vue @@ -0,0 +1,840 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..8e3a9ca --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,561 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/JobDetailView.vue b/frontend/src/views/JobDetailView.vue new file mode 100644 index 0000000..1541d74 --- /dev/null +++ b/frontend/src/views/JobDetailView.vue @@ -0,0 +1,917 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/JobListView.vue b/frontend/src/views/JobListView.vue new file mode 100644 index 0000000..2c2ba2c --- /dev/null +++ b/frontend/src/views/JobListView.vue @@ -0,0 +1,894 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..1e0ec7a --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,350 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/NotFoundView.vue b/frontend/src/views/NotFoundView.vue new file mode 100644 index 0000000..0392329 --- /dev/null +++ b/frontend/src/views/NotFoundView.vue @@ -0,0 +1,278 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue new file mode 100644 index 0000000..103d6f8 --- /dev/null +++ b/frontend/src/views/ProfileView.vue @@ -0,0 +1,562 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/UploadView.vue b/frontend/src/views/UploadView.vue new file mode 100644 index 0000000..acc1e05 --- /dev/null +++ b/frontend/src/views/UploadView.vue @@ -0,0 +1,865 @@ + + + + + \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..f169dc6 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,72 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import ElementPlus from 'unplugin-element-plus/vite' + +export default defineConfig({ + plugins: [ + vue(), + // Element Plus 自動導入 + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: [ + 'vue', + 'vue-router', + 'pinia', + { + axios: [ + 'default', + ['default', 'axios'] + ] + } + ], + dts: true + }), + Components({ + resolvers: [ElementPlusResolver()] + }), + ElementPlus({ + useSource: true + }) + ], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 3000, + host: '0.0.0.0', + proxy: { + '/api': { + target: 'http://127.0.0.1:5000', + changeOrigin: true, + secure: false + }, + '/socket.io': { + target: 'http://127.0.0.1:5000', + changeOrigin: true, + ws: true + } + } + }, + build: { + outDir: 'dist', + assetsDir: 'assets', + sourcemap: false, + rollupOptions: { + output: { + chunkFileNames: 'js/[name]-[hash].js', + entryFileNames: 'js/[name]-[hash].js', + assetFileNames: '[ext]/[name]-[hash].[ext]' + } + } + }, + define: { + __VUE_OPTIONS_API__: true, + __VUE_PROD_DEVTOOLS__: false + } +}) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e036ce6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,49 @@ +# Flask Framework +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 +Flask-Session==0.5.0 +Flask-Cors==4.0.0 +Flask-SocketIO==5.3.6 +Flask-JWT-Extended==4.6.0 + +# Database +PyMySQL==1.1.0 +SQLAlchemy==2.0.23 +Alembic==1.12.1 + +# Task Queue +Celery==5.3.4 +redis==5.0.1 + +# Authentication +ldap3==2.9.1 + +# File Processing +python-docx==1.1.0 +python-pptx==0.6.23 +openpyxl==3.1.2 +PyPDF2==3.0.1 + +# Translation & Language Processing +requests==2.31.0 +blingfire==0.1.8 +pysbd==0.3.4 + +# Utilities +python-dotenv==1.0.0 +Werkzeug==3.0.1 +gunicorn==21.2.0 +eventlet==0.33.3 + +# Email +Jinja2==3.1.2 + +# Testing +pytest==7.4.3 +pytest-flask==1.3.0 +pytest-mock==3.12.0 +coverage==7.3.2 + +# Development +black==23.11.0 +flake8==6.1.0 \ No newline at end of file diff --git a/update_db.py b/update_db.py new file mode 100644 index 0000000..c1fee24 --- /dev/null +++ b/update_db.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +更新數據庫模式,添加軟刪除字段 + +Author: PANJIT IT Team +""" + +from app import create_app, db + +if __name__ == '__main__': + app = create_app() + + with app.app_context(): + try: + # 檢查是否需要添加 deleted_at 字段 + from sqlalchemy import text + + # 檢查 deleted_at 字段是否存在(MySQL語法) + with db.engine.connect() as connection: + result = connection.execute(text(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'dt_translation_jobs' + """)) + columns = [row[0] for row in result.fetchall()] + + if 'deleted_at' not in columns: + print("添加 deleted_at 字段...") + connection.execute(text("ALTER TABLE dt_translation_jobs ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT '軟刪除時間'")) + connection.commit() + print("deleted_at 字段添加成功") + else: + print("deleted_at 字段已存在") + + # 確保所有表都是最新的 + db.create_all() + print("數據庫模式更新完成") + + except Exception as e: + print(f"更新數據庫模式時發生錯誤: {e}") \ No newline at end of file